feat(anime): remember the default Anime Browser source

- Add `anime.defaultSource` with installed-source fallback
- Let the source picker save a source or All sources as default
This commit is contained in:
2026-09-02 23:17:20 -07:00
parent 516fadd530
commit 68dd789fbf
22 changed files with 260 additions and 19 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ area: anime
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`. - 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 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. 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. - 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`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery. - Added `anime.repos`, `anime.extensionsDir`, `anime.preferredQuality`, and `anime.defaultSource` config keys; a star beside the Source picker saves the current source (or All sources) as the one the browser opens on. 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, closes the in-player browser, 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. - 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, closes the in-player browser, 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. - Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English``en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead. - The 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.
+1
View File
@@ -633,6 +633,7 @@
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions. "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. "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. "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. The star beside the Source picker 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. "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; }, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+16 -7
View File
@@ -116,6 +116,14 @@ missing.
An extension that fails to load is skipped rather than blocking the others, so An extension that fails to load is skipped rather than blocking the others, so
one bad APK will not hide the rest. one bad APK will not hide the rest.
### Default source
The browser opens on the first installed source. To open on a different one,
pick it in the **Source** picker and press the star beside it; the choice is
written to `anime.defaultSource` and applies the next time a browser window or
the in-player modal opens. **All sources** can be the default too. A default
that is no longer installed falls back to the first source.
## Searching every source at once ## Searching every source at once
With more than one source installed, the **Source** picker gains an With more than one source installed, the **Source** picker gains an
@@ -200,13 +208,14 @@ there is nowhere to write them and the status bar says so.
## Settings ## Settings
| Key | Purpose | | Key | Purpose |
| ------------------------ | ----------------------------------------------------------------------- | | ------------------------ | ------------------------------------------------------------------------ |
| `anime.autoOpenJimaku` | Pause new episodes and open Jimaku for Japanese subtitles. | | `anime.autoOpenJimaku` | Pause new episodes and open Jimaku for Japanese subtitles. |
| `anime.repos` | Repository index URLs. Empty by default. | | `anime.repos` | Repository index URLs. Empty by default. |
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. | | `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.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. | | `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 Enable `anime.autoOpenJimaku` to hand each newly loaded Anime Browser episode
to Jimaku. SubMiner pauses playback, closes the in-player browser if it is open, to Jimaku. SubMiner pauses playback, closes the in-player browser if it is open,
+4 -2
View File
@@ -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 | | `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`. | | `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`) | | `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. `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. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
@@ -1168,7 +1168,8 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
"autoOpenJimaku": false, "autoOpenJimaku": false,
"extensionsDir": "", "extensionsDir": "",
"repos": [], "repos": [],
"preferredQuality": "" "preferredQuality": "",
"defaultSource": ""
} }
} }
``` ```
@@ -1179,6 +1180,7 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. | | `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.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.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. The star beside the Source picker 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). | | `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. 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.
+1
View File
@@ -633,6 +633,7 @@
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions. "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. "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. "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. The star beside the Source picker 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. "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; }, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+44 -4
View File
@@ -50,6 +50,7 @@ const searchForm = el<HTMLFormElement>('search-form');
const searchInput = el<HTMLInputElement>('search-input'); const searchInput = el<HTMLInputElement>('search-input');
const searchButton = el<HTMLButtonElement>('search-button'); const searchButton = el<HTMLButtonElement>('search-button');
const sourceSelect = el<HTMLSelectElement>('source-select'); const sourceSelect = el<HTMLSelectElement>('source-select');
const sourceDefaultButton = el<HTMLButtonElement>('source-default');
const grid = el<HTMLDivElement>('grid'); const grid = el<HTMLDivElement>('grid');
const gridEmpty = el<HTMLParagraphElement>('grid-empty'); const gridEmpty = el<HTMLParagraphElement>('grid-empty');
const loadMoreButton = el<HTMLButtonElement>('load-more'); const loadMoreButton = el<HTMLButtonElement>('load-more');
@@ -70,6 +71,8 @@ const settingsTitle = el<HTMLHeadingElement>('settings-title');
/** Last source accepted by the main process, used to roll back a rejected change. */ /** Last source accepted by the main process, used to roll back a rejected change. */
let selectedSourceId: string | null = null; let selectedSourceId: string | null = null;
/** Configured `anime.defaultSource`, so the star reflects the picker's current value. */
let defaultSourceId: string | null = null;
/* ---------- tabs ---------- */ /* ---------- tabs ---------- */
@@ -168,7 +171,11 @@ function renderBridgeState(state: AnimeBrowserBridgeState): void {
* searches them together. That entry only earns its place with more than one * searches them together. That entry only earns its place with more than one
* source installed. * source installed.
*/ */
function renderSources(sources: AnimeBrowserSource[], selectedId: string | null): void { function renderSources(
sources: AnimeBrowserSource[],
selectedId: string | null,
defaultId: string | null,
): void {
const options: HTMLOptionElement[] = []; const options: HTMLOptionElement[] = [];
if (sources.length > 1) { if (sources.length > 1) {
@@ -190,6 +197,23 @@ function renderSources(sources: AnimeBrowserSource[], selectedId: string | null)
sourceSelect.replaceChildren(...options); sourceSelect.replaceChildren(...options);
sourceSelect.disabled = options.length <= 1; sourceSelect.disabled = options.length <= 1;
selectedSourceId = selectedId; selectedSourceId = selectedId;
defaultSourceId = defaultId;
renderDefaultSourceButton();
}
/**
* The star is lit while the picker shows the configured default. With one
* source or none there is nothing to choose between, so it stays hidden.
*/
function renderDefaultSourceButton(): void {
const isDefault = sourceSelect.value !== '' && sourceSelect.value === defaultSourceId;
sourceDefaultButton.hidden = sourceSelect.options.length <= 1;
sourceDefaultButton.disabled = isDefault;
sourceDefaultButton.setAttribute('aria-pressed', String(isDefault));
sourceDefaultButton.textContent = isDefault ? '\u2605' : '\u2606';
sourceDefaultButton.title = isDefault
? 'The browser opens on this source'
: 'Open the browser on this source';
} }
function searchingAllSources(): boolean { function searchingAllSources(): boolean {
@@ -402,7 +426,7 @@ async function openSettings(): Promise<void> {
async function refreshSources(): Promise<void> { async function refreshSources(): Promise<void> {
const snapshot = await api.getSnapshot(); const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId); renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
} }
const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources }); const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
@@ -429,6 +453,7 @@ sourceSelect.addEventListener('change', () => {
try { try {
await api.selectSource(requestedSourceId); await api.selectSource(requestedSourceId);
selectedSourceId = requestedSourceId; selectedSourceId = requestedSourceId;
renderDefaultSourceButton();
// Settings belong to the source, so reload them rather than showing stale fields. // Settings belong to the source, so reload them rather than showing stale fields.
if (currentView === 'settings') await openSettings(); if (currentView === 'settings') await openSettings();
await runSearch(searchInput.value.trim()); await runSearch(searchInput.value.trim());
@@ -439,6 +464,21 @@ sourceSelect.addEventListener('change', () => {
})(); })();
}); });
sourceDefaultButton.addEventListener('click', () => {
void (async () => {
const sourceId = sourceSelect.value;
try {
await api.setDefaultSource(sourceId);
defaultSourceId = sourceId;
renderDefaultSourceButton();
const label = sourceSelect.selectedOptions[0]?.textContent ?? sourceId;
setStatus(`${label} is now the default source.`, 'ok');
} catch (error) {
setStatus(describe(error), 'error');
}
})();
});
loadMoreButton.addEventListener('click', () => void loadNextPage()); loadMoreButton.addEventListener('click', () => void loadNextPage());
bannerUpdate.addEventListener('click', () => { bannerUpdate.addEventListener('click', () => {
@@ -457,7 +497,7 @@ bannerUpdate.addEventListener('click', () => {
} }
// The bridge restarted, so the source list is fresh from disk. // The bridge restarted, so the source list is fresh from disk.
const snapshot = await api.getSnapshot(); const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId); renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
if (currentView === 'extensions') await extensions.refresh(); if (currentView === 'extensions') await extensions.refresh();
} catch (error) { } catch (error) {
setStatus(describe(error), 'error'); setStatus(describe(error), 'error');
@@ -500,7 +540,7 @@ void (async () => {
renderBridgeState(state); renderBridgeState(state);
const snapshot = await api.getSnapshot(); const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId); renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
if (state.stage === 'ready' && snapshot.sources.length > 0) { if (state.stage === 'ready' && snapshot.sources.length > 0) {
searchInput.focus(); searchInput.focus();
+12 -3
View File
@@ -33,10 +33,19 @@
<button class="primary-button" id="search-button" type="submit">Search</button> <button class="primary-button" id="search-button" type="submit">Search</button>
</form> </form>
<label class="source-picker"> <div class="source-picker">
<span class="source-label">Source</span> <label class="source-label" for="source-select">Source</label>
<select class="text-input" id="source-select" aria-label="Extension source"></select> <select class="text-input" id="source-select" aria-label="Extension source"></select>
</label> <button
class="ghost-button default-source-button"
id="source-default"
type="button"
title="Open the browser on this source"
aria-label="Set as default source"
>
&#9734;
</button>
</div>
<nav class="tabs" role="tablist" aria-label="View"> <nav class="tabs" role="tablist" aria-label="View">
<button <button
+11
View File
@@ -276,6 +276,17 @@ body {
width: auto; width: auto;
} }
.default-source-button {
padding: 6px 9px;
font-size: 15px;
line-height: 1;
}
.default-source-button[aria-pressed='true'] {
color: var(--accent);
border-color: var(--accent);
}
/* ---------- bridge banner ---------- */ /* ---------- bridge banner ---------- */
.bridge-banner { .bridge-banner {
@@ -108,6 +108,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
extensionsDir: '', extensionsDir: '',
repos: [], repos: [],
preferredQuality: '', preferredQuality: '',
defaultSource: '',
bridgeDir: '', bridgeDir: '',
}, },
jimaku: { jimaku: {
@@ -616,6 +616,13 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.', '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. The star beside the Source picker writes this value.',
},
{ {
path: 'anime.bridgeDir', path: 'anime.bridgeDir',
kind: 'string', kind: 'string',
+12
View File
@@ -86,3 +86,15 @@ test('resolveConfig warns for an invalid Anime Browser Jimaku handoff option', (
}, },
]); ]);
}); });
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.' },
]);
});
+12
View File
@@ -388,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)) { if (Array.isArray(src.anime.repos)) {
const repos: string[] = []; const repos: string[] = [];
for (const entry of src.anime.repos) { for (const entry of src.anime.repos) {
+3
View File
@@ -3426,6 +3426,9 @@ const animeBrowserApplicationRuntime = createAnimeBrowserApplicationRuntime({
repos: () => configService.getConfig().anime?.repos ?? [], repos: () => configService.getConfig().anime?.repos ?? [],
setRepos: (repos) => configService.patchRawConfig({ anime: { repos } }), setRepos: (repos) => configService.patchRawConfig({ anime: { repos } }),
preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined, 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'), preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
ensureBinaries: (onProgress) => ensureBinaries: (onProgress) =>
ensureBridgeBinaries({ ensureBridgeBinaries({
@@ -52,6 +52,9 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) => handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) =>
runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)), runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)),
); );
handle(channels.animeBrowserSetDefaultSource, (_event, sourceId) =>
runtime.setDefaultSource(String(sourceId)),
);
handle(channels.animeBrowserSearch, (event, sessionId, query, page) => handle(channels.animeBrowserSearch, (event, sessionId, query, page) =>
runtime.search(String(query ?? ''), toPage(page), registerSession(deps, event, sessionId)), runtime.search(String(query ?? ''), toPage(page), registerSession(deps, event, sessionId)),
); );
@@ -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. */ /** Streams per-source progress while a search invoke is pending. */
onSearchUpdate?: (update: AnimeBrowserSearchUpdate, sessionId: string) => void; onSearchUpdate?: (update: AnimeBrowserSearchUpdate, sessionId: string) => void;
preferredQuality?: () => string | undefined; 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; log: (message: string) => void;
/** Overrides process startup in focused runtime tests. */ /** Overrides process startup in focused runtime tests. */
startSidecar?: typeof startSidecar; startSidecar?: typeof startSidecar;
+31 -2
View File
@@ -92,10 +92,24 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return result; 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') { function getBrowserSession(sessionId = 'default') {
const existing = browserSessions.get(sessionId); const existing = browserSessions.get(sessionId);
if (existing) return existing; if (existing) return existing;
const created = { selectedSourceId: sources[0]?.id ?? null, searchToken: 0 }; const created = { selectedSourceId: initialSourceId(), searchToken: 0 };
browserSessions.set(sessionId, created); browserSessions.set(sessionId, created);
return created; return created;
} }
@@ -245,7 +259,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
for (const session of browserSessions.values()) { for (const session of browserSessions.values()) {
const keptAll = session.selectedSourceId === ALL_SOURCES_ID && sources.length > 0; const keptAll = session.selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
if (!keptAll && !sources.some((source) => source.id === session.selectedSourceId)) { if (!keptAll && !sources.some((source) => source.id === session.selectedSourceId)) {
session.selectedSourceId = sources[0]?.id ?? null; session.selectedSourceId = initialSourceId();
} }
} }
@@ -526,6 +540,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
pkg: source.pkg, pkg: source.pkg,
})), })),
selectedSourceId: session.selectedSourceId, selectedSourceId: session.selectedSourceId,
defaultSourceId: deps.defaultSourceId?.()?.trim() || null,
loadFailures, loadFailures,
installed: toInstalledExtensionViews(extensions, sources, loadFailures), installed: toInstalledExtensionViews(extensions, sources, loadFailures),
extensionsDir: deps.extensionsDir(), extensionsDir: deps.extensionsDir(),
@@ -656,6 +671,20 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
if (sources.some((source) => source.id === sourceId)) session.selectedSourceId = sourceId; 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 * The extension's settings schema, merged with anything saved locally. The
* extension is the source of truth for structure; saved values only supply * extension is the source of truth for structure; saved values only supply
+2
View File
@@ -44,6 +44,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
ipcRenderer.invoke(request.animeBrowserUpdateBridge), ipcRenderer.invoke(request.animeBrowserUpdateBridge),
selectSource: (sourceId: string): Promise<void> => selectSource: (sourceId: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserSelectSource, sessionId, sourceId), ipcRenderer.invoke(request.animeBrowserSelectSource, sessionId, sourceId),
setDefaultSource: (sourceId: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserSetDefaultSource, sourceId),
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> => search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
ipcRenderer.invoke(request.animeBrowserSearch, sessionId, query, page), ipcRenderer.invoke(request.animeBrowserSearch, sessionId, query, page),
getPopular: (page?: number): Promise<AnimeBrowserSearchResult> => getPopular: (page?: number): Promise<AnimeBrowserSearchResult> =>
+1
View File
@@ -130,6 +130,7 @@ export const IPC_CHANNELS = {
animeBrowserEnsureBridge: 'anime-browser:ensure-bridge', animeBrowserEnsureBridge: 'anime-browser:ensure-bridge',
animeBrowserUpdateBridge: 'anime-browser:update-bridge', animeBrowserUpdateBridge: 'anime-browser:update-bridge',
animeBrowserSelectSource: 'anime-browser:select-source', animeBrowserSelectSource: 'anime-browser:select-source',
animeBrowserSetDefaultSource: 'anime-browser:set-default-source',
animeBrowserSearch: 'anime-browser:search', animeBrowserSearch: 'anime-browser:search',
animeBrowserGetPopular: 'anime-browser:get-popular', animeBrowserGetPopular: 'anime-browser:get-popular',
animeBrowserGetDetails: 'anime-browser:get-details', animeBrowserGetDetails: 'anime-browser:get-details',
+4
View File
@@ -218,6 +218,8 @@ export interface AnimeBrowserSnapshot {
bridge: AnimeBrowserBridgeState; bridge: AnimeBrowserBridgeState;
sources: AnimeBrowserSource[]; sources: AnimeBrowserSource[];
selectedSourceId: string | null; selectedSourceId: string | null;
/** Configured `anime.defaultSource`, verbatim; null when unset. */
defaultSourceId: string | null;
loadFailures: ExtensionLoadFailure[]; loadFailures: ExtensionLoadFailure[];
/** Every extension on disk, whether or not it loaded. */ /** Every extension on disk, whether or not it loaded. */
installed: InstalledExtensionView[]; installed: InstalledExtensionView[];
@@ -287,6 +289,8 @@ export interface AnimeBrowserAPI {
*/ */
updateBridge: () => Promise<AnimeBrowserBridgeState>; updateBridge: () => Promise<AnimeBrowserBridgeState>;
selectSource: (sourceId: string) => Promise<void>; 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>; search: (query: string, page?: number) => Promise<AnimeBrowserSearchResult>;
getPopular: (page?: number) => Promise<AnimeBrowserSearchResult>; getPopular: (page?: number) => Promise<AnimeBrowserSearchResult>;
/** `sourceId` is required after an all-sources search; pass the entry's own. */ /** `sourceId` is required after an all-sources search; pass the entry's own. */
+1
View File
@@ -325,6 +325,7 @@ export interface ResolvedConfig {
extensionsDir: string; extensionsDir: string;
repos: string[]; repos: string[];
preferredQuality: string; preferredQuality: string;
defaultSource: string;
bridgeDir: string; bridgeDir: string;
}; };
jimaku: JimakuConfig & { jimaku: JimakuConfig & {
+5
View File
@@ -63,6 +63,11 @@ export interface AnimeConfig {
repos?: string[]; repos?: string[];
/** Preferred stream label, matched as a substring, e.g. "1080". */ /** Preferred stream label, matched as a substring, e.g. "1080". */
preferredQuality?: string; 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 * Directory holding an M-Extension-Server bundle (java runtime plus server
* jar) to run instead of the copy SubMiner downloads. Empty checks the * jar) to run instead of the copy SubMiner downloads. Empty checks the