mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-10 17:16:20 -07:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01fcacb6cf
|
||
|
|
cd7a65ec80
|
||
|
|
89479f7045
|
||
|
|
7bc9a07a52
|
||
|
|
68dd789fbf
|
||
|
|
516fadd530
|
||
|
|
18beac13f4
|
||
|
|
484a9e047d
|
@@ -5,12 +5,13 @@ area: anime
|
||||
- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
|
||||
- Anime extensions are read from `<userData>/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
|
||||
- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per extension and source, and updated extension schemas replace stale saved field definitions without losing values. Older unscoped preferences are discarded once because their package ownership cannot be proven safely.
|
||||
- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason. Repository requests time out instead of hanging, and APK updates are staged before replacing the installed copy.
|
||||
- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason. Repository requests time out instead of hanging, APK updates are staged before replacing the installed copy, and removing an extension refreshes the runtime even when saved credential cleanup fails while still reporting that failure.
|
||||
- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
|
||||
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
|
||||
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
|
||||
- The Extensions tab opens with an Installed section listing every extension on disk with Remove — including ones added by hand or whose repository has since been removed — and Update where a configured repository still carries it.
|
||||
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
||||
- 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. It compares APK and repository version codes, enables Update only for newer builds, marks current extensions as Up to date, and offers Update all when multiple updates are waiting.
|
||||
- Added `anime.repos`, `anime.extensionsDir`, `anime.preferredQuality`, and `anime.defaultSource` config keys; **Set default** on an Installed row in the Extensions tab makes that source (or All sources) the one the browser opens on, and the current default carries a tag. SubMiner ships no extension repositories and performs no discovery.
|
||||
- The settings app now groups Anime Browser config under an **Aniyomi** section in Integrations. A new `anime.autoOpenJimaku` option pauses newly loaded Anime Browser episodes, waits for mpv's video window to appear, closes the in-player browser, hides the standalone browser window so it is not pulled in front of mpv when the modals close, opens Jimaku with the episode details filled in, and resumes after a subtitle loads or the modal closes without overriding playback that was already paused.
|
||||
- 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 and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. Upstream responses use identity encoding so playlists remain readable for URL rewriting.
|
||||
@@ -28,6 +29,6 @@ area: anime
|
||||
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
||||
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
||||
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
|
||||
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal.
|
||||
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal. The SubMiner logo identifies the Anime Browser in both the modal and standalone window.
|
||||
- The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency.
|
||||
- SubMiner records which bridge release it installed and, once the bridge is running, checks GitHub for a newer one; when there is, the banner offers an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The updater waits for a bridge still starting to stop and keeps the previous bundle until the replacement is active. The Extensions tab shows the bridge version, where it lives, and who updates it without describing an unchecked install as current.
|
||||
|
||||
@@ -626,12 +626,14 @@
|
||||
// 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.
|
||||
// Hot-reload: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.
|
||||
// ==========================================
|
||||
"anime": {
|
||||
"autoOpenJimaku": false, // Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes only when auto-open initiated the pause. Values: true | false
|
||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.
|
||||
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
|
||||
@@ -94,8 +94,12 @@ extension you dropped in by hand — or one whose repository you have since
|
||||
removed — is still listed and still removable. An installed extension borrows
|
||||
its icon from the catalogue, so one no repository carries shows its monogram.
|
||||
|
||||
**Update** appears next to an extension a configured repository still carries;
|
||||
it downloads the current version over the existing APK.
|
||||
SubMiner compares the APK's Android version code with the newest build in the
|
||||
configured repositories. **Update** is clickable only when the repository has a
|
||||
newer build. A current extension says **Up to date**. If an unusual APK has no
|
||||
readable version code, its disabled button says **Version unknown**. **Update
|
||||
all** installs every newer build in one pass and disables itself when there is
|
||||
nothing to update.
|
||||
|
||||
### From a file
|
||||
|
||||
@@ -112,6 +116,16 @@ missing.
|
||||
An extension that fails to load is skipped rather than blocking the others, so
|
||||
one bad APK will not hide the rest.
|
||||
|
||||
### Default source
|
||||
|
||||
The browser opens on the first installed source. To open on a different one,
|
||||
press **Set default** on its row in the Installed list; the row then carries a
|
||||
**default** tag, and only one source can carry it. The choice is written to
|
||||
`anime.defaultSource` and applies the next time a browser window or the
|
||||
in-player modal opens. With more than one source installed, an **All sources**
|
||||
row at the top of the list can be the default too. A default that is no longer
|
||||
installed falls back to the first source.
|
||||
|
||||
## Searching every source at once
|
||||
|
||||
With more than one source installed, the **Source** picker gains an
|
||||
@@ -196,12 +210,24 @@ there is nowhere to write them and the status bar says so.
|
||||
|
||||
## Settings
|
||||
|
||||
| Key | Purpose |
|
||||
| ------------------------ | ----------------------------------------------------------------------- |
|
||||
| `anime.repos` | Repository index URLs. Empty by default. |
|
||||
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
|
||||
| `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. |
|
||||
| Key | Purpose |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `anime.autoOpenJimaku` | Pause new episodes and open Jimaku for Japanese subtitles. |
|
||||
| `anime.repos` | Repository index URLs. Empty by default. |
|
||||
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
|
||||
| `anime.defaultSource` | Source the browser opens on: a source id or `all`. Empty uses the first. |
|
||||
| `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. |
|
||||
|
||||
Enable `anime.autoOpenJimaku` to hand each newly loaded Anime Browser episode
|
||||
to Jimaku. SubMiner pauses playback on its first frame, waits for mpv's video
|
||||
window to appear, closes the in-player browser if it is open, hides the
|
||||
standalone browser window, and opens Jimaku with the source title, season, and
|
||||
episode already filled in. Playback resumes after the selected subtitle loads.
|
||||
Closing Jimaku also releases the automatic pause, while playback that was
|
||||
already paused stays paused. A stream that never shows a window resumes without
|
||||
opening Jimaku. Reopen the browser window with `subminer anime`, the tray
|
||||
entry, or the shortcut to keep browsing.
|
||||
|
||||
## Source settings
|
||||
|
||||
|
||||
@@ -1053,7 +1053,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time, `%T`=time with milliseconds, `<br>`=newline |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. |
|
||||
| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. |
|
||||
|
||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||
@@ -1165,18 +1165,22 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
|
||||
```json
|
||||
{
|
||||
"anime": {
|
||||
"autoOpenJimaku": false,
|
||||
"extensionsDir": "",
|
||||
"repos": [],
|
||||
"preferredQuality": ""
|
||||
"preferredQuality": "",
|
||||
"defaultSource": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------------ | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `anime.autoOpenJimaku` | `boolean` | `false` | Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes. Playback that was already paused stays paused. |
|
||||
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
|
||||
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
|
||||
| `anime.defaultSource` | `string` | `""` | Source the Anime Browser selects when it opens: a source id (`<package>:<source>`) or `all` for every installed source. Empty selects the first installed source. **Set default** in the Extensions tab writes this value. |
|
||||
| `anime.bridgeDir` | `string` | `""` | Directory holding an M-Extension-Server bundle (Java runtime plus server jar) to run instead of the downloaded copy. Empty checks the package-manager install first, then `<userData>/anime-bridge`. See [the bridge](anime-browser.md#the-bridge). |
|
||||
|
||||
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
|
||||
|
||||
@@ -626,12 +626,14 @@
|
||||
// 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.
|
||||
// Hot-reload: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.
|
||||
// ==========================================
|
||||
"anime": {
|
||||
"autoOpenJimaku": false, // Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes only when auto-open initiated the pause. Values: true | false
|
||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.
|
||||
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
## Highlights
|
||||
### Fixed
|
||||
|
||||
- **Anki Card Update Progress**: The update spinner now stays visible until audio and image updates actually finish, so you won't mistake an in-progress update for a failure.
|
||||
- **Word-Card Field Enrichment**: Word-card enrichment now reliably writes sentence text and audio into whichever AnkiConnect fields you've configured, while the dedicated Lapis/Kiku sentence-card and audio-card actions still use their expected field names.
|
||||
- **Overlapping Subtitles**:
|
||||
- Lines that start while another is still on screen now show together instead of staying hidden until you switch tracks or seek.
|
||||
- Subtitles shown at the same time now stack by their authored screen position, with signs and song lyrics above dialogue.
|
||||
- Half-size ASS furigana no longer shows up as if it were its own subtitle line.
|
||||
- **YouTube Auto-Generated Captions**:
|
||||
- Captions now follow their intended timing instead of drifting off sync.
|
||||
- Long speech is paged across two rows instead of piling into a wall of text.
|
||||
- Timed sound cues like `[音楽]` no longer linger over later dialogue.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(anki): keep overlay progress visible through card updates by @ksyasuda in #218
|
||||
- fix(youtube): keep auto captions on screen for their full span by @ksyasuda in #219
|
||||
- fix(subtitles): keep overlapping lines that join an already active cue by @ksyasuda in #221
|
||||
- fix(anki): respect configured fields for word-card enrichment by @ksyasuda in #223
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const assetsSourceDir = path.join(repoRoot, 'assets');
|
||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
||||
@@ -58,6 +59,7 @@ function copyAnimeUiAssets() {
|
||||
'detail.css',
|
||||
'panels.css',
|
||||
]);
|
||||
copyFile(path.join(assetsSourceDir, 'SubMiner.png'), path.join(animeUiOutputDir, 'SubMiner.png'));
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
|
||||
@@ -33,6 +33,10 @@ test('anime UI stylesheet files exist and are all staged', () => {
|
||||
source,
|
||||
/copyAssets\(animeUiSourceDir, animeUiOutputDir, 'animeui', \[\s*'style\.css',\s*'detail\.css',\s*'panels\.css',?\s*\]\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/copyFile\(\s*path\.join\(assetsSourceDir, 'SubMiner\.png'\),\s*path\.join\(animeUiOutputDir, 'SubMiner\.png'\),?\s*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { parseAndroidManifestVersionCode, readApkVersionCode } from './apk-version';
|
||||
|
||||
const NO_STRING = 0xffffffff;
|
||||
|
||||
function writeChunkHeader(buffer: Buffer, type: number, headerSize: number, size: number): void {
|
||||
buffer.writeUInt16LE(type, 0);
|
||||
buffer.writeUInt16LE(headerSize, 2);
|
||||
buffer.writeUInt32LE(size, 4);
|
||||
}
|
||||
|
||||
function makeStringPool(strings: string[]): Buffer {
|
||||
const encoded = strings.map((value) => {
|
||||
const bytes = Buffer.from(value, 'utf8');
|
||||
return Buffer.concat([Buffer.from([value.length, bytes.length]), bytes, Buffer.from([0])]);
|
||||
});
|
||||
const offsets = encoded.map((_value, index) =>
|
||||
encoded.slice(0, index).reduce((total, value) => total + value.length, 0),
|
||||
);
|
||||
const dataLength = encoded.reduce((total, value) => total + value.length, 0);
|
||||
const paddedDataLength = Math.ceil(dataLength / 4) * 4;
|
||||
const headerSize = 28;
|
||||
const stringsStart = headerSize + strings.length * 4;
|
||||
const chunk = Buffer.alloc(stringsStart + paddedDataLength);
|
||||
writeChunkHeader(chunk, 0x0001, headerSize, chunk.length);
|
||||
chunk.writeUInt32LE(strings.length, 8);
|
||||
chunk.writeUInt32LE(0x00000100, 16);
|
||||
chunk.writeUInt32LE(stringsStart, 20);
|
||||
offsets.forEach((offset, index) => chunk.writeUInt32LE(offset, headerSize + index * 4));
|
||||
Buffer.concat(encoded).copy(chunk, stringsStart);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function makeBinaryManifest(versionCode: number): Buffer {
|
||||
const stringPool = makeStringPool(['manifest', 'versionCode']);
|
||||
const startElement = Buffer.alloc(56);
|
||||
writeChunkHeader(startElement, 0x0102, 16, startElement.length);
|
||||
startElement.writeUInt32LE(NO_STRING, 12);
|
||||
startElement.writeUInt32LE(NO_STRING, 16);
|
||||
startElement.writeUInt32LE(0, 20);
|
||||
startElement.writeUInt16LE(20, 24);
|
||||
startElement.writeUInt16LE(20, 26);
|
||||
startElement.writeUInt16LE(1, 28);
|
||||
const attributeOffset = 36;
|
||||
startElement.writeUInt32LE(NO_STRING, attributeOffset);
|
||||
startElement.writeUInt32LE(1, attributeOffset + 4);
|
||||
startElement.writeUInt32LE(NO_STRING, attributeOffset + 8);
|
||||
startElement.writeUInt16LE(8, attributeOffset + 12);
|
||||
startElement[attributeOffset + 15] = 0x10;
|
||||
startElement.writeUInt32LE(versionCode, attributeOffset + 16);
|
||||
|
||||
const document = Buffer.alloc(8);
|
||||
writeChunkHeader(document, 0x0003, 8, document.length + stringPool.length + startElement.length);
|
||||
return Buffer.concat([document, stringPool, startElement]);
|
||||
}
|
||||
|
||||
function makeDeflatedZip(name: string, data: Buffer): Buffer {
|
||||
const fileName = Buffer.from(name, 'utf8');
|
||||
const compressed = deflateRawSync(data);
|
||||
const local = Buffer.alloc(30 + fileName.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(fileName.length, 26);
|
||||
fileName.copy(local, 30);
|
||||
|
||||
const central = Buffer.alloc(46 + fileName.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt32LE(compressed.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(fileName.length, 28);
|
||||
fileName.copy(central, 46);
|
||||
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(1, 8);
|
||||
end.writeUInt16LE(1, 10);
|
||||
end.writeUInt32LE(central.length, 12);
|
||||
end.writeUInt32LE(local.length + compressed.length, 16);
|
||||
return Buffer.concat([local, compressed, central, end]);
|
||||
}
|
||||
|
||||
test('parseAndroidManifestVersionCode reads the typed manifest attribute', () => {
|
||||
assert.equal(parseAndroidManifestVersionCode(makeBinaryManifest(42)), 42);
|
||||
assert.equal(parseAndroidManifestVersionCode(Buffer.from('plain xml')), null);
|
||||
});
|
||||
|
||||
test('readApkVersionCode reads a deflated AndroidManifest.xml from an APK', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-apk-version-'));
|
||||
const apkPath = path.join(directory, 'extension.apk');
|
||||
await writeFile(apkPath, makeDeflatedZip('AndroidManifest.xml', makeBinaryManifest(730)));
|
||||
|
||||
assert.equal(await readApkVersionCode(apkPath), 730);
|
||||
});
|
||||
|
||||
test('readApkVersionCode returns null for a malformed APK', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-apk-version-'));
|
||||
const apkPath = path.join(directory, 'broken.apk');
|
||||
await writeFile(apkPath, 'not a zip');
|
||||
|
||||
assert.equal(await readApkVersionCode(apkPath), null);
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { open, type FileHandle } from 'node:fs/promises';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const ANDROID_XML_TYPE = 0x0003;
|
||||
const STRING_POOL_TYPE = 0x0001;
|
||||
const START_ELEMENT_TYPE = 0x0102;
|
||||
const UTF8_STRING_POOL_FLAG = 0x00000100;
|
||||
const NO_STRING = 0xffffffff;
|
||||
const TYPE_INT_DEC = 0x10;
|
||||
const TYPE_INT_HEX = 0x11;
|
||||
const MANIFEST_ENTRY = 'AndroidManifest.xml';
|
||||
const MAX_ZIP_TAIL_BYTES = 65_535 + 22;
|
||||
const MAX_CENTRAL_DIRECTORY_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
|
||||
interface ZipEntryLocation {
|
||||
compressionMethod: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localHeaderOffset: number;
|
||||
}
|
||||
|
||||
async function readExactly(handle: FileHandle, length: number, position: number): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
||||
if (bytesRead !== length) throw new Error('Unexpected end of APK.');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(tail: Buffer): number | null {
|
||||
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
|
||||
if (tail.readUInt32LE(offset) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue;
|
||||
const commentLength = tail.readUInt16LE(offset + 20);
|
||||
if (offset + 22 + commentLength === tail.length) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findZipEntry(
|
||||
central: Buffer,
|
||||
entryCount: number,
|
||||
wantedName: string,
|
||||
): ZipEntryLocation | null {
|
||||
let offset = 0;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (offset + 46 > central.length) return null;
|
||||
if (central.readUInt32LE(offset) !== CENTRAL_FILE_HEADER_SIGNATURE) return null;
|
||||
|
||||
const flags = central.readUInt16LE(offset + 8);
|
||||
const compressionMethod = central.readUInt16LE(offset + 10);
|
||||
const compressedSize = central.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = central.readUInt32LE(offset + 24);
|
||||
const nameLength = central.readUInt16LE(offset + 28);
|
||||
const extraLength = central.readUInt16LE(offset + 30);
|
||||
const commentLength = central.readUInt16LE(offset + 32);
|
||||
const recordLength = 46 + nameLength + extraLength + commentLength;
|
||||
if (offset + recordLength > central.length) return null;
|
||||
|
||||
const name = central.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
||||
if (name === wantedName) {
|
||||
if ((flags & 0x0001) !== 0) return null;
|
||||
if (compressedSize > MAX_MANIFEST_BYTES || uncompressedSize > MAX_MANIFEST_BYTES) return null;
|
||||
return {
|
||||
compressionMethod,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset: central.readUInt32LE(offset + 42),
|
||||
};
|
||||
}
|
||||
offset += recordLength;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readZipEntry(apkPath: string, wantedName: string): Promise<Buffer | null> {
|
||||
let handle: FileHandle | null = null;
|
||||
try {
|
||||
handle = await open(apkPath, 'r');
|
||||
const fileSize = (await handle.stat()).size;
|
||||
const tailSize = Math.min(fileSize, MAX_ZIP_TAIL_BYTES);
|
||||
if (tailSize < 22) return null;
|
||||
const tail = await readExactly(handle, tailSize, fileSize - tailSize);
|
||||
const endOffset = findEndOfCentralDirectory(tail);
|
||||
if (endOffset === null) return null;
|
||||
|
||||
const diskNumber = tail.readUInt16LE(endOffset + 4);
|
||||
const centralDisk = tail.readUInt16LE(endOffset + 6);
|
||||
const diskEntryCount = tail.readUInt16LE(endOffset + 8);
|
||||
const entryCount = tail.readUInt16LE(endOffset + 10);
|
||||
const centralSize = tail.readUInt32LE(endOffset + 12);
|
||||
const centralOffset = tail.readUInt32LE(endOffset + 16);
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
centralDisk !== 0 ||
|
||||
diskEntryCount !== entryCount ||
|
||||
entryCount === 0 ||
|
||||
centralSize === 0 ||
|
||||
centralSize > MAX_CENTRAL_DIRECTORY_BYTES ||
|
||||
centralOffset + centralSize > fileSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const central = await readExactly(handle, centralSize, centralOffset);
|
||||
const entry = findZipEntry(central, entryCount, wantedName);
|
||||
if (!entry || entry.localHeaderOffset + 30 > centralOffset) return null;
|
||||
|
||||
const localHeader = await readExactly(handle, 30, entry.localHeaderOffset);
|
||||
if (localHeader.readUInt32LE(0) !== LOCAL_FILE_HEADER_SIGNATURE) return null;
|
||||
const nameLength = localHeader.readUInt16LE(26);
|
||||
const extraLength = localHeader.readUInt16LE(28);
|
||||
const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
||||
if (dataOffset + entry.compressedSize > centralOffset) return null;
|
||||
const compressed = await readExactly(handle, entry.compressedSize, dataOffset);
|
||||
|
||||
let data: Buffer;
|
||||
if (entry.compressionMethod === 0) {
|
||||
data = compressed;
|
||||
} else if (entry.compressionMethod === 8) {
|
||||
data = inflateRawSync(compressed, { maxOutputLength: MAX_MANIFEST_BYTES });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return data.length === entry.uncompressedSize ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function readUtf8Length(buffer: Buffer, offset: number): { length: number; next: number } | null {
|
||||
if (offset >= buffer.length) return null;
|
||||
const first = buffer[offset]!;
|
||||
if ((first & 0x80) === 0) return { length: first, next: offset + 1 };
|
||||
if (offset + 1 >= buffer.length) return null;
|
||||
return { length: ((first & 0x7f) << 8) | buffer[offset + 1]!, next: offset + 2 };
|
||||
}
|
||||
|
||||
function readUtf16Length(buffer: Buffer, offset: number): { length: number; next: number } | null {
|
||||
if (offset + 2 > buffer.length) return null;
|
||||
const first = buffer.readUInt16LE(offset);
|
||||
if ((first & 0x8000) === 0) return { length: first, next: offset + 2 };
|
||||
if (offset + 4 > buffer.length) return null;
|
||||
return {
|
||||
length: ((first & 0x7fff) << 16) | buffer.readUInt16LE(offset + 2),
|
||||
next: offset + 4,
|
||||
};
|
||||
}
|
||||
|
||||
interface AndroidStringPool {
|
||||
stringAt: (index: number) => string | null;
|
||||
}
|
||||
|
||||
function parseStringPool(
|
||||
buffer: Buffer,
|
||||
chunkOffset: number,
|
||||
chunkSize: number,
|
||||
): AndroidStringPool | null {
|
||||
const headerSize = buffer.readUInt16LE(chunkOffset + 2);
|
||||
if (headerSize < 28 || chunkOffset + chunkSize > buffer.length) return null;
|
||||
const stringCount = buffer.readUInt32LE(chunkOffset + 8);
|
||||
const flags = buffer.readUInt32LE(chunkOffset + 16);
|
||||
const stringsStart = buffer.readUInt32LE(chunkOffset + 20);
|
||||
if (headerSize + stringCount * 4 > chunkSize || stringsStart >= chunkSize) return null;
|
||||
|
||||
return {
|
||||
stringAt(index) {
|
||||
if (index === NO_STRING || index >= stringCount) return null;
|
||||
const relativeOffset = buffer.readUInt32LE(chunkOffset + headerSize + index * 4);
|
||||
let stringOffset = chunkOffset + stringsStart + relativeOffset;
|
||||
const chunkEnd = chunkOffset + chunkSize;
|
||||
if (stringOffset >= chunkEnd) return null;
|
||||
|
||||
if ((flags & UTF8_STRING_POOL_FLAG) !== 0) {
|
||||
const utf16Length = readUtf8Length(buffer, stringOffset);
|
||||
if (!utf16Length) return null;
|
||||
const byteLength = readUtf8Length(buffer, utf16Length.next);
|
||||
if (!byteLength || byteLength.next + byteLength.length > chunkEnd) return null;
|
||||
return buffer.toString('utf8', byteLength.next, byteLength.next + byteLength.length);
|
||||
}
|
||||
|
||||
const length = readUtf16Length(buffer, stringOffset);
|
||||
if (!length) return null;
|
||||
stringOffset = length.next;
|
||||
const byteLength = length.length * 2;
|
||||
if (stringOffset + byteLength > chunkEnd) return null;
|
||||
return buffer.toString('utf16le', stringOffset, stringOffset + byteLength);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Read Android's numeric version code from a binary AndroidManifest.xml. */
|
||||
export function parseAndroidManifestVersionCode(buffer: Buffer): number | null {
|
||||
try {
|
||||
if (buffer.length < 8 || buffer.readUInt16LE(0) !== ANDROID_XML_TYPE) return null;
|
||||
const documentSize = buffer.readUInt32LE(4);
|
||||
if (documentSize > buffer.length) return null;
|
||||
|
||||
let strings: AndroidStringPool | null = null;
|
||||
let offset = buffer.readUInt16LE(2);
|
||||
while (offset + 8 <= documentSize) {
|
||||
const chunkType = buffer.readUInt16LE(offset);
|
||||
const headerSize = buffer.readUInt16LE(offset + 2);
|
||||
const chunkSize = buffer.readUInt32LE(offset + 4);
|
||||
if (headerSize < 8 || chunkSize < headerSize || offset + chunkSize > documentSize)
|
||||
return null;
|
||||
|
||||
if (chunkType === STRING_POOL_TYPE) {
|
||||
strings = parseStringPool(buffer, offset, chunkSize);
|
||||
} else if (chunkType === START_ELEMENT_TYPE && strings && headerSize >= 16) {
|
||||
const elementName = strings.stringAt(buffer.readUInt32LE(offset + 20));
|
||||
if (elementName === 'manifest') {
|
||||
const attributeStart = buffer.readUInt16LE(offset + 24);
|
||||
const attributeSize = buffer.readUInt16LE(offset + 26);
|
||||
const attributeCount = buffer.readUInt16LE(offset + 28);
|
||||
const attributesOffset = offset + 16 + attributeStart;
|
||||
if (
|
||||
attributeSize < 20 ||
|
||||
attributesOffset + attributeSize * attributeCount > offset + chunkSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = 0; index < attributeCount; index += 1) {
|
||||
const attributeOffset = attributesOffset + index * attributeSize;
|
||||
const name = strings.stringAt(buffer.readUInt32LE(attributeOffset + 4));
|
||||
if (name !== 'versionCode') continue;
|
||||
const valueType = buffer[attributeOffset + 15];
|
||||
if (valueType === TYPE_INT_DEC || valueType === TYPE_INT_HEX) {
|
||||
return buffer.readUInt32LE(attributeOffset + 16);
|
||||
}
|
||||
const rawValue = strings.stringAt(buffer.readUInt32LE(attributeOffset + 8));
|
||||
if (rawValue === null) return null;
|
||||
const parsed = Number(rawValue);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
offset += chunkSize;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the installed version without extracting the APK or running Android tooling. */
|
||||
export async function readApkVersionCode(apkPath: string): Promise<number | null> {
|
||||
const manifest = await readZipEntry(apkPath, MANIFEST_ENTRY);
|
||||
return manifest ? parseAndroidManifestVersionCode(manifest) : null;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ test('readInstalledExtensions fingerprints apks without holding their bytes', as
|
||||
assert.equal(extensions.length, 1);
|
||||
assert.equal(extensions[0]?.fallbackName, 'my-source');
|
||||
assert.equal(extensions[0]?.sha256, createHash('sha256').update('APK-BYTES').digest('hex'));
|
||||
assert.equal(extensions[0]?.versionCode, null);
|
||||
});
|
||||
|
||||
test('the fingerprint changes when an apk is replaced in place', async () => {
|
||||
@@ -69,7 +70,12 @@ 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', sha256: 'hash-a' };
|
||||
const extension: InstalledExtension = {
|
||||
file: '/x/a.apk',
|
||||
fallbackName: 'a',
|
||||
sha256: 'hash-a',
|
||||
versionCode: 1,
|
||||
};
|
||||
assert.equal(toBridgeSource(extension).sourceId, undefined);
|
||||
assert.equal(toBridgeSource(extension, 'src-1').sourceId, 'src-1');
|
||||
assert.equal(toBridgeSource(extension, 'src-1').fingerprint, 'hash-a');
|
||||
@@ -77,7 +83,7 @@ test('toBridgeSource includes sourceId only when selecting inside a factory apk'
|
||||
|
||||
test('listExtensionSources flattens every source a factory apk provides', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a', versionCode: 1 },
|
||||
];
|
||||
const client = fakeClient(async () => [
|
||||
{ id: 101, name: 'Source One', lang: 'en' },
|
||||
@@ -96,8 +102,8 @@ test('listExtensionSources flattens every source a factory apk provides', async
|
||||
|
||||
test('sources with the same bridge id in different packages have distinct runtime ids', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one' },
|
||||
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two' },
|
||||
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one', versionCode: 1 },
|
||||
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two', versionCode: 2 },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: 'shared', name: 'Source', lang: 'en' }]);
|
||||
|
||||
@@ -114,7 +120,7 @@ test('sources with the same bridge id in different packages have distinct runtim
|
||||
|
||||
test('listExtensionSources falls back to the file name and a default language', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a' },
|
||||
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a', versionCode: 1 },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
|
||||
|
||||
@@ -126,14 +132,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', sha256: 'hash-a' },
|
||||
{ file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a', versionCode: 1 },
|
||||
]);
|
||||
assert.deepEqual(sources, []);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a', versionCode: 7 },
|
||||
];
|
||||
const sources: ExtensionSource[] = [
|
||||
{ id: 'multi:1', bridgeId: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
||||
@@ -141,26 +147,47 @@ test('toInstalledExtensionViews names an extension after the sources it provides
|
||||
];
|
||||
|
||||
assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
|
||||
{ pkg: 'multi', name: 'One, Two', langs: ['en', 'ja'], sourceCount: 2, error: null },
|
||||
{
|
||||
pkg: 'multi',
|
||||
name: 'One, Two',
|
||||
langs: ['en', 'ja'],
|
||||
sourceCount: 2,
|
||||
sources: [
|
||||
{ id: 'multi:1', name: 'One' },
|
||||
{ id: 'multi:2', name: 'Two' },
|
||||
],
|
||||
versionCode: 7,
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a', versionCode: null },
|
||||
];
|
||||
|
||||
// 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' }],
|
||||
[
|
||||
{
|
||||
pkg: 'broken',
|
||||
name: 'broken',
|
||||
langs: [],
|
||||
sourceCount: 0,
|
||||
sources: [],
|
||||
versionCode: null,
|
||||
error: 'dex2jar failed',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('one broken extension does not hide the working ones', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
||||
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b' },
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a', versionCode: null },
|
||||
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b', versionCode: 1 },
|
||||
];
|
||||
const failures: string[] = [];
|
||||
const client = fakeClient(async (source) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import path from 'node:path';
|
||||
import { readApkVersionCode } from './apk-version';
|
||||
import type { AnimeBridgeClient } from './bridge-client';
|
||||
import type { BridgeSource } from './bridge-client';
|
||||
import type { ExtensionLoadFailure, InstalledExtensionView } from '../types/anime-browser';
|
||||
@@ -23,6 +24,8 @@ export interface InstalledExtension {
|
||||
* bridge's extension-id cache misses after an in-place upgrade.
|
||||
*/
|
||||
sha256: string;
|
||||
/** Android manifest version code, or null when the APK cannot declare one. */
|
||||
versionCode: number | null;
|
||||
}
|
||||
|
||||
export interface ExtensionSource {
|
||||
@@ -56,10 +59,12 @@ export async function readInstalledExtensions(directory: string): Promise<Instal
|
||||
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 [sha256, versionCode] = await Promise.all([hashFile(file), readApkVersionCode(file)]);
|
||||
extensions.push({
|
||||
file,
|
||||
fallbackName: entry.name.replace(/\.apk$/i, ''),
|
||||
sha256: await hashFile(file),
|
||||
sha256,
|
||||
versionCode,
|
||||
});
|
||||
}
|
||||
return extensions;
|
||||
@@ -91,6 +96,8 @@ export function toInstalledExtensionViews(
|
||||
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
|
||||
langs: [...new Set(provided.map((source) => source.lang))],
|
||||
sourceCount: provided.length,
|
||||
sources: provided.map((source) => ({ id: source.id, name: source.name })),
|
||||
versionCode: extension.versionCode,
|
||||
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AvailableExtension, InstalledExtensionView } from '../types/anime-browser';
|
||||
import { getExtensionUpdateState, summarizeExtensionUpdates } from './extensions-panel';
|
||||
|
||||
const installed = {
|
||||
pkg: 'pkg.example',
|
||||
name: 'Example',
|
||||
langs: ['en'],
|
||||
sourceCount: 1,
|
||||
sources: [{ id: 'pkg.example:1', name: 'Example' }],
|
||||
versionCode: 12,
|
||||
error: null,
|
||||
} satisfies InstalledExtensionView;
|
||||
|
||||
const offered = {
|
||||
pkg: 'pkg.example',
|
||||
name: 'Example',
|
||||
lang: 'en',
|
||||
version: '1.2.0',
|
||||
versionCode: 12,
|
||||
nsfw: false,
|
||||
repoUrl: 'https://repo.example/index.json',
|
||||
iconUrl: 'https://repo.example/icon.png',
|
||||
sourceNames: ['Example'],
|
||||
installed: true,
|
||||
} satisfies AvailableExtension;
|
||||
|
||||
test('extension update state only enables a strictly newer repository build', () => {
|
||||
assert.equal(getExtensionUpdateState(installed, { ...offered, versionCode: 13 }), 'available');
|
||||
assert.equal(getExtensionUpdateState(installed, offered), 'current');
|
||||
assert.equal(getExtensionUpdateState(installed, { ...offered, versionCode: 11 }), 'current');
|
||||
});
|
||||
|
||||
test('extension update state does not offer unverifiable updates', () => {
|
||||
assert.equal(getExtensionUpdateState({ ...installed, versionCode: null }, offered), 'unknown');
|
||||
assert.equal(getExtensionUpdateState(installed, undefined), 'unavailable');
|
||||
});
|
||||
|
||||
test('bulk update summary distinguishes waiting, current, and unverifiable states', () => {
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'available', 'available']), {
|
||||
kind: 'available',
|
||||
count: 2,
|
||||
});
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'current']), { kind: 'current' });
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'unknown']), { kind: 'none' });
|
||||
assert.deepEqual(summarizeExtensionUpdates([]), { kind: 'none' });
|
||||
});
|
||||
+192
-42
@@ -8,11 +8,13 @@ import {
|
||||
pruneSelection,
|
||||
toggleLanguage,
|
||||
} from './language-filter';
|
||||
import { ALL_SOURCES_ID } from '../types/anime-browser';
|
||||
import type {
|
||||
AnimeBrowserAPI,
|
||||
AvailableExtension,
|
||||
InstalledExtensionView,
|
||||
} from '../types/anime-browser';
|
||||
import { hasExtensionUpdate } from '../shared/extension-updates';
|
||||
|
||||
/**
|
||||
* The Extensions tab: what is installed, which repositories feed it, and what
|
||||
@@ -31,11 +33,17 @@ export interface ExtensionsPanelOptions {
|
||||
onSourcesChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface RowAction {
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
type RowAction =
|
||||
| {
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
| {
|
||||
label: string;
|
||||
disabled: true;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
interface RowOptions {
|
||||
name: string;
|
||||
@@ -96,8 +104,14 @@ function extensionRow(options: RowOptions): HTMLDivElement {
|
||||
for (const action of options.actions ?? []) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = action.primary ? 'primary-button' : 'ghost-button';
|
||||
button.className = 'primary' in action && action.primary ? 'primary-button' : 'ghost-button';
|
||||
button.textContent = action.label;
|
||||
if ('disabled' in action) {
|
||||
button.disabled = true;
|
||||
if (action.title) button.title = action.title;
|
||||
row.append(button);
|
||||
continue;
|
||||
}
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
void Promise.resolve(action.onClick()).finally(() => {
|
||||
@@ -110,6 +124,32 @@ function extensionRow(options: RowOptions): HTMLDivElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
export type ExtensionUpdateState = 'available' | 'current' | 'unknown' | 'unavailable';
|
||||
|
||||
export type ExtensionUpdateSummary =
|
||||
| { kind: 'available'; count: number }
|
||||
| { kind: 'current' }
|
||||
| { kind: 'none' };
|
||||
|
||||
export function getExtensionUpdateState(
|
||||
installed: InstalledExtensionView,
|
||||
offered: AvailableExtension | undefined,
|
||||
): ExtensionUpdateState {
|
||||
if (!offered) return 'unavailable';
|
||||
if (installed.versionCode === null) return 'unknown';
|
||||
return hasExtensionUpdate(installed.versionCode, offered.versionCode) ? 'available' : 'current';
|
||||
}
|
||||
|
||||
export function summarizeExtensionUpdates(
|
||||
states: readonly ExtensionUpdateState[],
|
||||
): ExtensionUpdateSummary {
|
||||
const count = states.filter((state) => state === 'available').length;
|
||||
if (count > 0) return { kind: 'available', count };
|
||||
return states.length > 0 && states.every((state) => state === 'current')
|
||||
? { kind: 'current' }
|
||||
: { kind: 'none' };
|
||||
}
|
||||
|
||||
function emptyNote(text: string): HTMLParagraphElement {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'ext-empty';
|
||||
@@ -124,6 +164,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
const bridgeInfo = el<HTMLParagraphElement>('bridge-info');
|
||||
const installedList = el<HTMLDivElement>('installed-list');
|
||||
const installedCount = el<HTMLSpanElement>('installed-count');
|
||||
const updateAllButton = el<HTMLButtonElement>('update-all');
|
||||
const availableList = el<HTMLDivElement>('extensions-list');
|
||||
const availableCount = el<HTMLSpanElement>('available-count');
|
||||
const langFilter = el<HTMLDivElement>('lang-filter');
|
||||
@@ -138,6 +179,8 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
let iconsByPkg = new Map<string, string>();
|
||||
let repoFailures: Array<{ name: string; error: string }> = [];
|
||||
let hasRepos = false;
|
||||
let updatingAll = false;
|
||||
let pendingUpdateCount = 0;
|
||||
/** Selected language codes; empty means "All". */
|
||||
let selectedLangs = new Set<string>();
|
||||
|
||||
@@ -147,12 +190,59 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
setStatus(`${extensionName} ${verb}`, 'ok');
|
||||
}
|
||||
|
||||
/**
|
||||
* The "default" tag and "Set default" buttons for a row's sources. The tag
|
||||
* marks the one source (or All sources) the browser opens on; every other
|
||||
* source gets a button, so exactly one can carry the tag at a time.
|
||||
*/
|
||||
function defaultSourceControls(
|
||||
sources: Array<{ id: string; name: string }>,
|
||||
defaultSourceId: string | null,
|
||||
): { tags: RowOptions['tags']; actions: RowAction[] } {
|
||||
const tags: NonNullable<RowOptions['tags']> = [];
|
||||
const actions: RowAction[] = [];
|
||||
const named = sources.length > 1;
|
||||
for (const source of sources) {
|
||||
if (source.id === defaultSourceId) {
|
||||
tags.push({ text: named ? `default · ${source.name}` : 'default', className: 'default' });
|
||||
continue;
|
||||
}
|
||||
actions.push({
|
||||
label: named ? `Set default: ${source.name}` : 'Set default',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await api.setDefaultSource(source.id);
|
||||
await refresh();
|
||||
setStatus(`The browser now opens on ${source.name}.`, 'ok');
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return { tags, actions };
|
||||
}
|
||||
|
||||
function renderInstalled(
|
||||
installed: InstalledExtensionView[],
|
||||
offeredPkgs: Set<string>,
|
||||
offeredByPkg: Map<string, AvailableExtension>,
|
||||
extensionsDir: string,
|
||||
defaultSourceId: string | null,
|
||||
): void {
|
||||
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
|
||||
const updateStates = installed.map((view) =>
|
||||
getExtensionUpdateState(view, offeredByPkg.get(view.pkg)),
|
||||
);
|
||||
const updateSummary = summarizeExtensionUpdates(updateStates);
|
||||
const updateCount = updateSummary.kind === 'available' ? updateSummary.count : 0;
|
||||
pendingUpdateCount = updateCount;
|
||||
updateAllButton.textContent =
|
||||
updateSummary.kind === 'available'
|
||||
? `Update all (${updateSummary.count})`
|
||||
: updateSummary.kind === 'current'
|
||||
? 'All up to date'
|
||||
: 'No updates';
|
||||
updateAllButton.disabled = updatingAll || updateCount === 0;
|
||||
|
||||
if (installed.length === 0) {
|
||||
installedList.replaceChildren(
|
||||
@@ -163,48 +253,74 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
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');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
const rows = installed.map((view) => {
|
||||
const defaults = defaultSourceControls(view.sources, defaultSourceId);
|
||||
const actions: RowAction[] = [...defaults.actions];
|
||||
const updateState = getExtensionUpdateState(view, offeredByPkg.get(view.pkg));
|
||||
if (updateState === 'available') {
|
||||
actions.push({
|
||||
label: 'Remove',
|
||||
label: 'Update',
|
||||
onClick: async () => {
|
||||
setStatus(`Removing ${view.name}…`);
|
||||
setStatus(`Updating ${view.name}…`);
|
||||
try {
|
||||
await api.removeExtension(view.pkg);
|
||||
await afterChange(view.name, 'removed');
|
||||
await api.installExtension(view.pkg);
|
||||
await afterChange(view.name, 'updated');
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return extensionRow({
|
||||
name: view.name,
|
||||
sub: view.error ?? describeInstalled(view),
|
||||
iconUrl: iconsByPkg.get(view.pkg) ?? null,
|
||||
isError: view.error !== null,
|
||||
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
|
||||
actions,
|
||||
} else if (updateState === 'current') {
|
||||
actions.push({ label: 'Up to date', disabled: true });
|
||||
} else if (updateState === 'unknown') {
|
||||
actions.push({
|
||||
label: 'Version unknown',
|
||||
disabled: true,
|
||||
title: 'SubMiner could not read a version code from this APK.',
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
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),
|
||||
iconUrl: iconsByPkg.get(view.pkg) ?? null,
|
||||
isError: view.error !== null,
|
||||
tags: view.error === null ? defaults.tags : [{ text: 'failed', className: 'nsfw' }],
|
||||
actions,
|
||||
});
|
||||
});
|
||||
|
||||
// "All sources" is a picker entry too, so it can be the default like any
|
||||
// source. It only exists with more than one source installed.
|
||||
const sourceTotal = installed.reduce((sum, view) => sum + view.sourceCount, 0);
|
||||
if (sourceTotal > 1) {
|
||||
const defaults = defaultSourceControls(
|
||||
[{ id: ALL_SOURCES_ID, name: 'All sources' }],
|
||||
defaultSourceId,
|
||||
);
|
||||
rows.unshift(
|
||||
extensionRow({
|
||||
name: 'All sources',
|
||||
sub: `Search every installed source at once · ${sourceTotal} sources`,
|
||||
tags: defaults.tags,
|
||||
actions: defaults.actions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
installedList.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
function renderRepos(repos: string[]): void {
|
||||
@@ -344,11 +460,18 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
repoFailures.push({ name: failure.repoUrl, error: failure.error });
|
||||
}
|
||||
|
||||
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
|
||||
const offeredByPkg = new Map(
|
||||
available.extensions.map((extension) => [extension.pkg, extension]),
|
||||
);
|
||||
// The catalogue is the only source of icons, so an installed extension can
|
||||
// only show one while a repository still carries its package.
|
||||
iconsByPkg = buildIconIndex(available.extensions);
|
||||
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
|
||||
renderInstalled(
|
||||
snapshot.installed,
|
||||
offeredByPkg,
|
||||
snapshot.extensionsDir,
|
||||
snapshot.defaultSourceId,
|
||||
);
|
||||
// Installed extensions have their own section; leaving them here too would
|
||||
// list every one of them twice.
|
||||
installable = available.extensions.filter((extension) => !extension.installed);
|
||||
@@ -371,6 +494,33 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
})();
|
||||
});
|
||||
|
||||
updateAllButton.addEventListener('click', () => {
|
||||
if (updateAllButton.disabled) return;
|
||||
updatingAll = true;
|
||||
updateAllButton.disabled = true;
|
||||
updateAllButton.textContent = 'Updating…';
|
||||
setStatus('Updating extensions…');
|
||||
void (async () => {
|
||||
try {
|
||||
const count = await api.updateAllExtensions();
|
||||
await refresh();
|
||||
await onSourcesChanged();
|
||||
setStatus(`${count} ${count === 1 ? 'extension' : 'extensions'} updated`, 'ok');
|
||||
} catch (error) {
|
||||
await refresh().catch(() => undefined);
|
||||
await onSourcesChanged().catch(() => undefined);
|
||||
setStatus(describe(error), 'error');
|
||||
} finally {
|
||||
updatingAll = false;
|
||||
updateAllButton.disabled = pendingUpdateCount === 0;
|
||||
if (updateAllButton.textContent === 'Updating…') {
|
||||
updateAllButton.textContent =
|
||||
pendingUpdateCount > 0 ? `Update all (${pendingUpdateCount})` : 'No updates';
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
repoInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -51,6 +51,11 @@ test('describeInstalled reports sources and languages when the extension loaded'
|
||||
name: 'One, Two',
|
||||
langs: ['en', 'ja'],
|
||||
sourceCount: 2,
|
||||
sources: [
|
||||
{ id: 'multi:1', name: 'One' },
|
||||
{ id: 'multi:2', name: 'Two' },
|
||||
],
|
||||
versionCode: 1,
|
||||
error: null,
|
||||
}),
|
||||
'multi · 2 sources · en, ja',
|
||||
@@ -59,7 +64,15 @@ test('describeInstalled reports sources and languages when the extension loaded'
|
||||
|
||||
test('describeInstalled falls back to the package alone when nothing loaded', () => {
|
||||
assert.equal(
|
||||
describeInstalled({ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'boom' }),
|
||||
describeInstalled({
|
||||
pkg: 'broken',
|
||||
name: 'broken',
|
||||
langs: [],
|
||||
sourceCount: 0,
|
||||
sources: [],
|
||||
versionCode: null,
|
||||
error: 'boom',
|
||||
}),
|
||||
'broken',
|
||||
);
|
||||
});
|
||||
|
||||
+13
-5
@@ -14,8 +14,11 @@
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Anime</div>
|
||||
<img class="brand-logo" src="./SubMiner.png" alt="" width="52" height="52" />
|
||||
<div class="brand-copy">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Anime</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="search-form" id="search-form" role="search">
|
||||
@@ -100,9 +103,14 @@
|
||||
<span class="settings-note" id="extensions-dir"></span>
|
||||
</div>
|
||||
|
||||
<h3 class="ext-group-title">
|
||||
Installed <span class="ext-group-count" id="installed-count"></span>
|
||||
</h3>
|
||||
<div class="ext-group-heading">
|
||||
<h3 class="ext-group-title">
|
||||
Installed <span class="ext-group-count" id="installed-count"></span>
|
||||
</h3>
|
||||
<button class="ghost-button ext-update-all" id="update-all" type="button" disabled>
|
||||
No updates
|
||||
</button>
|
||||
</div>
|
||||
<div class="ext-list" id="installed-list" aria-label="Installed extensions"></div>
|
||||
|
||||
<h3 class="ext-group-title">Repositories</h3>
|
||||
|
||||
+24
-1
@@ -130,8 +130,26 @@
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.ext-group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ext-group-heading .ext-group-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ext-update-all {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* A rule between the groups, but not above the first one. */
|
||||
.ext-list + .ext-group-title {
|
||||
.ext-list + .ext-group-title,
|
||||
.ext-list + .ext-group-heading {
|
||||
margin-top: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -294,6 +312,11 @@
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.ext-tag.default {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ext-tag.nsfw {
|
||||
border-color: rgba(237, 135, 150, 0.4);
|
||||
color: var(--danger);
|
||||
|
||||
+33
-1
@@ -58,7 +58,24 @@ html[data-embedded='overlay-modal'] .topbar {
|
||||
}
|
||||
|
||||
html[data-embedded='overlay-modal'] .brand-block {
|
||||
display: none;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
html[data-embedded='overlay-modal'] .brand-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
html[data-embedded='overlay-modal'] .brand-copy {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
html[data-embedded='overlay-modal'] .bridge-banner {
|
||||
@@ -96,6 +113,21 @@ body {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 5px 9px rgba(0, 0, 0, 0.28));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -167,6 +167,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.mpv.subminerBinaryPath, '');
|
||||
assert.equal(config.mpv.aniskipEnabled, true);
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
||||
assert.equal(config.anime.autoOpenJimaku, false);
|
||||
});
|
||||
|
||||
test('rejects invalid mpv volume mirroring values', () => {
|
||||
|
||||
@@ -104,9 +104,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
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.
|
||||
autoOpenJimaku: false,
|
||||
extensionsDir: '',
|
||||
repos: [],
|
||||
preferredQuality: '',
|
||||
defaultSource: '',
|
||||
bridgeDir: '',
|
||||
},
|
||||
jimaku: {
|
||||
|
||||
@@ -588,6 +588,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||
description: 'mpv key used to skip the detected intro while the skip prompt is visible.',
|
||||
},
|
||||
{
|
||||
path: 'anime.autoOpenJimaku',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.anime.autoOpenJimaku,
|
||||
description:
|
||||
'Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes only when auto-open initiated the pause.',
|
||||
},
|
||||
{
|
||||
path: 'anime.extensionsDir',
|
||||
kind: 'string',
|
||||
@@ -609,6 +616,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.',
|
||||
},
|
||||
{
|
||||
path: 'anime.defaultSource',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.anime.defaultSource,
|
||||
description:
|
||||
'Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.',
|
||||
},
|
||||
{
|
||||
path: 'anime.bridgeDir',
|
||||
kind: 'string',
|
||||
|
||||
@@ -147,7 +147,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
'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.'],
|
||||
notes: [
|
||||
'Hot-reload: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.',
|
||||
],
|
||||
key: 'anime',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,3 +57,44 @@ test('resolveConfig warns for invalid mpv launch mode', () => {
|
||||
message: "Expected one of: 'normal', 'maximized', 'fullscreen'.",
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveConfig parses the Anime Browser Jimaku handoff option', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
anime: {
|
||||
autoOpenJimaku: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.anime.autoOpenJimaku, true);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig warns for an invalid Anime Browser Jimaku handoff option', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
anime: {
|
||||
autoOpenJimaku: 'yes' as never,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.anime.autoOpenJimaku, false);
|
||||
assert.deepEqual(warnings, [
|
||||
{
|
||||
path: 'anime.autoOpenJimaku',
|
||||
value: 'yes',
|
||||
fallback: false,
|
||||
message: 'Expected boolean.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveConfig trims the Anime Browser default source and warns on a non-string', () => {
|
||||
const trimmed = resolveConfig({ anime: { defaultSource: ' all ' } });
|
||||
assert.equal(trimmed.resolved.anime.defaultSource, 'all');
|
||||
assert.deepEqual(trimmed.warnings, []);
|
||||
|
||||
const invalid = resolveConfig({ anime: { defaultSource: 3 as never } });
|
||||
assert.equal(invalid.resolved.anime.defaultSource, '');
|
||||
assert.deepEqual(invalid.warnings, [
|
||||
{ path: 'anime.defaultSource', value: 3, fallback: '', message: 'Expected string.' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -345,6 +345,18 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
if (isObject(src.anime)) {
|
||||
const autoOpenJimaku = asBoolean(src.anime.autoOpenJimaku);
|
||||
if (autoOpenJimaku !== undefined) {
|
||||
resolved.anime.autoOpenJimaku = autoOpenJimaku;
|
||||
} else if (src.anime.autoOpenJimaku !== undefined) {
|
||||
warn(
|
||||
'anime.autoOpenJimaku',
|
||||
src.anime.autoOpenJimaku,
|
||||
resolved.anime.autoOpenJimaku,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const extensionsDir = asString(src.anime.extensionsDir);
|
||||
if (extensionsDir !== undefined) {
|
||||
resolved.anime.extensionsDir = normalizeExternalProfilePath(extensionsDir);
|
||||
@@ -376,6 +388,18 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const defaultSource = asString(src.anime.defaultSource);
|
||||
if (defaultSource !== undefined) {
|
||||
resolved.anime.defaultSource = defaultSource.trim();
|
||||
} else if (src.anime.defaultSource !== undefined) {
|
||||
warn(
|
||||
'anime.defaultSource',
|
||||
src.anime.defaultSource,
|
||||
resolved.anime.defaultSource,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(src.anime.repos)) {
|
||||
const repos: string[] = [];
|
||||
for (const entry of src.anime.repos) {
|
||||
|
||||
@@ -55,6 +55,21 @@ test('settings registry groups playback startup controls under playback behavior
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry groups Anime Browser options under Aniyomi integrations', () => {
|
||||
for (const path of [
|
||||
'anime.autoOpenJimaku',
|
||||
'anime.bridgeDir',
|
||||
'anime.extensionsDir',
|
||||
'anime.preferredQuality',
|
||||
'anime.repos',
|
||||
]) {
|
||||
assert.equal(field(path).category, 'integrations', path);
|
||||
assert.equal(field(path).section, 'Aniyomi', path);
|
||||
}
|
||||
assert.equal(field('anime.autoOpenJimaku').label, 'Auto-open Jimaku');
|
||||
assert.equal(field('anime.autoOpenJimaku').restartBehavior, 'hot-reload');
|
||||
});
|
||||
|
||||
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
|
||||
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
|
||||
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
||||
|
||||
@@ -134,6 +134,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Kiku/Lapis/Senren Features',
|
||||
'Anki AI',
|
||||
'AnkiConnect Proxy',
|
||||
'Aniyomi',
|
||||
'Jimaku',
|
||||
'Subtitle Sync',
|
||||
'MPV Keybindings',
|
||||
@@ -245,6 +246,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||
'anime.autoOpenJimaku': 'Auto-open Jimaku',
|
||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||
};
|
||||
@@ -444,6 +446,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('anime.')) {
|
||||
return { category: 'integrations', section: 'Aniyomi' };
|
||||
}
|
||||
if (path.startsWith('subsync.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
@@ -724,6 +729,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'logging.rotation' ||
|
||||
pathStartsWith(path, 'logging.files') ||
|
||||
pathStartsWith(path, 'notifications') ||
|
||||
path === 'anime.autoOpenJimaku' ||
|
||||
path === 'youtube.primarySubLanguages' ||
|
||||
pathStartsWith(path, 'jimaku') ||
|
||||
pathStartsWith(path, 'subsync')
|
||||
|
||||
@@ -42,10 +42,19 @@ interface TrackerInternals {
|
||||
writeLock: { locked: boolean };
|
||||
}
|
||||
|
||||
function replaceFlushNow(tracker: TrackerInternals, replacement: () => void): () => void {
|
||||
const original = tracker.flushNow;
|
||||
tracker.flushNow = replacement;
|
||||
return () => {
|
||||
tracker.flushNow = original;
|
||||
};
|
||||
}
|
||||
|
||||
test('delete maintenance fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let deleteRunnerCalls = 0;
|
||||
let restoreFlushNow = (): void => {};
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
@@ -61,10 +70,10 @@ test('delete maintenance fails closed when queued writes cannot drain', async ()
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
let flushCalls = 0;
|
||||
internals.flushNow = () => {
|
||||
restoreFlushNow = replaceFlushNow(internals, () => {
|
||||
flushCalls += 1;
|
||||
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
|
||||
};
|
||||
});
|
||||
|
||||
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
|
||||
|
||||
@@ -72,6 +81,7 @@ test('delete maintenance fails closed when queued writes cannot drain', async ()
|
||||
assert.equal(deleteRunnerCalls, 0);
|
||||
assert.equal(internals.writeLock.locked, false);
|
||||
} finally {
|
||||
restoreFlushNow();
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
@@ -80,6 +90,7 @@ test('delete maintenance fails closed when queued writes cannot drain', async ()
|
||||
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let restoreFlushNow = (): void => {};
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
@@ -88,7 +99,7 @@ test('reassignAnimeAnilist fails closed before resolving a conflict when writes
|
||||
seedTwoEntries(internals.db);
|
||||
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
restoreFlushNow = replaceFlushNow(internals, () => {});
|
||||
|
||||
await assert.rejects(
|
||||
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||
@@ -107,6 +118,7 @@ test('reassignAnimeAnilist fails closed before resolving a conflict when writes
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
restoreFlushNow();
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
@@ -115,6 +127,7 @@ test('reassignAnimeAnilist fails closed before resolving a conflict when writes
|
||||
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let restoreFlushNow = (): void => {};
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
@@ -122,7 +135,7 @@ test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
restoreFlushNow = replaceFlushNow(internals, () => {});
|
||||
|
||||
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
|
||||
|
||||
@@ -134,6 +147,7 @@ test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
restoreFlushNow();
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
@@ -142,6 +156,7 @@ test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let restoreFlushNow = (): void => {};
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
@@ -149,7 +164,7 @@ test('moveVideoToAnime fails closed when queued writes cannot drain', async () =
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
restoreFlushNow = replaceFlushNow(internals, () => {});
|
||||
|
||||
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
|
||||
assert.equal(
|
||||
@@ -163,6 +178,7 @@ test('moveVideoToAnime fails closed when queued writes cannot drain', async () =
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
restoreFlushNow();
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
@@ -171,6 +187,7 @@ test('moveVideoToAnime fails closed when queued writes cannot drain', async () =
|
||||
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let restoreFlushNow = (): void => {};
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
@@ -178,10 +195,11 @@ test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', as
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
restoreFlushNow = replaceFlushNow(internals, () => {});
|
||||
|
||||
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
|
||||
} finally {
|
||||
restoreFlushNow();
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface RuntimeHarness {
|
||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
sentCommands: Array<{ command: (string | number)[] }>;
|
||||
jimakuSubtitleLoaded: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ function createHarness(): RuntimeHarness {
|
||||
query?: Record<string, unknown>;
|
||||
}>,
|
||||
sentCommands: [] as Array<{ command: (string | number)[] }>,
|
||||
jimakuSubtitleLoaded: 0,
|
||||
};
|
||||
|
||||
const options: AnkiJimakuIpcRuntimeOptions = {
|
||||
@@ -148,6 +150,9 @@ function createHarness(): RuntimeHarness {
|
||||
ok: true,
|
||||
path: `${destPath}:${url}`,
|
||||
}),
|
||||
onJimakuSubtitleLoaded: () => {
|
||||
state.jimakuSubtitleLoaded += 1;
|
||||
},
|
||||
};
|
||||
|
||||
let registered: Record<string, (...args: unknown[]) => unknown> = {};
|
||||
@@ -313,6 +318,7 @@ test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to
|
||||
|
||||
registered.onDownloadedSubtitle!('/tmp/subtitle.ass');
|
||||
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
|
||||
assert.equal(state.jimakuSubtitleLoaded, 1);
|
||||
});
|
||||
|
||||
test('onDownloadedSecondarySubtitle loads without stealing the primary track', async () => {
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
error: { error: string; code?: number; retryAfter?: number };
|
||||
}
|
||||
>;
|
||||
onJimakuSubtitleLoaded?: () => void;
|
||||
}
|
||||
|
||||
const logger = createLogger('main:anki-jimaku');
|
||||
@@ -289,6 +290,7 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
const mpvClient = options.getMpvClient();
|
||||
if (mpvClient && mpvClient.connected) {
|
||||
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
|
||||
options.onJimakuSubtitleLoaded?.();
|
||||
}
|
||||
},
|
||||
onDownloadedSecondarySubtitle: async (pathToSubtitle) => {
|
||||
|
||||
@@ -102,6 +102,29 @@ test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required'
|
||||
assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']);
|
||||
});
|
||||
|
||||
test('classifyConfigHotReloadDiff treats anime Jimaku auto-open as hot-reloadable', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.anime.autoOpenJimaku = !prev.anime.autoOpenJimaku;
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
|
||||
assert.deepEqual(diff.hotReloadFields, ['anime.autoOpenJimaku']);
|
||||
assert.deepEqual(diff.restartRequiredFields, []);
|
||||
});
|
||||
|
||||
test('classifyConfigHotReloadDiff keeps other anime settings restart-required', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.anime.autoOpenJimaku = !prev.anime.autoOpenJimaku;
|
||||
next.anime.preferredQuality = '1080';
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
|
||||
assert.deepEqual(diff.hotReloadFields, ['anime.autoOpenJimaku']);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['anime']);
|
||||
});
|
||||
|
||||
test('config hot reload runtime debounces rapid watch events', () => {
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
const pendingTimers = new Map<number, () => void>();
|
||||
|
||||
@@ -65,6 +65,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'logging.rotation',
|
||||
'logging.files',
|
||||
'youtube.primarySubLanguages',
|
||||
'anime.autoOpenJimaku',
|
||||
'jimaku',
|
||||
'subsync',
|
||||
'ankiConnect.deck',
|
||||
|
||||
@@ -617,18 +617,13 @@ test('tracker starts the injected lexical rollup backfill when it is pending', a
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy waits for lexical backfill shutdown before finalizing and draining writes', async () => {
|
||||
test('destroy drains queued telemetry and events after lexical backfill stops', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let releaseBackfill = (): void => {};
|
||||
let releaseTermination = (): void => {};
|
||||
const heldBackfill = new Promise<void>((resolve) => {
|
||||
releaseBackfill = resolve;
|
||||
});
|
||||
const heldTermination = new Promise<void>((resolve) => {
|
||||
releaseTermination = resolve;
|
||||
});
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
const setupDb = new Database(dbPath);
|
||||
@@ -643,41 +638,37 @@ test('destroy waits for lexical backfill shutdown before finalizing and draining
|
||||
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor(
|
||||
{ dbPath },
|
||||
{ dbPath, policy: { batchSize: 1, flushIntervalMs: 60_000 } },
|
||||
{
|
||||
runLexicalRollupBackfillTask: async () => heldBackfill,
|
||||
destroyLexicalRollupBackfillRunner: async () => {
|
||||
calls.push('stop');
|
||||
destroyLexicalRollupBackfillRunner: () => {
|
||||
releaseBackfill();
|
||||
await heldTermination;
|
||||
calls.push('stopped');
|
||||
},
|
||||
},
|
||||
);
|
||||
tracker.handleMediaChange('/tmp/destroy-backfill.mkv', 'Destroy Backfill');
|
||||
tracker.recordCardsMined(1);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
queue: unknown[];
|
||||
finalizeActiveSession: () => void;
|
||||
};
|
||||
const finalizeActiveSession = privateApi.finalizeActiveSession.bind(tracker);
|
||||
privateApi.finalizeActiveSession = () => {
|
||||
finalizeActiveSession();
|
||||
calls.push(`finalized:${privateApi.queue.length}`);
|
||||
};
|
||||
tracker.recordLookup(true);
|
||||
|
||||
const destroyTask = tracker.destroy();
|
||||
assert.ok(destroyTask instanceof Promise);
|
||||
assert.deepEqual(calls, ['stop']);
|
||||
assert.ok(privateApi.queue.length > 0);
|
||||
|
||||
releaseTermination();
|
||||
await destroyTask;
|
||||
assert.deepEqual(calls, ['stop', 'stopped', 'finalized:0']);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const eventCount = db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as {
|
||||
total: number;
|
||||
};
|
||||
const telemetryCount = db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
|
||||
.get() as { total: number };
|
||||
assert.equal(eventCount.total, 2);
|
||||
assert.equal(telemetryCount.total, 2);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} finally {
|
||||
releaseBackfill();
|
||||
releaseTermination();
|
||||
await tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
|
||||
@@ -654,6 +654,7 @@ export class ImmersionTrackerService {
|
||||
const pendingLexicalBackfill = this.lexicalRollupBackfillTask;
|
||||
const finish = (): void => {
|
||||
this.finalizeActiveSession();
|
||||
this.requireWriteQueueDrained('destroying immersion tracker');
|
||||
this.isDestroyed = true;
|
||||
this.deleteMaintenanceScheduler.destroy();
|
||||
this.destroyDeleteMaintenanceRunner();
|
||||
|
||||
+57
-4
@@ -544,6 +544,8 @@ import {
|
||||
createCreateSyncUiWindowHandler,
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime';
|
||||
import { createAnimeBrowserJimakuAutoOpen } from './main/runtime/anime-browser-jimaku-auto-open';
|
||||
import { waitForPlaybackWindow } from './main/runtime/playback-window-ready';
|
||||
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
|
||||
import {
|
||||
ensureBridgeBinaries,
|
||||
@@ -2513,6 +2515,45 @@ const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
|
||||
*/
|
||||
const streamPlaybackMetadata = createStreamPlaybackMetadataStore();
|
||||
|
||||
const animeBrowserJimakuAutoOpen = createAnimeBrowserJimakuAutoOpen({
|
||||
isEnabled: () => configService.getConfig().anime.autoOpenJimaku,
|
||||
isAnimeBrowserMedia: (mediaPath) => streamPlaybackMetadata.match(mediaPath) !== null,
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
getPlaybackPaused: () =>
|
||||
resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => appState.playbackPaused,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
}),
|
||||
setPlaybackPaused: (paused) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', paused ? 'yes' : 'no']);
|
||||
},
|
||||
waitForPlaybackWindow: () =>
|
||||
waitForPlaybackWindow({
|
||||
isWindowTracked: () => appState.windowTracker?.isTracking() ?? null,
|
||||
readProperty: (name) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return Promise.reject(new Error('mpv is not connected.'));
|
||||
return client.requestProperty(name);
|
||||
},
|
||||
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
}),
|
||||
closeAnimeBrowserModal: () => {
|
||||
if (!overlayModalRuntime.isModalOpen('anime-browser')) return;
|
||||
overlayModalRuntime.sendToActiveOverlayWindow(IPC_CHANNELS.event.animeBrowserClose, undefined, {
|
||||
restoreOnModalClose: 'anime-browser',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
},
|
||||
hideAnimeBrowserWindow: () => {
|
||||
const window = appState.animeBrowserWindow;
|
||||
if (window && !window.isDestroyed() && window.isVisible()) {
|
||||
window.hide();
|
||||
}
|
||||
},
|
||||
openJimakuModal: () => openJimakuOverlay(),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
|
||||
/** Stream metadata for the requested path, or current media when none was supplied. */
|
||||
function getActiveStreamMetadata(mediaPath: string | null = null) {
|
||||
return matchRequestedStreamPlaybackMetadata(
|
||||
@@ -2545,6 +2586,7 @@ const mediaRuntime = createMediaRuntimeService(
|
||||
setCurrentMediaPath: (nextPath: string | null) => {
|
||||
appState.currentMediaPath = nextPath;
|
||||
animeBrowserApplicationRuntime.publishPlaybackState(nextPath);
|
||||
void animeBrowserJimakuAutoOpen.handleMediaPathChange(nextPath);
|
||||
},
|
||||
clearPendingSubtitlePosition: () => {
|
||||
appState.pendingSubtitlePosition = null;
|
||||
@@ -2944,20 +2986,22 @@ function openOverlayHostedModalWithOsd(
|
||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
||||
unavailableMessage: string,
|
||||
failureLogMessage: string,
|
||||
): void {
|
||||
void openModal(createOverlayHostedModalOpenDeps())
|
||||
): Promise<boolean> {
|
||||
return openModal(createOverlayHostedModalOpenDeps())
|
||||
.then((opened) => {
|
||||
if (!opened) {
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, {
|
||||
variant: 'warning',
|
||||
});
|
||||
}
|
||||
return opened;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(failureLogMessage, error);
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, {
|
||||
variant: 'error',
|
||||
});
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2969,8 +3013,8 @@ function openRuntimeOptionsPalette(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function openJimakuOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
function openJimakuOverlay(): Promise<boolean> {
|
||||
return openOverlayHostedModalWithOsd(
|
||||
openJimakuModalRuntime,
|
||||
'Jimaku overlay unavailable.',
|
||||
'Failed to open Jimaku overlay.',
|
||||
@@ -3399,6 +3443,9 @@ const animeBrowserApplicationRuntime = createAnimeBrowserApplicationRuntime({
|
||||
repos: () => configService.getConfig().anime?.repos ?? [],
|
||||
setRepos: (repos) => configService.patchRawConfig({ anime: { repos } }),
|
||||
preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined,
|
||||
defaultSourceId: () => configService.getConfig().anime?.defaultSource || undefined,
|
||||
setDefaultSourceId: (defaultSource) =>
|
||||
configService.patchRawConfig({ anime: { defaultSource } }),
|
||||
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
|
||||
ensureBinaries: (onProgress) =>
|
||||
ensureBridgeBinaries({
|
||||
@@ -5729,6 +5776,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
applyOverlayClickThrough(senderWindow);
|
||||
senderWindow.hide();
|
||||
}
|
||||
if (modal === 'jimaku') {
|
||||
animeBrowserJimakuAutoOpen.handleJimakuModalClosed();
|
||||
}
|
||||
handleOverlayModalClosedHandler(modal);
|
||||
},
|
||||
onOverlayModalOpened: (modal, senderWindow) => {
|
||||
@@ -6108,6 +6158,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
headers: Record<string, string>,
|
||||
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
|
||||
) => downloadToFile(url, destPath, headers, downloadOptions),
|
||||
onJimakuSubtitleLoaded: () => {
|
||||
animeBrowserJimakuAutoOpen.handleJimakuSubtitleLoaded();
|
||||
},
|
||||
}),
|
||||
registerIpcRuntimeServices,
|
||||
},
|
||||
|
||||
@@ -145,6 +145,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
resolveJimakuApiKey: AnkiJimakuIpcRuntimeOptions['resolveJimakuApiKey'];
|
||||
isRemoteMediaPath: AnkiJimakuIpcRuntimeOptions['isRemoteMediaPath'];
|
||||
downloadToFile: AnkiJimakuIpcRuntimeOptions['downloadToFile'];
|
||||
onJimakuSubtitleLoaded?: AnkiJimakuIpcRuntimeOptions['onJimakuSubtitleLoaded'];
|
||||
}
|
||||
|
||||
export interface CliCommandRuntimeServiceDepsParams {
|
||||
@@ -350,6 +351,9 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
||||
resolveJimakuApiKey: params.resolveJimakuApiKey,
|
||||
isRemoteMediaPath: params.isRemoteMediaPath,
|
||||
downloadToFile: params.downloadToFile,
|
||||
...(params.onJimakuSubtitleLoaded
|
||||
? { onJimakuSubtitleLoaded: params.onJimakuSubtitleLoaded }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,3 +29,19 @@ test('anime browser preference IPC coerces values at the renderer boundary', ()
|
||||
['source', 'invalid', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
test('anime browser bulk update IPC returns the runtime result', async () => {
|
||||
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
registerAnimeBrowserIpcHandlers({
|
||||
ipcMain: {
|
||||
handle: (channel, listener) => handlers.set(channel, listener),
|
||||
},
|
||||
runtime: {
|
||||
updateAllExtensions: async () => 3,
|
||||
} as never,
|
||||
});
|
||||
|
||||
const updateAll = handlers.get(IPC_CHANNELS.request.animeBrowserUpdateAllExtensions);
|
||||
assert.ok(updateAll);
|
||||
assert.equal(await updateAll({}), 3);
|
||||
});
|
||||
|
||||
@@ -52,6 +52,9 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
||||
handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) =>
|
||||
runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)),
|
||||
);
|
||||
handle(channels.animeBrowserSetDefaultSource, (_event, sourceId) =>
|
||||
runtime.setDefaultSource(String(sourceId)),
|
||||
);
|
||||
handle(channels.animeBrowserSearch, (event, sessionId, query, page) =>
|
||||
runtime.search(String(query ?? ''), toPage(page), registerSession(deps, event, sessionId)),
|
||||
);
|
||||
@@ -82,6 +85,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
||||
handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
|
||||
runtime.installExtension(String(pkg)),
|
||||
);
|
||||
handle(channels.animeBrowserUpdateAllExtensions, () => runtime.updateAllExtensions());
|
||||
handle(channels.animeBrowserRemoveExtension, (_event, pkg) =>
|
||||
runtime.removeExtension(String(pkg)),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAnimeBrowserJimakuAutoOpen } from './anime-browser-jimaku-auto-open';
|
||||
|
||||
function createHarness(options?: {
|
||||
enabled?: boolean;
|
||||
animeMedia?: boolean | ((mediaPath: string) => boolean);
|
||||
paused?: boolean | null;
|
||||
opened?: boolean;
|
||||
windowReady?: () => Promise<boolean>;
|
||||
}) {
|
||||
const calls: string[] = [];
|
||||
let currentMediaPath: string | null = null;
|
||||
const runtime = createAnimeBrowserJimakuAutoOpen({
|
||||
isEnabled: () => options?.enabled ?? true,
|
||||
isAnimeBrowserMedia: (mediaPath) =>
|
||||
typeof options?.animeMedia === 'function'
|
||||
? options.animeMedia(mediaPath)
|
||||
: (options?.animeMedia ?? true),
|
||||
getCurrentMediaPath: () => currentMediaPath,
|
||||
getPlaybackPaused: async () => (options?.paused === undefined ? false : options.paused),
|
||||
setPlaybackPaused: (paused) => calls.push(`pause:${paused}`),
|
||||
waitForPlaybackWindow: () => {
|
||||
calls.push('wait-window');
|
||||
return options?.windowReady?.() ?? Promise.resolve(true);
|
||||
},
|
||||
closeAnimeBrowserModal: () => calls.push('close-anime-browser'),
|
||||
hideAnimeBrowserWindow: () => calls.push('hide-anime-browser-window'),
|
||||
openJimakuModal: async () => {
|
||||
calls.push('open-jimaku');
|
||||
return options?.opened ?? true;
|
||||
},
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
});
|
||||
|
||||
return {
|
||||
calls,
|
||||
runtime,
|
||||
setMediaPath: (mediaPath: string | null) => {
|
||||
currentMediaPath = mediaPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const OPEN_SEQUENCE = [
|
||||
'pause:true',
|
||||
'wait-window',
|
||||
'close-anime-browser',
|
||||
'hide-anime-browser-window',
|
||||
'open-jimaku',
|
||||
];
|
||||
|
||||
test('anime browser playback pauses, waits for the mpv window, opens Jimaku, and resumes after subtitle load', async () => {
|
||||
const harness = createHarness();
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
assert.deepEqual(harness.calls, OPEN_SEQUENCE);
|
||||
|
||||
harness.runtime.handleJimakuSubtitleLoaded();
|
||||
assert.deepEqual(harness.calls, [...OPEN_SEQUENCE, 'pause:false']);
|
||||
});
|
||||
|
||||
test('anime browser playback that was already paused stays paused after subtitle load', async () => {
|
||||
const harness = createHarness({ paused: true });
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
harness.runtime.handleJimakuSubtitleLoaded();
|
||||
|
||||
assert.deepEqual(harness.calls, [
|
||||
'wait-window',
|
||||
'close-anime-browser',
|
||||
'hide-anime-browser-window',
|
||||
'open-jimaku',
|
||||
]);
|
||||
});
|
||||
|
||||
test('disabled and non-Anime Browser media do not open Jimaku', async () => {
|
||||
const disabled = createHarness({ enabled: false });
|
||||
disabled.setMediaPath('/video.mkv');
|
||||
await disabled.runtime.handleMediaPathChange('/video.mkv');
|
||||
assert.deepEqual(disabled.calls, []);
|
||||
|
||||
const unrelated = createHarness({ animeMedia: false });
|
||||
unrelated.setMediaPath('/video.mkv');
|
||||
await unrelated.runtime.handleMediaPathChange('/video.mkv');
|
||||
assert.deepEqual(unrelated.calls, []);
|
||||
});
|
||||
|
||||
test('a stream that never shows a window releases the pause without opening Jimaku', async () => {
|
||||
const harness = createHarness({ windowReady: async () => false });
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
assert.ok(!harness.calls.includes('open-jimaku'));
|
||||
assert.equal(harness.calls.at(-1), 'pause:false');
|
||||
});
|
||||
|
||||
test('a newer episode during the window wait cancels the stale Jimaku open', async () => {
|
||||
const firstWait: { resolve: (ready: boolean) => void } = { resolve: () => {} };
|
||||
const firstWaitPromise = new Promise<boolean>((resolve) => {
|
||||
firstWait.resolve = resolve;
|
||||
});
|
||||
let notifyFirstWaitStarted: () => void = () => {};
|
||||
const firstWaitStarted = new Promise<void>((resolve) => {
|
||||
notifyFirstWaitStarted = resolve;
|
||||
});
|
||||
let waits = 0;
|
||||
const harness = createHarness({
|
||||
windowReady: () => {
|
||||
waits += 1;
|
||||
if (waits === 1) {
|
||||
notifyFirstWaitStarted();
|
||||
return firstWaitPromise;
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
});
|
||||
|
||||
harness.setMediaPath('https://127.0.0.1/one.m3u8');
|
||||
const first = harness.runtime.handleMediaPathChange('https://127.0.0.1/one.m3u8');
|
||||
await firstWaitStarted;
|
||||
|
||||
harness.setMediaPath('https://127.0.0.1/two.m3u8');
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/two.m3u8');
|
||||
firstWait.resolve(true);
|
||||
await first;
|
||||
|
||||
assert.equal(harness.calls.filter((call) => call === 'open-jimaku').length, 1);
|
||||
});
|
||||
|
||||
test('closing Jimaku or failing to open it releases an owned pause', async () => {
|
||||
const closed = createHarness();
|
||||
closed.setMediaPath('https://127.0.0.1/one.m3u8');
|
||||
await closed.runtime.handleMediaPathChange('https://127.0.0.1/one.m3u8');
|
||||
closed.runtime.handleJimakuModalClosed();
|
||||
assert.equal(closed.calls.at(-1), 'pause:false');
|
||||
|
||||
const failed = createHarness({ opened: false });
|
||||
failed.setMediaPath('https://127.0.0.1/two.m3u8');
|
||||
await failed.runtime.handleMediaPathChange('https://127.0.0.1/two.m3u8');
|
||||
assert.equal(failed.calls.at(-1), 'pause:false');
|
||||
});
|
||||
|
||||
test('leaving Anime Browser playback releases an owned pause', async () => {
|
||||
const harness = createHarness({
|
||||
animeMedia: (mediaPath) => mediaPath.startsWith('https://127.0.0.1/'),
|
||||
});
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
harness.setMediaPath('/video.mkv');
|
||||
await harness.runtime.handleMediaPathChange('/video.mkv');
|
||||
|
||||
assert.equal(harness.calls.at(-1), 'pause:false');
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
export interface AnimeBrowserJimakuAutoOpenDeps {
|
||||
isEnabled: () => boolean;
|
||||
isAnimeBrowserMedia: (mediaPath: string) => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
setPlaybackPaused: (paused: boolean) => void;
|
||||
/** Resolves true once mpv shows a video window; false when it never appears. */
|
||||
waitForPlaybackWindow: () => Promise<boolean>;
|
||||
closeAnimeBrowserModal: () => void;
|
||||
hideAnimeBrowserWindow: () => void;
|
||||
openJimakuModal: () => Promise<boolean>;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export interface AnimeBrowserJimakuAutoOpen {
|
||||
handleMediaPathChange: (mediaPath: string | null) => Promise<void>;
|
||||
handleJimakuSubtitleLoaded: () => void;
|
||||
handleJimakuModalClosed: () => void;
|
||||
}
|
||||
|
||||
interface ActiveFlow {
|
||||
mediaPath: string;
|
||||
ownsPause: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates the pause owned by the Anime Browser to Jimaku handoff.
|
||||
*
|
||||
* The pause goes out on the path change so the stream freezes on its first
|
||||
* frame, but Jimaku waits for mpv's window: the modal takes its geometry from
|
||||
* that window, and on macOS closing the last modal yields focus back to it.
|
||||
* Both Anime Browser surfaces get out of the way before Jimaku opens; a
|
||||
* visible standalone window would otherwise be pulled in front of mpv the
|
||||
* next time the app activates after that focus handoff.
|
||||
*/
|
||||
export function createAnimeBrowserJimakuAutoOpen(
|
||||
deps: AnimeBrowserJimakuAutoOpenDeps,
|
||||
): AnimeBrowserJimakuAutoOpen {
|
||||
let activeFlow: ActiveFlow | null = null;
|
||||
let lastMediaPath: string | null = null;
|
||||
|
||||
const isCurrent = (flow: ActiveFlow): boolean =>
|
||||
activeFlow === flow && deps.getCurrentMediaPath()?.trim() === flow.mediaPath;
|
||||
|
||||
const releaseFlow = (flow: ActiveFlow | null): void => {
|
||||
if (!flow || activeFlow !== flow) return;
|
||||
activeFlow = null;
|
||||
if (flow.ownsPause) {
|
||||
deps.setPlaybackPaused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaPathChange = async (mediaPath: string | null): Promise<void> => {
|
||||
const normalizedPath = mediaPath?.trim() || null;
|
||||
if (normalizedPath === lastMediaPath) return;
|
||||
lastMediaPath = normalizedPath;
|
||||
|
||||
if (!normalizedPath || !deps.isEnabled() || !deps.isAnimeBrowserMedia(normalizedPath)) {
|
||||
releaseFlow(activeFlow);
|
||||
return;
|
||||
}
|
||||
|
||||
const flow: ActiveFlow = {
|
||||
mediaPath: normalizedPath,
|
||||
ownsPause: activeFlow?.ownsPause ?? false,
|
||||
};
|
||||
activeFlow = flow;
|
||||
|
||||
if (!flow.ownsPause) {
|
||||
try {
|
||||
const paused = await deps.getPlaybackPaused();
|
||||
if (!isCurrent(flow)) return;
|
||||
if (paused === false) {
|
||||
deps.setPlaybackPaused(true);
|
||||
flow.ownsPause = true;
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('Could not read playback state before opening Jimaku.', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCurrent(flow)) return;
|
||||
try {
|
||||
const windowReady = await deps.waitForPlaybackWindow();
|
||||
if (!isCurrent(flow)) return;
|
||||
if (!windowReady) {
|
||||
deps.logWarn('mpv showed no video window for Anime Browser playback; skipping Jimaku.');
|
||||
releaseFlow(flow);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('Could not wait for the mpv window before opening Jimaku.', error);
|
||||
releaseFlow(flow);
|
||||
return;
|
||||
}
|
||||
|
||||
deps.closeAnimeBrowserModal();
|
||||
deps.hideAnimeBrowserWindow();
|
||||
|
||||
try {
|
||||
const opened = await deps.openJimakuModal();
|
||||
if (isCurrent(flow) && !opened) {
|
||||
releaseFlow(flow);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('Could not open Jimaku for Anime Browser playback.', error);
|
||||
releaseFlow(flow);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleMediaPathChange,
|
||||
handleJimakuSubtitleLoaded: () => releaseFlow(activeFlow),
|
||||
handleJimakuModalClosed: () => releaseFlow(activeFlow),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
import { ALL_SOURCES_ID } from '../../types/anime-browser';
|
||||
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
|
||||
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
|
||||
} as unknown as AnimeBridgeClient;
|
||||
|
||||
async function setupRuntime(defaultSource: string) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-default-source-'));
|
||||
await writeFile(path.join(dir, 'pkg.one.apk'), 'one');
|
||||
await writeFile(path.join(dir, 'pkg.two.apk'), 'two');
|
||||
let saved = defaultSource;
|
||||
const runtime = createAnimeBrowserRuntime({
|
||||
extensionsDir: () => dir,
|
||||
repos: () => [],
|
||||
setRepos: () => undefined,
|
||||
defaultSourceId: () => saved,
|
||||
setDefaultSourceId: (sourceId) => {
|
||||
saved = sourceId;
|
||||
},
|
||||
preferencesFile: path.join(dir, 'preferences.json'),
|
||||
ensureBinaries: async () => ({}) as never,
|
||||
checkBridgeUpdate: async () => null,
|
||||
stageBridgeUpdate: async () => {
|
||||
throw new Error('not under test');
|
||||
},
|
||||
sendMpvCommand: () => undefined,
|
||||
ensureMpvConnected: async () => true,
|
||||
onBridgeState: () => undefined,
|
||||
log: () => undefined,
|
||||
startSidecar: async () => ({
|
||||
client,
|
||||
baseUrl: 'http://127.0.0.1:12345',
|
||||
port: 12345,
|
||||
stop: async () => undefined,
|
||||
onExit: () => undefined,
|
||||
}),
|
||||
startStreamStripProxy: async () => ({
|
||||
origin: 'http://127.0.0.1:12346',
|
||||
port: 12346,
|
||||
close: async () => undefined,
|
||||
}),
|
||||
});
|
||||
await runtime.ensureBridge();
|
||||
return { runtime, saved: () => saved };
|
||||
}
|
||||
|
||||
test('a new browser session opens on the configured default source', async () => {
|
||||
const { runtime } = await setupRuntime('pkg.two:shared');
|
||||
const snapshot = runtime.getSnapshot('fresh');
|
||||
assert.equal(snapshot.selectedSourceId, 'pkg.two:shared');
|
||||
assert.equal(snapshot.defaultSourceId, 'pkg.two:shared');
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test('"all" as the default selects every source, and an unknown default falls back to the first', async () => {
|
||||
const all = await setupRuntime(ALL_SOURCES_ID);
|
||||
assert.equal(all.runtime.getSnapshot('fresh').selectedSourceId, ALL_SOURCES_ID);
|
||||
await all.runtime.dispose();
|
||||
|
||||
const missing = await setupRuntime('pkg.gone:shared');
|
||||
assert.equal(missing.runtime.getSnapshot('fresh').selectedSourceId, 'pkg.one:shared');
|
||||
await missing.runtime.dispose();
|
||||
});
|
||||
|
||||
test('setDefaultSource persists an installed source and rejects an unknown one', async () => {
|
||||
const { runtime, saved } = await setupRuntime('');
|
||||
runtime.setDefaultSource('pkg.two:shared');
|
||||
assert.equal(saved(), 'pkg.two:shared');
|
||||
assert.throws(() => runtime.setDefaultSource('pkg.gone:shared'), /no longer installed/);
|
||||
assert.equal(saved(), 'pkg.two:shared');
|
||||
// Sessions that already exist keep their own selection; only new ones follow the default.
|
||||
assert.equal(runtime.getSnapshot('existing').selectedSourceId, 'pkg.two:shared');
|
||||
await runtime.dispose();
|
||||
});
|
||||
@@ -79,6 +79,13 @@ export interface AnimeBrowserRuntimeDeps {
|
||||
/** Streams per-source progress while a search invoke is pending. */
|
||||
onSearchUpdate?: (update: AnimeBrowserSearchUpdate, sessionId: string) => void;
|
||||
preferredQuality?: () => string | undefined;
|
||||
/**
|
||||
* Source id (or `all`) a new browser session starts on. Falls back to the
|
||||
* first installed source when unset or no longer installed.
|
||||
*/
|
||||
defaultSourceId?: () => string | undefined;
|
||||
/** Persists the default; absent when the host has no config to write to. */
|
||||
setDefaultSourceId?: (sourceId: string) => void;
|
||||
log: (message: string) => void;
|
||||
/** Overrides process startup in focused runtime tests. */
|
||||
startSidecar?: typeof startSidecar;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
|
||||
|
||||
const REPO_URL = 'https://repo.example/anime/index.json';
|
||||
const PKG = 'eu.kanade.tachiyomi.animeextension.all.example';
|
||||
|
||||
function createTestRuntime(
|
||||
directory: string,
|
||||
client: AnimeBridgeClient,
|
||||
repos: readonly string[] = [],
|
||||
) {
|
||||
return createAnimeBrowserRuntime({
|
||||
extensionsDir: () => directory,
|
||||
repos: () => [...repos],
|
||||
setRepos: () => undefined,
|
||||
preferencesFile: path.join(directory, 'preferences.json'),
|
||||
ensureBinaries: async () => ({}) as never,
|
||||
checkBridgeUpdate: async () => null,
|
||||
stageBridgeUpdate: async () => {
|
||||
throw new Error('not under test');
|
||||
},
|
||||
sendMpvCommand: () => undefined,
|
||||
ensureMpvConnected: async () => true,
|
||||
onBridgeState: () => undefined,
|
||||
log: () => undefined,
|
||||
startSidecar: async () => ({
|
||||
client,
|
||||
baseUrl: 'http://127.0.0.1:12345',
|
||||
port: 12345,
|
||||
stop: async () => undefined,
|
||||
onExit: () => undefined,
|
||||
}),
|
||||
startStreamStripProxy: async () => ({
|
||||
origin: 'http://127.0.0.1:12346',
|
||||
port: 12346,
|
||||
close: async () => undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
test('extension mutations run in request order while a download is pending', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-extension-mutations-'));
|
||||
const apkPath = path.join(directory, `${PKG}.apk`);
|
||||
await writeFile(apkPath, 'old apk');
|
||||
|
||||
let notifyDownloadStarted: () => void = () => undefined;
|
||||
const downloadStarted = new Promise<void>((resolve) => {
|
||||
notifyDownloadStarted = resolve;
|
||||
});
|
||||
let releaseDownload: () => void = () => undefined;
|
||||
const downloadGate = new Promise<void>((resolve) => {
|
||||
releaseDownload = resolve;
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
if (String(input) === REPO_URL) {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'Aniyomi: Example',
|
||||
pkg: PKG,
|
||||
apk: 'example.apk',
|
||||
lang: 'all',
|
||||
code: 2,
|
||||
version: '2.0',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
notifyDownloadStarted();
|
||||
await downloadGate;
|
||||
const body = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x01]) as unknown as BodyInit;
|
||||
return new Response(body);
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'one', name: 'Example', lang: 'all' }],
|
||||
} as unknown as AnimeBridgeClient;
|
||||
const runtime = createTestRuntime(directory, client, [REPO_URL]);
|
||||
|
||||
try {
|
||||
await runtime.ensureBridge();
|
||||
const install = runtime.installExtension(PKG);
|
||||
await downloadStarted;
|
||||
const remove = runtime.removeExtension(PKG);
|
||||
releaseDownload();
|
||||
await Promise.all([install, remove]);
|
||||
|
||||
assert.equal(existsSync(apkPath), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('startup scanning cannot restore an extension removed concurrently', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-extension-startup-'));
|
||||
const apkPath = path.join(directory, `${PKG}.apk`);
|
||||
await writeFile(apkPath, 'old apk');
|
||||
|
||||
let notifyScanStarted: () => void = () => undefined;
|
||||
const scanStarted = new Promise<void>((resolve) => {
|
||||
notifyScanStarted = resolve;
|
||||
});
|
||||
let releaseScan: () => void = () => undefined;
|
||||
const scanGate = new Promise<void>((resolve) => {
|
||||
releaseScan = resolve;
|
||||
});
|
||||
const client = {
|
||||
listAnimeSources: async () => {
|
||||
notifyScanStarted();
|
||||
await scanGate;
|
||||
return [{ id: 'one', name: 'Example', lang: 'all' }];
|
||||
},
|
||||
} as unknown as AnimeBridgeClient;
|
||||
const runtime = createTestRuntime(directory, client);
|
||||
|
||||
try {
|
||||
const start = runtime.ensureBridge();
|
||||
await scanStarted;
|
||||
const remove = runtime.removeExtension(PKG);
|
||||
releaseScan();
|
||||
await Promise.all([start, remove]);
|
||||
|
||||
assert.equal(existsSync(apkPath), false);
|
||||
assert.deepEqual(runtime.getSnapshot().installed, []);
|
||||
assert.deepEqual(runtime.getSnapshot().sources, []);
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { BridgePreference } from '../../anime-bridge/types';
|
||||
@@ -145,6 +145,32 @@ test('colliding bridge ids keep package preferences isolated and uninstall clear
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test('uninstall rescans after preference cleanup fails and propagates the failure', async () => {
|
||||
const saved = textPreference('password', 'secret');
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
|
||||
getSourcePreferences: async () => [textPreference('password')],
|
||||
};
|
||||
const { runtime, preferencesFile } = await setupRuntime(
|
||||
client,
|
||||
{ 'pkg.one': 'one' },
|
||||
{ 'pkg.one:shared': [saved] },
|
||||
);
|
||||
await runtime.getPreferences('pkg.one:shared');
|
||||
|
||||
const blockedTemporaryFile = `${preferencesFile}.tmp`;
|
||||
await mkdir(blockedTemporaryFile);
|
||||
try {
|
||||
await assert.rejects(runtime.removeExtension('pkg.one'));
|
||||
assert.deepEqual(runtime.getSnapshot().installed, []);
|
||||
assert.deepEqual(runtime.getSnapshot().sources, []);
|
||||
} finally {
|
||||
await chmod(blockedTemporaryFile, 0o700);
|
||||
await rm(blockedTemporaryFile, { recursive: true, force: true });
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('standalone and modal browser sessions keep source selection and searches isolated', async () => {
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
ExtensionLoadFailure,
|
||||
} from '../../types/anime-browser';
|
||||
import type { BridgeAnimePage, BridgePreference } from '../../anime-bridge/types';
|
||||
import { findExtensionUpdates, hasExtensionUpdate } from '../../shared/extension-updates';
|
||||
import { createAnimeBrowserPlayback } from './anime-browser-playback';
|
||||
import { createAnimeBrowserQueue } from './anime-browser-queue';
|
||||
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
||||
@@ -72,6 +73,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
let stripProxy: StreamStripProxyHandle | null = null;
|
||||
let starting: Promise<SidecarHandle> | null = null;
|
||||
let updating: Promise<AnimeBrowserBridgeState> | null = null;
|
||||
let extensionMutationTail = Promise.resolve();
|
||||
let extensions: InstalledExtension[] = [];
|
||||
let sources: ExtensionSource[] = [];
|
||||
let loadFailures: ExtensionLoadFailure[] = [];
|
||||
@@ -81,10 +83,33 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
>();
|
||||
const preferenceStore = new PreferenceStore(deps.preferencesFile);
|
||||
|
||||
function withExtensionMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = extensionMutationTail.then(operation);
|
||||
extensionMutationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured default when it is installed (or `all` with anything
|
||||
* installed), otherwise the first source the scan found. Null with nothing
|
||||
* installed.
|
||||
*/
|
||||
function initialSourceId(): string | null {
|
||||
const configured = deps.defaultSourceId?.()?.trim();
|
||||
if (configured) {
|
||||
if (configured === ALL_SOURCES_ID && sources.length > 0) return ALL_SOURCES_ID;
|
||||
if (sources.some((source) => source.id === configured)) return configured;
|
||||
}
|
||||
return sources[0]?.id ?? null;
|
||||
}
|
||||
|
||||
function getBrowserSession(sessionId = 'default') {
|
||||
const existing = browserSessions.get(sessionId);
|
||||
if (existing) return existing;
|
||||
const created = { selectedSourceId: sources[0]?.id ?? null, searchToken: 0 };
|
||||
const created = { selectedSourceId: initialSourceId(), searchToken: 0 };
|
||||
browserSessions.set(sessionId, created);
|
||||
return created;
|
||||
}
|
||||
@@ -187,7 +212,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`);
|
||||
}
|
||||
|
||||
await scanExtensions(handle);
|
||||
await withExtensionMutation(() => scanExtensions(handle));
|
||||
void checkForBridgeUpdate(handle);
|
||||
return handle;
|
||||
}
|
||||
@@ -234,7 +259,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
for (const session of browserSessions.values()) {
|
||||
const keptAll = session.selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
|
||||
if (!keptAll && !sources.some((source) => source.id === session.selectedSourceId)) {
|
||||
session.selectedSourceId = sources[0]?.id ?? null;
|
||||
session.selectedSourceId = initialSourceId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,8 +361,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
return updating;
|
||||
}
|
||||
|
||||
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
|
||||
async function downloadExtension(extension: RepoExtension): Promise<void> {
|
||||
await installExtension({ extensionsDir: deps.extensionsDir(), extension });
|
||||
}
|
||||
|
||||
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
|
||||
await downloadExtension(extension);
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
}
|
||||
|
||||
@@ -511,6 +540,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
pkg: source.pkg,
|
||||
})),
|
||||
selectedSourceId: session.selectedSourceId,
|
||||
defaultSourceId: deps.defaultSourceId?.()?.trim() || null,
|
||||
loadFailures,
|
||||
installed: toInstalledExtensionViews(extensions, sources, loadFailures),
|
||||
extensionsDir: deps.extensionsDir(),
|
||||
@@ -538,6 +568,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
name: extension.name,
|
||||
lang: extension.lang,
|
||||
version: extension.version,
|
||||
versionCode: extension.versionCode,
|
||||
nsfw: extension.nsfw,
|
||||
repoUrl: extension.repoUrl,
|
||||
iconUrl: extension.iconUrl,
|
||||
@@ -549,22 +580,64 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
},
|
||||
|
||||
/** Download an extension by package name, then rescan. */
|
||||
async installExtension(pkg: string): Promise<void> {
|
||||
const repos = deps.repos();
|
||||
if (repos.length === 0) throw new Error('No extension repository is configured.');
|
||||
installExtension(pkg: string): Promise<void> {
|
||||
return withExtensionMutation(async () => {
|
||||
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.`);
|
||||
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);
|
||||
const current = extensions.find((candidate) => candidate.fallbackName === pkg);
|
||||
if (
|
||||
current &&
|
||||
current.versionCode !== null &&
|
||||
!hasExtensionUpdate(current.versionCode, match.versionCode)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await installExtensionFrom(match);
|
||||
});
|
||||
},
|
||||
|
||||
/** Download every strictly newer repository build, then rescan once. */
|
||||
updateAllExtensions(): Promise<number> {
|
||||
return withExtensionMutation(async () => {
|
||||
const repos = deps.repos();
|
||||
if (repos.length === 0) return 0;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue(repos);
|
||||
const installedVersions = extensions.map((extension) => ({
|
||||
pkg: extension.fallbackName,
|
||||
versionCode: extension.versionCode,
|
||||
}));
|
||||
const updates = findExtensionUpdates(installedVersions, catalogue.extensions);
|
||||
|
||||
let installedCount = 0;
|
||||
try {
|
||||
for (const extension of updates) {
|
||||
await downloadExtension(extension);
|
||||
installedCount += 1;
|
||||
}
|
||||
} finally {
|
||||
if (installedCount > 0 && sidecar) await scanExtensions(sidecar);
|
||||
}
|
||||
return installedCount;
|
||||
});
|
||||
},
|
||||
|
||||
/** Remove an installed extension, then rescan. */
|
||||
async removeExtension(pkg: string): Promise<void> {
|
||||
await removeExtensionFile(deps.extensionsDir(), pkg);
|
||||
await preferenceStore.clear(pkg).catch(() => undefined);
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
removeExtension(pkg: string): Promise<void> {
|
||||
return withExtensionMutation(async () => {
|
||||
await removeExtensionFile(deps.extensionsDir(), pkg);
|
||||
try {
|
||||
await preferenceStore.clear(pkg);
|
||||
} finally {
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -586,8 +659,10 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
},
|
||||
|
||||
/** Re-read the extensions directory without restarting the bridge. */
|
||||
async rescanExtensions(): Promise<void> {
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
rescanExtensions(): Promise<void> {
|
||||
return withExtensionMutation(async () => {
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
});
|
||||
},
|
||||
|
||||
selectSource(sourceId: string, sessionId = 'default'): void {
|
||||
@@ -599,6 +674,20 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
if (sources.some((source) => source.id === sourceId)) session.selectedSourceId = sourceId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remember `sourceId` as the source every future browser session opens on.
|
||||
* Only an installed source (or `all`) is accepted, so a typo cannot be
|
||||
* persisted from the UI; the config file itself is not validated this way.
|
||||
*/
|
||||
setDefaultSource(sourceId: string): void {
|
||||
const valid =
|
||||
(sourceId === ALL_SOURCES_ID && sources.length > 0) ||
|
||||
sources.some((source) => source.id === sourceId);
|
||||
if (!valid) throw new Error('That source is no longer installed. Rescan and try again.');
|
||||
if (!deps.setDefaultSourceId) throw new Error('Default source cannot be saved here.');
|
||||
deps.setDefaultSourceId(sourceId);
|
||||
},
|
||||
|
||||
/**
|
||||
* The extension's settings schema, merged with anything saved locally. The
|
||||
* extension is the source of truth for structure; saved values only supply
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { waitForPlaybackWindow } from './playback-window-ready';
|
||||
|
||||
function createClock() {
|
||||
let time = 0;
|
||||
return {
|
||||
now: () => time,
|
||||
wait: async (ms: number) => {
|
||||
time += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('resolves once the video output is configured and the tracker has the window', async () => {
|
||||
const clock = createClock();
|
||||
let probes = 0;
|
||||
let tracked = false;
|
||||
const ready = await waitForPlaybackWindow({
|
||||
...clock,
|
||||
isWindowTracked: () => tracked,
|
||||
readProperty: async () => {
|
||||
probes += 1;
|
||||
if (probes === 1) throw new Error('property unavailable');
|
||||
if (probes === 3) tracked = true;
|
||||
return probes >= 2;
|
||||
},
|
||||
probeIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.equal(probes, 3);
|
||||
assert.equal(clock.now(), 200);
|
||||
});
|
||||
|
||||
test('a configured video output is enough when no window tracker is running', async () => {
|
||||
const ready = await waitForPlaybackWindow({
|
||||
...createClock(),
|
||||
isWindowTracked: () => null,
|
||||
readProperty: async () => true,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('gives up after the wall-clock budget when no window appears', async () => {
|
||||
const clock = createClock();
|
||||
const ready = await waitForPlaybackWindow({
|
||||
...clock,
|
||||
isWindowTracked: () => false,
|
||||
readProperty: async () => true,
|
||||
timeoutMs: 1000,
|
||||
probeIntervalMs: 300,
|
||||
});
|
||||
|
||||
assert.equal(ready, false);
|
||||
assert.equal(clock.now(), 1000);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Waits until mpv has a video window the overlay can attach to.
|
||||
*
|
||||
* mpv reports a new `path` as soon as loading starts, which for a network
|
||||
* stream is seconds before any window exists (idle mpv shows none). A modal
|
||||
* opened on the path change lands on a blank desktop with fallback geometry.
|
||||
* Ready means the video output is configured (`vo-configured`, a window with a
|
||||
* frame in it) and, when a window tracker is running, that it has located the
|
||||
* window so overlay geometry follows it.
|
||||
*/
|
||||
export interface WaitForPlaybackWindowDeps {
|
||||
/** Tracker state, or null when no window tracker is running. */
|
||||
isWindowTracked: () => boolean | null;
|
||||
/** One-shot mpv property read; may reject while the file is still loading. */
|
||||
readProperty: (name: string) => Promise<unknown>;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
now?: () => number;
|
||||
timeoutMs?: number;
|
||||
probeIntervalMs?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYBACK_WINDOW_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_PROBE_INTERVAL_MS = 200;
|
||||
|
||||
/** Resolves true once the window is ready, false when the wall-clock budget runs out. */
|
||||
export async function waitForPlaybackWindow(deps: WaitForPlaybackWindowDeps): Promise<boolean> {
|
||||
const now = deps.now ?? Date.now;
|
||||
const timeoutMs = deps.timeoutMs ?? DEFAULT_PLAYBACK_WINDOW_TIMEOUT_MS;
|
||||
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
|
||||
const deadline = now() + timeoutMs;
|
||||
|
||||
for (;;) {
|
||||
let voConfigured = false;
|
||||
try {
|
||||
voConfigured = (await deps.readProperty('vo-configured')) === true;
|
||||
} catch {
|
||||
// Unreadable between files or before mpv connects; keep polling.
|
||||
}
|
||||
if (voConfigured && deps.isWindowTracked() !== false) return true;
|
||||
|
||||
const remaining = deadline - now();
|
||||
if (remaining <= 0) return false;
|
||||
await deps.wait(Math.min(probeIntervalMs, remaining));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ test('anime browser API keeps one opaque session per renderer bridge', async ()
|
||||
await firstApi.getDetails('/frieren');
|
||||
await firstApi.getEpisodes('/frieren', 'source.one');
|
||||
await firstApi.getPlaybackState();
|
||||
await firstApi.updateAllExtensions();
|
||||
await secondApi.getPopular(3);
|
||||
|
||||
const firstSessionId = first.calls[0]?.args[0];
|
||||
@@ -61,6 +62,10 @@ test('anime browser API keeps one opaque session per renderer bridge', async ()
|
||||
channel: IPC_CHANNELS.request.animeBrowserGetPlaybackState,
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.request.animeBrowserUpdateAllExtensions,
|
||||
args: [],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(second.calls, [
|
||||
{
|
||||
|
||||
@@ -44,6 +44,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
|
||||
ipcRenderer.invoke(request.animeBrowserUpdateBridge),
|
||||
selectSource: (sourceId: string): Promise<void> =>
|
||||
ipcRenderer.invoke(request.animeBrowserSelectSource, sessionId, sourceId),
|
||||
setDefaultSource: (sourceId: string): Promise<void> =>
|
||||
ipcRenderer.invoke(request.animeBrowserSetDefaultSource, sourceId),
|
||||
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
|
||||
ipcRenderer.invoke(request.animeBrowserSearch, sessionId, query, page),
|
||||
getPopular: (page?: number): Promise<AnimeBrowserSearchResult> =>
|
||||
@@ -85,6 +87,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
|
||||
ipcRenderer.invoke(request.animeBrowserListAvailableExtensions),
|
||||
installExtension: (pkg: string): Promise<void> =>
|
||||
ipcRenderer.invoke(request.animeBrowserInstallExtension, pkg),
|
||||
updateAllExtensions: (): Promise<number> =>
|
||||
ipcRenderer.invoke(request.animeBrowserUpdateAllExtensions),
|
||||
removeExtension: (pkg: string): Promise<void> =>
|
||||
ipcRenderer.invoke(request.animeBrowserRemoveExtension, pkg),
|
||||
rescanExtensions: (): Promise<void> => ipcRenderer.invoke(request.animeBrowserRescanExtensions),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { findExtensionUpdates } from './extension-updates';
|
||||
|
||||
test('findExtensionUpdates returns only strictly newer builds for known installed versions', () => {
|
||||
const updates = findExtensionUpdates(
|
||||
[
|
||||
{ pkg: 'old', versionCode: 10 },
|
||||
{ pkg: 'current', versionCode: 12 },
|
||||
{ pkg: 'newer-than-repo', versionCode: 20 },
|
||||
{ pkg: 'unknown', versionCode: null },
|
||||
],
|
||||
[
|
||||
{ pkg: 'old', versionCode: 11, name: 'Old' },
|
||||
{ pkg: 'current', versionCode: 12, name: 'Current' },
|
||||
{ pkg: 'newer-than-repo', versionCode: 19, name: 'Newer' },
|
||||
{ pkg: 'unknown', versionCode: 30, name: 'Unknown' },
|
||||
{ pkg: 'not-installed', versionCode: 1, name: 'Not installed' },
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(updates, [{ pkg: 'old', versionCode: 11, name: 'Old' }]);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Repository version codes are monotonic, so only a strictly newer code is an update. */
|
||||
export function hasExtensionUpdate(
|
||||
installedVersionCode: number | null,
|
||||
availableVersionCode: number,
|
||||
): boolean {
|
||||
return installedVersionCode !== null && availableVersionCode > installedVersionCode;
|
||||
}
|
||||
|
||||
/** Keep only installed packages whose repository build is strictly newer. */
|
||||
export function findExtensionUpdates<T extends { pkg: string; versionCode: number }>(
|
||||
installed: ReadonlyArray<{ pkg: string; versionCode: number | null }>,
|
||||
offered: readonly T[],
|
||||
): T[] {
|
||||
const installedVersions = new Map(
|
||||
installed.map((extension) => [extension.pkg, extension.versionCode]),
|
||||
);
|
||||
return offered.filter((extension) => {
|
||||
const installedVersion = installedVersions.get(extension.pkg);
|
||||
return installedVersion === undefined
|
||||
? false
|
||||
: hasExtensionUpdate(installedVersion, extension.versionCode);
|
||||
});
|
||||
}
|
||||
@@ -130,6 +130,7 @@ export const IPC_CHANNELS = {
|
||||
animeBrowserEnsureBridge: 'anime-browser:ensure-bridge',
|
||||
animeBrowserUpdateBridge: 'anime-browser:update-bridge',
|
||||
animeBrowserSelectSource: 'anime-browser:select-source',
|
||||
animeBrowserSetDefaultSource: 'anime-browser:set-default-source',
|
||||
animeBrowserSearch: 'anime-browser:search',
|
||||
animeBrowserGetPopular: 'anime-browser:get-popular',
|
||||
animeBrowserGetDetails: 'anime-browser:get-details',
|
||||
@@ -147,6 +148,7 @@ export const IPC_CHANNELS = {
|
||||
animeBrowserSetPreference: 'anime-browser:set-preference',
|
||||
animeBrowserListAvailableExtensions: 'anime-browser:list-available-extensions',
|
||||
animeBrowserInstallExtension: 'anime-browser:install-extension',
|
||||
animeBrowserUpdateAllExtensions: 'anime-browser:update-all-extensions',
|
||||
animeBrowserRemoveExtension: 'anime-browser:remove-extension',
|
||||
animeBrowserRescanExtensions: 'anime-browser:rescan-extensions',
|
||||
animeBrowserAddRepo: 'anime-browser:add-repo',
|
||||
|
||||
@@ -175,6 +175,8 @@ export interface AvailableExtension {
|
||||
name: string;
|
||||
lang: string;
|
||||
version: string;
|
||||
/** Monotonic Android version code used to decide whether an update exists. */
|
||||
versionCode: number;
|
||||
nsfw: boolean;
|
||||
repoUrl: string;
|
||||
/** Where the repository publishes the extension's icon; may 404. */
|
||||
@@ -206,6 +208,10 @@ export interface InstalledExtensionView {
|
||||
langs: string[];
|
||||
/** How many sources it provides; 0 when it failed to load. */
|
||||
sourceCount: number;
|
||||
/** Each source it provides, so the Extensions tab can pick a default. */
|
||||
sources: Array<{ id: string; name: string }>;
|
||||
/** Read from AndroidManifest.xml; null only for an invalid or unusual APK. */
|
||||
versionCode: number | null;
|
||||
/** Why it failed to load, or null when it loaded. */
|
||||
error: string | null;
|
||||
}
|
||||
@@ -214,6 +220,8 @@ export interface AnimeBrowserSnapshot {
|
||||
bridge: AnimeBrowserBridgeState;
|
||||
sources: AnimeBrowserSource[];
|
||||
selectedSourceId: string | null;
|
||||
/** Configured `anime.defaultSource`, verbatim; null when unset. */
|
||||
defaultSourceId: string | null;
|
||||
loadFailures: ExtensionLoadFailure[];
|
||||
/** Every extension on disk, whether or not it loaded. */
|
||||
installed: InstalledExtensionView[];
|
||||
@@ -283,6 +291,8 @@ export interface AnimeBrowserAPI {
|
||||
*/
|
||||
updateBridge: () => Promise<AnimeBrowserBridgeState>;
|
||||
selectSource: (sourceId: string) => Promise<void>;
|
||||
/** Persist the source (or `all`) new browser sessions open on. */
|
||||
setDefaultSource: (sourceId: string) => Promise<void>;
|
||||
search: (query: string, page?: number) => Promise<AnimeBrowserSearchResult>;
|
||||
getPopular: (page?: number) => Promise<AnimeBrowserSearchResult>;
|
||||
/** `sourceId` is required after an all-sources search; pass the entry's own. */
|
||||
@@ -315,6 +325,7 @@ export interface AnimeBrowserAPI {
|
||||
) => Promise<SourcePreferenceView[]>;
|
||||
listAvailableExtensions: () => Promise<AvailableExtensionsResult>;
|
||||
installExtension: (pkg: string) => Promise<void>;
|
||||
updateAllExtensions: () => Promise<number>;
|
||||
removeExtension: (pkg: string) => Promise<void>;
|
||||
rescanExtensions: () => Promise<void>;
|
||||
addRepo: (url: string) => Promise<void>;
|
||||
|
||||
@@ -321,9 +321,11 @@ export interface ResolvedConfig {
|
||||
subtitleSidebar: ResolvedSubtitleSidebarConfig;
|
||||
auto_start_overlay: boolean;
|
||||
anime: AnimeConfig & {
|
||||
autoOpenJimaku: boolean;
|
||||
extensionsDir: string;
|
||||
repos: string[];
|
||||
preferredQuality: string;
|
||||
defaultSource: string;
|
||||
bridgeDir: string;
|
||||
};
|
||||
jimaku: JimakuConfig & {
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface YoutubePickerResolveResult {
|
||||
}
|
||||
|
||||
export interface AnimeConfig {
|
||||
/**
|
||||
* Pause Anime Browser playback and open Jimaku when an episode loads. The
|
||||
* owned pause is released after a Jimaku subtitle loads or the modal closes.
|
||||
*/
|
||||
autoOpenJimaku?: boolean;
|
||||
/**
|
||||
* Directory holding Aniyomi extension `.apk` files. Defaults to
|
||||
* `<userData>/anime-extensions` when unset.
|
||||
@@ -58,6 +63,11 @@ export interface AnimeConfig {
|
||||
repos?: string[];
|
||||
/** Preferred stream label, matched as a substring, e.g. "1080". */
|
||||
preferredQuality?: string;
|
||||
/**
|
||||
* Source id (`<package>:<source>`) selected when the browser opens, or
|
||||
* "all" for every installed source. Empty selects the first installed one.
|
||||
*/
|
||||
defaultSource?: string;
|
||||
/**
|
||||
* Directory holding an M-Extension-Server bundle (java runtime plus server
|
||||
* jar) to run instead of the copy SubMiner downloads. Empty checks the
|
||||
|
||||
Reference in New Issue
Block a user