Disable character-dictionary features in external profile mode

- Gate character-dictionary runtime, auto-sync, annotations, and CLI generation when `yomitan.externalProfilePath` is set
- Return explicit disabled reason for blocked character-dictionary generation in read-only external-profile mode
- Fix default config bootstrap to seed `config.jsonc` when config dir exists but config file is missing
- Update tests, changelog fragment, and docs to reflect the new behavior
This commit is contained in:
2026-03-11 21:02:00 -07:00
parent 6569eaa0ac
commit c9d5f6b6e3
6 changed files with 39 additions and 16 deletions

View File

@@ -1,5 +1,6 @@
type: added
area: yomitan
area: config
- Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode.
- SubMiner now reuses external Yomitan dictionaries/settings without writing back to that profile.
- Fixed default config bootstrap so `config.jsonc` is seeded even when the config directory already exists.

View File

@@ -63,7 +63,7 @@ The first sync for a media title takes a few seconds while character data and po
:::
::: warning
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but character-dictionary auto-sync does not import or update the merged dictionary.
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
:::
## Name Generation

View File

@@ -959,7 +959,7 @@ External-profile mode behavior:
- SubMiner reads the external profile's currently active Yomitan profile selection and installed dictionaries.
- SubMiner does not open its own Yomitan settings window in this mode.
- SubMiner does not import, delete, or update dictionaries/settings in the external profile.
- SubMiner character-dictionary auto-sync is effectively disabled in this mode because it requires Yomitan writes.
- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations.
### Jellyfin

View File

@@ -374,6 +374,10 @@ import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import {
getCharacterDictionaryDisabledReason,
isCharacterDictionaryRuntimeEnabled,
} from './main/runtime/character-dictionary-availability';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
@@ -1328,7 +1332,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
getConfig: () => {
const config = getResolvedConfig().anilist.characterDictionary;
return {
enabled: config.enabled,
enabled:
config.enabled &&
isCharacterDictionaryRuntimeEnabled(getConfiguredExternalYomitanProfilePath()),
maxLoaded: config.maxLoaded,
profileScope: config.profileScope,
};
@@ -2756,6 +2762,9 @@ const {
);
},
scheduleCharacterDictionarySync: () => {
if (!isCharacterDictionaryEnabledForCurrentProcess()) {
return;
}
characterDictionaryAutoSyncRuntime.scheduleSync();
},
updateCurrentMediaTitle: (title) => {
@@ -2833,7 +2842,9 @@ const {
'subtitle.annotation.jlpt',
getResolvedConfig().subtitleStyle.enableJlpt,
),
getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled,
getCharacterDictionaryEnabled: () =>
getResolvedConfig().anilist.characterDictionary.enabled &&
isCharacterDictionaryEnabledForCurrentProcess(),
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getFrequencyDictionaryEnabled: () =>
getRuntimeBooleanOption(
@@ -3035,6 +3046,14 @@ function isYomitanExternalReadOnlyMode(): boolean {
return getConfiguredExternalYomitanProfilePath().length > 0;
}
function isCharacterDictionaryEnabledForCurrentProcess(): boolean {
return isCharacterDictionaryRuntimeEnabled(getConfiguredExternalYomitanProfilePath());
}
function getCharacterDictionaryDisabledReasonForCurrentProcess(): string | null {
return getCharacterDictionaryDisabledReason(getConfiguredExternalYomitanProfilePath());
}
function logSkippedYomitanWrite(action: string): void {
logger.info(
`[yomitan] skipping ${action}: yomitan.externalProfilePath is configured; external profile mode is read-only`,
@@ -3537,8 +3556,13 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
generateCharacterDictionary: async (targetPath?: string) => {
const disabledReason = getCharacterDictionaryDisabledReasonForCurrentProcess();
if (disabledReason) {
throw new Error(disabledReason);
}
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
},
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),

View File

@@ -65,7 +65,7 @@ test('ensureDefaultConfigBootstrap creates config dir and default jsonc only whe
});
});
test('ensureDefaultConfigBootstrap does not seed default config into an existing config directory', () => {
test('ensureDefaultConfigBootstrap seeds default config into an existing config directory when missing', () => {
withTempDir((root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
@@ -74,10 +74,13 @@ test('ensureDefaultConfigBootstrap does not seed default config into an existing
ensureDefaultConfigBootstrap({
configDir,
configFilePaths: getDefaultConfigFilePaths(configDir),
generateTemplate: () => 'should-not-write',
generateTemplate: () => '{\n "logging": {}\n}\n',
});
assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false);
assert.equal(
fs.readFileSync(path.join(configDir, 'config.jsonc'), 'utf8'),
'{\n "logging": {}\n}\n',
);
assert.equal(fs.readFileSync(path.join(configDir, 'existing-user-file.txt'), 'utf8'), 'keep\n');
});
});

View File

@@ -208,13 +208,8 @@ export function ensureDefaultConfigBootstrap(options: {
const existsSync = options.existsSync ?? fs.existsSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
const configDirExists = existsSync(options.configDir);
if (
existsSync(options.configFilePaths.jsoncPath) ||
existsSync(options.configFilePaths.jsonPath) ||
configDirExists
) {
if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) {
return;
}