diff --git a/README.md b/README.md
index 7970f43..8472235 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-[](./assets/minecard.mp4)
+[](./assets/minecard.mp4)
@@ -69,7 +69,7 @@ mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/c
### 3. Set up Yomitan Dictionaries
```bash
-subminer app --start --yomitan
+subminer app --yomitan
```
### 4. Mine
diff --git a/docs/anki-integration.md b/docs/anki-integration.md
index bcc7302..a0522c3 100644
--- a/docs/anki-integration.md
+++ b/docs/anki-integration.md
@@ -1,6 +1,7 @@
# Anki Integration
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
+This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
## Prerequisites
diff --git a/docs/configuration.md b/docs/configuration.md
index d15ba1c..863b5f2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -97,6 +97,7 @@ The configuration file includes several main sections:
**Anki Integration**
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
+- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
@@ -662,13 +663,28 @@ This example is intentionally compact. The option table below documents availabl
| `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`) |
-**Kiku / Lapis Note Type Support:**
+### Kiku/Lapis Integration
-SubMiner supports the [Lapis](https://github.com/donkuri/lapis) and [Kiku](https://kiku.youyoumu.my.id/) note types. Both `isLapis.enabled` and `isKiku.enabled` can be true; Kiku takes precedence for grouping behavior, while sentence-card model/field settings come from `isLapis`.
+SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) workflows, with note-type-specific behavior built into Anki settings.
-When enabled, sentence cards automatically set `IsSentenceCard` to `"x"` and populate the `Expression` field. Audio cards set `IsAudioCard` to `"x"`.
+```jsonc
+"ankiConnect": {
+ "isLapis": {
+ "enabled": true,
+ "sentenceCardModel": "Japanese sentences"
+ },
+ "isKiku": {
+ "enabled": true,
+ "fieldGrouping": "manual",
+ "deleteDuplicateInAuto": true
+ }
+}
+```
-Kiku extends Lapis with **field grouping** — when a duplicate card is detected (same Word/Expression), SubMiner merges the two cards' content into one using Kiku's `data-group-id` HTML structure, organizing each mining instance into separate pages within the note.
+- Enable `isLapis` to mine dedicated sentence cards. SubMiner sets `IsSentenceCard` to `"x"` and fills the sentence fields for the configured model.
+- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
+- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
+- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
### N+1 Word Highlighting
diff --git a/docs/usage.md b/docs/usage.md
index 44905b5..f971976 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -1,11 +1,15 @@
# Usage
+> [!IMPORTANT]
+> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
+> See [Yomitan setup](#yomitan-setup) for details.
+
There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin:
-| Approach | Best For |
-| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Approach | Best For |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. With default plugin settings, overlay auto-starts visible and playback resumes after annotation readiness. |
-| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. |
+| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. |
You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow.
@@ -147,6 +151,14 @@ secondary-sub-visibility=no
`secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead.
+### Yomitan setup
+
+SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
+
+For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance.
+
+If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
+
### YouTube Playback
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.
diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts
index 1d2e76b..2d762b1 100644
--- a/src/cli/args.test.ts
+++ b/src/cli/args.test.ts
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { hasExplicitCommand, parseArgs, shouldStartApp } from './args';
+import { hasExplicitCommand, parseArgs, shouldRunSettingsOnlyStartup, shouldStartApp } from './args';
test('parseArgs parses booleans and value flags', () => {
const args = parseArgs([
@@ -60,6 +60,28 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(refreshKnownWords), true);
assert.equal(shouldStartApp(refreshKnownWords), false);
+ const settings = parseArgs(['--settings']);
+ assert.equal(settings.settings, true);
+ assert.equal(hasExplicitCommand(settings), true);
+ assert.equal(shouldStartApp(settings), true);
+ assert.equal(shouldRunSettingsOnlyStartup(settings), true);
+
+ const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
+ assert.equal(settingsWithOverlay.settings, true);
+ assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
+ assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false);
+
+ const yomitanAlias = parseArgs(['--yomitan']);
+ assert.equal(yomitanAlias.settings, true);
+ assert.equal(hasExplicitCommand(yomitanAlias), true);
+ assert.equal(shouldStartApp(yomitanAlias), true);
+
+ const help = parseArgs(['--help']);
+ assert.equal(help.help, true);
+ assert.equal(hasExplicitCommand(help), true);
+ assert.equal(shouldStartApp(help), false);
+ assert.equal(shouldRunSettingsOnlyStartup(help), false);
+
const anilistStatus = parseArgs(['--anilist-status']);
assert.equal(anilistStatus.anilistStatus, true);
assert.equal(hasExplicitCommand(anilistStatus), true);
diff --git a/src/cli/args.ts b/src/cli/args.ts
index 39db2d6..752deb2 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -295,6 +295,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.start ||
args.toggle ||
args.toggleVisibleOverlay ||
+ args.settings ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
@@ -314,6 +315,50 @@ export function shouldStartApp(args: CliArgs): boolean {
return false;
}
+export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
+ return (
+ args.settings &&
+ !args.background &&
+ !args.start &&
+ !args.stop &&
+ !args.toggle &&
+ !args.toggleVisibleOverlay &&
+ !args.show &&
+ !args.hide &&
+ !args.showVisibleOverlay &&
+ !args.hideVisibleOverlay &&
+ !args.copySubtitle &&
+ !args.copySubtitleMultiple &&
+ !args.mineSentence &&
+ !args.mineSentenceMultiple &&
+ !args.updateLastCardFromClipboard &&
+ !args.refreshKnownWords &&
+ !args.toggleSecondarySub &&
+ !args.triggerFieldGrouping &&
+ !args.triggerSubsync &&
+ !args.markAudioCard &&
+ !args.openRuntimeOptions &&
+ !args.anilistStatus &&
+ !args.anilistLogout &&
+ !args.anilistSetup &&
+ !args.anilistRetryQueue &&
+ !args.jellyfin &&
+ !args.jellyfinLogin &&
+ !args.jellyfinLogout &&
+ !args.jellyfinLibraries &&
+ !args.jellyfinItems &&
+ !args.jellyfinSubtitles &&
+ !args.jellyfinPlay &&
+ !args.jellyfinRemoteAnnounce &&
+ !args.texthooker &&
+ !args.help &&
+ !args.autoStartOverlay &&
+ !args.generateConfig &&
+ !args.backupOverwrite &&
+ !args.debug
+ );
+}
+
export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
return (
args.toggle ||
diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts
index 4a0a734..2bbd48b 100644
--- a/src/core/services/app-ready.test.ts
+++ b/src/core/services/app-ready.test.ts
@@ -66,6 +66,55 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
);
});
+test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns true', async () => {
+ const { deps, calls } = makeDeps({
+ shouldSkipHeavyStartup: () => true,
+ reloadConfig: () => calls.push('reloadConfig'),
+ getResolvedConfig: () => {
+ calls.push('getResolvedConfig');
+ return {
+ websocket: { enabled: 'auto' },
+ secondarySub: {},
+ };
+ },
+ getConfigWarnings: () => {
+ calls.push('getConfigWarnings');
+ return [];
+ },
+ setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`),
+ initRuntimeOptionsManager: () => calls.push('initRuntimeOptionsManager'),
+ startBackgroundWarmups: () => calls.push('startBackgroundWarmups'),
+ loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
+ resolveKeybindings: () => calls.push('resolveKeybindings'),
+ createMpvClient: () => calls.push('createMpvClient'),
+ logConfigWarning: () => calls.push('logConfigWarning'),
+ startJellyfinRemoteSession: async () => {
+ calls.push('startJellyfinRemoteSession');
+ },
+ createImmersionTracker: () => calls.push('createImmersionTracker'),
+ handleInitialArgs: () => calls.push('handleInitialArgs'),
+ });
+
+ await runAppReadyRuntime(deps);
+
+ assert.equal(calls.includes('reloadConfig'), false);
+ assert.equal(calls.includes('getResolvedConfig'), false);
+ assert.equal(calls.includes('getConfigWarnings'), false);
+ assert.equal(calls.includes('setLogLevel:warn:config'), false);
+ assert.equal(calls.includes('startBackgroundWarmups'), false);
+ assert.equal(calls.includes('loadSubtitlePosition'), false);
+ assert.equal(calls.includes('resolveKeybindings'), false);
+ assert.equal(calls.includes('createMpvClient'), false);
+ assert.equal(calls.includes('initRuntimeOptionsManager'), false);
+ assert.equal(calls.includes('createImmersionTracker'), false);
+ assert.equal(calls.includes('startJellyfinRemoteSession'), false);
+ assert.equal(calls.includes('logConfigWarning'), false);
+ assert.equal(calls.includes('handleInitialArgs'), true);
+ assert.equal(calls.includes('loadYomitanExtension'), true);
+ assert.equal(calls[0], 'loadYomitanExtension');
+ assert.equal(calls[calls.length - 1], 'handleInitialArgs');
+});
+
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
const { deps, calls } = makeDeps({
startJellyfinRemoteSession: undefined,
diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts
index c7f7122..21df7af 100644
--- a/src/core/services/startup.ts
+++ b/src/core/services/startup.ts
@@ -121,6 +121,7 @@ export interface AppReadyRuntimeDeps {
logDebug?: (message: string) => void;
onCriticalConfigErrors?: (errors: string[]) => void;
now?: () => number;
+ shouldSkipHeavyStartup?: () => boolean;
}
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
@@ -169,6 +170,13 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
+ shouldSkipHeavyStartup: () =>
+ Boolean(appState.initialArgs && shouldRunSettingsOnlyStartup(appState.initialArgs)),
createImmersionTracker: () => {
ensureImmersionTrackerStarted();
},
diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts
index 4829220..1742f48 100644
--- a/src/main/app-lifecycle.ts
+++ b/src/main/app-lifecycle.ts
@@ -48,6 +48,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
logDebug?: AppReadyRuntimeDeps['logDebug'];
now?: AppReadyRuntimeDeps['now'];
+ shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
}
export function createAppLifecycleRuntimeDeps(
@@ -103,6 +104,7 @@ export function createAppReadyRuntimeDeps(
onCriticalConfigErrors: params.onCriticalConfigErrors,
logDebug: params.logDebug,
now: params.now,
+ shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
};
}
diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts
index aff812b..d0eec25 100644
--- a/src/main/runtime/app-ready-main-deps.ts
+++ b/src/main/runtime/app-ready-main-deps.ts
@@ -31,5 +31,6 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
onCriticalConfigErrors: deps.onCriticalConfigErrors,
logDebug: deps.logDebug,
now: deps.now,
+ shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
});
}