mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
Compare commits
2
Commits
a8c16147ad
...
026d495fac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
026d495fac | ||
|
|
dd76782d30
|
No files matched your search
@@ -4,5 +4,5 @@ area: subtitles
|
||||
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
|
||||
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front.
|
||||
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue.
|
||||
- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections.
|
||||
- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections. Use a wider generation modal to reduce text wrapping and scrolling, while keeping it within smaller player windows.
|
||||
- Automatically use an eligible embedded or external subtitle track loaded in mpv to guide long-passage cuts, preferring English dialogue and excluding tracks marked signs, songs, forced, or generated. Honor active subtitle delays, retain unreferenced audio, and fall back to audio timing when references cannot be read. Available in the overlay and launcher, with or without VAD. The launcher captures audio selection, reference tracks, and delays before model setup so playback changes during a download do not mix inputs from different times, and rejects an unverified media snapshot before using mpv's selected audio stream.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: jimaku
|
||||
|
||||
- Jimaku modal gains Anime / Live action tabs so searches can pull from Jimaku's live action catalogue instead of only anime entries (Arrow Left / Right switch tabs).
|
||||
@@ -1,6 +1,6 @@
|
||||
# Jimaku integration
|
||||
|
||||
[Jimaku](https://jimaku.cc) is a community subtitle repository for anime, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away.
|
||||
[Jimaku](https://jimaku.cc) is a community subtitle repository for anime and Japanese live action, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away.
|
||||
|
||||
::: tip Prerequisite: a free API key
|
||||
You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set."
|
||||
@@ -14,21 +14,23 @@ When you open the modal, SubMiner parses the current video filename to extract a
|
||||
|
||||
From there:
|
||||
|
||||
1. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of anime entries (Japanese and English names).
|
||||
2. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected.
|
||||
3. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first).
|
||||
4. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track.
|
||||
1. **Pick a catalogue** - The **Anime** and **Live action** tabs at the top of the modal choose which Jimaku catalogue to search. Switching tabs re-runs the current search. The choice persists until SubMiner restarts.
|
||||
2. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of entries (Japanese and English names).
|
||||
3. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected.
|
||||
4. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first).
|
||||
5. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track.
|
||||
|
||||
If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry.
|
||||
|
||||
### Modal keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `Enter` (in text field) | Search |
|
||||
| `Enter` (in list) | Select entry / download file |
|
||||
| `Arrow Up` / `Arrow Down` | Navigate entries or files |
|
||||
| `Escape` | Close modal |
|
||||
| Key | Action |
|
||||
| ---------------------------- | --------------------------------------------- |
|
||||
| `Enter` (in text field) | Search |
|
||||
| `Enter` (in list) | Select entry / download file |
|
||||
| `Arrow Up` / `Arrow Down` | Navigate entries or files |
|
||||
| `Arrow Left` / `Arrow Right` | Switch between the Anime and Live action tabs |
|
||||
| `Escape` | Close modal |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -41,26 +43,26 @@ Add a `jimaku` section to your `config.jsonc`:
|
||||
"apiKeyCommand": "cat ~/.jimaku_key",
|
||||
"apiBaseUrl": "https://jimaku.cc",
|
||||
"languagePreference": "ja",
|
||||
"maxEntryResults": 10
|
||||
}
|
||||
"maxEntryResults": 10,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
|
||||
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
|
||||
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
|
||||
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
|
||||
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
|
||||
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
|
||||
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
|
||||
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
|
||||
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of entries returned per search. |
|
||||
|
||||
The keyboard shortcut is configured separately under `shortcuts`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"shortcuts": {
|
||||
"openJimaku": "Ctrl+Shift+J"
|
||||
}
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ A configured external Model Path takes precedence and hides the managed model pi
|
||||
4. Optionally check **Focus on spoken dialogue** and click **Download speech detection model** if prompted.
|
||||
5. Click **Generate subtitles**.
|
||||
|
||||
The modal shows audio preparation, transcription, and saving progress. Percentages appear when the underlying tool reports them. **Cancel** stops the current operation. Closing the modal lets the job continue; reopening it shows the current progress or result.
|
||||
The modal adapts to the player window, using a wider layout when space allows and scrolling in smaller windows. It shows audio preparation, transcription, and saving progress. Percentages appear when the underlying tool reports them. **Cancel** stops the current operation. Closing the modal lets the job continue; reopening it shows the current progress or result.
|
||||
|
||||
**Escape** or **Close** closes the modal using the same focus and overlay restoration as other SubMiner modals. Change or disable its shortcut with `shortcuts.openSubtitleGeneration` in Settings. Ctrl+G remains assigned to field grouping.
|
||||
|
||||
|
||||
@@ -214,9 +214,10 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
},
|
||||
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()),
|
||||
searchJimakuEntries: async (query) => {
|
||||
logger.info(`[jimaku] search-entries query: "${query.query}"`);
|
||||
const category = query.category ?? 'anime';
|
||||
logger.info(`[jimaku] search-entries query: "${query.query}" category=${category}`);
|
||||
const response = await options.jimakuFetchJson<JimakuEntry[]>('/api/entries/search', {
|
||||
anime: true,
|
||||
anime: category === 'anime',
|
||||
query: query.query,
|
||||
});
|
||||
if (!response.ok) return response;
|
||||
|
||||
+21
-1
@@ -86,10 +86,30 @@
|
||||
<button id="jimakuClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="jimaku-tabs" role="tablist">
|
||||
<button
|
||||
id="jimakuTabAnime"
|
||||
class="jimaku-tab active"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
>
|
||||
Anime
|
||||
</button>
|
||||
<button
|
||||
id="jimakuTabLiveAction"
|
||||
class="jimaku-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
>
|
||||
Live action
|
||||
</button>
|
||||
</div>
|
||||
<div class="jimaku-form">
|
||||
<label class="jimaku-field">
|
||||
<span>Title</span>
|
||||
<input id="jimakuTitle" type="text" placeholder="Anime title" />
|
||||
<input id="jimakuTitle" type="text" placeholder="Title" />
|
||||
</label>
|
||||
<label class="jimaku-field">
|
||||
<span>Season</span>
|
||||
|
||||
@@ -119,6 +119,8 @@ test('successful Jimaku subtitle selection closes modal', async () => {
|
||||
classList: jimakuBroadenButtonClassList,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
@@ -147,3 +149,427 @@ test('successful Jimaku subtitle selection closes modal', async () => {
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('switching to the Live action tab re-runs the search with the live action category', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const searchQueries: Array<{ query: string; category?: string }> = [];
|
||||
const electronAPI = {
|
||||
jimakuSearchEntries: async (query: { query: string; category?: string }) => {
|
||||
searchQueries.push(query);
|
||||
return { ok: true, data: [] };
|
||||
},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
const animeTabClassList = createClassList(['active']);
|
||||
const liveActionTabClassList = createClassList();
|
||||
const status = { textContent: '', style: { color: '' } };
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: 'Shinzanmono' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '3' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: status,
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: animeTabClassList, setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: liveActionTabClassList, setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(state.jimakuActiveTab, 'liveAction');
|
||||
assert.equal(liveActionTabClassList.contains('active'), true);
|
||||
assert.equal(animeTabClassList.contains('active'), false);
|
||||
assert.deepEqual(searchQueries, [{ query: 'Shinzanmono', category: 'liveAction' }]);
|
||||
assert.equal(status.textContent, 'No live action entries found. Try the Anime tab.');
|
||||
|
||||
// Same tab again is a no-op: no duplicate request.
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
assert.equal(searchQueries.length, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow reply from a superseded search does not overwrite the newer results', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const pending: Array<(entries: unknown[]) => void> = [];
|
||||
const electronAPI = {
|
||||
jimakuSearchEntries: () =>
|
||||
new Promise((resolve) => {
|
||||
pending.push((entries) => resolve({ ok: true, data: entries }));
|
||||
}),
|
||||
jimakuListFiles: async () => ({ ok: true, data: [] }),
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: 'Shinzanmono' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: { textContent: '', style: { color: '' } },
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
// Anime -> Live action -> Anime, all before any reply arrives.
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowLeft',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
assert.equal(pending.length, 2);
|
||||
|
||||
// The stale live action reply lands after the newer anime search was issued.
|
||||
pending[0]!([{ id: 1, name: 'Stale live action entry' }]);
|
||||
await flushAsyncWork();
|
||||
assert.equal(state.jimakuEntries.length, 0);
|
||||
|
||||
pending[1]!([
|
||||
{ id: 2, name: 'Anime A' },
|
||||
{ id: 3, name: 'Anime B' },
|
||||
]);
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(
|
||||
state.jimakuEntries.map((entry) => entry.id),
|
||||
[2, 3],
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('closing the modal discards an in-flight search reply', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let resolveSearch!: (entries: unknown[]) => void;
|
||||
let listFilesCalls = 0;
|
||||
const electronAPI = {
|
||||
jimakuSearchEntries: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveSearch = (entries) => resolve({ ok: true, data: entries });
|
||||
}),
|
||||
jimakuListFiles: async () => {
|
||||
listFilesCalls += 1;
|
||||
return { ok: true, data: [] };
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: 'Shinzanmono' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: { textContent: '', style: { color: '' } },
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
jimakuModal.closeJimakuModal();
|
||||
|
||||
// A single entry would normally auto-select and fetch its files.
|
||||
resolveSearch([{ id: 7, name: 'Only entry' }]);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(state.jimakuEntries.length, 0);
|
||||
assert.equal(state.currentEntryId, null);
|
||||
assert.equal(listFilesCalls, 0);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow files reply for a previously selected entry is ignored', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const pending = new Map<number, (files: unknown[]) => void>();
|
||||
const electronAPI = {
|
||||
jimakuListFiles: (query: { entryId: number }) =>
|
||||
new Promise((resolve) => {
|
||||
pending.set(query.entryId, (files) => resolve({ ok: true, data: files }));
|
||||
}),
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
state.jimakuEntries = [
|
||||
{ id: 1, name: 'Entry A' },
|
||||
{ id: 2, name: 'Entry B' },
|
||||
];
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: '' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: { textContent: '', style: { color: '' } },
|
||||
jimakuEntriesSection: { classList: createClassList() },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
// Select entry A, then move to entry B before A's files arrive.
|
||||
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowDown',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
assert.equal(state.currentEntryId, 2);
|
||||
|
||||
pending.get(1)!([
|
||||
{ name: 'a.srt', url: 'https://jimaku.cc/a.srt', size: 1, last_modified: '' },
|
||||
]);
|
||||
await flushAsyncWork();
|
||||
assert.equal(state.jimakuFiles.length, 0);
|
||||
|
||||
pending.get(2)!([
|
||||
{ name: 'b1.srt', url: 'https://jimaku.cc/b1.srt', size: 1, last_modified: '' },
|
||||
{ name: 'b2.srt', url: 'https://jimaku.cc/b2.srt', size: 1, last_modified: '' },
|
||||
]);
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(
|
||||
state.jimakuFiles.map((file) => file.name),
|
||||
['b1.srt', 'b2.srt'],
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('media info arriving after the modal closed does not fill inputs or search', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let resolveMediaInfo!: (info: unknown) => void;
|
||||
let searchCalls = 0;
|
||||
const electronAPI = {
|
||||
getJimakuMediaInfo: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveMediaInfo = resolve;
|
||||
}),
|
||||
jimakuSearchEntries: async () => {
|
||||
searchCalls += 1;
|
||||
return { ok: true, data: [] };
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
const titleInput = { value: '' };
|
||||
const status = { textContent: '', style: { color: '' } };
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
jimakuModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
||||
jimakuTitleInput: titleInput,
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: status,
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
jimakuModal.openJimakuModal();
|
||||
await flushAsyncWork();
|
||||
jimakuModal.closeJimakuModal();
|
||||
|
||||
resolveMediaInfo({
|
||||
title: 'Shinzanmono',
|
||||
season: 1,
|
||||
episode: 3,
|
||||
confidence: 'high',
|
||||
filename: 'Shinzanmono S01E03.mkv',
|
||||
rawTitle: 'Shinzanmono S01E03',
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(titleInput.value, '');
|
||||
assert.equal(searchCalls, 0);
|
||||
assert.equal(status.textContent, 'Loading media info...');
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuMediaInfo,
|
||||
JimakuSearchCategory,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
@@ -21,7 +22,12 @@ export function createJimakuModal(
|
||||
: 'rgba(255, 255, 255, 0.8)';
|
||||
}
|
||||
|
||||
// Bumped whenever the lists are reset (new search, tab switch, open, close)
|
||||
// so any in-flight entries or files reply for the old state is discarded.
|
||||
let searchGeneration = 0;
|
||||
|
||||
function resetJimakuLists(): void {
|
||||
searchGeneration += 1;
|
||||
ctx.state.jimakuEntries = [];
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
@@ -35,6 +41,33 @@ export function createJimakuModal(
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
}
|
||||
|
||||
function renderTabs(): void {
|
||||
const liveActionActive = ctx.state.jimakuActiveTab === 'liveAction';
|
||||
const active = liveActionActive
|
||||
? ctx.dom.jimakuTabLiveActionButton
|
||||
: ctx.dom.jimakuTabAnimeButton;
|
||||
const inactive = liveActionActive
|
||||
? ctx.dom.jimakuTabAnimeButton
|
||||
: ctx.dom.jimakuTabLiveActionButton;
|
||||
active.classList.add('active');
|
||||
active.setAttribute('aria-selected', 'true');
|
||||
inactive.classList.remove('active');
|
||||
inactive.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
|
||||
// Tabs map to Jimaku's anime / live-action catalogues, so switching re-runs
|
||||
// the search server-side instead of filtering a shared result list.
|
||||
function setActiveTab(tab: JimakuSearchCategory): void {
|
||||
if (ctx.state.jimakuActiveTab === tab) return;
|
||||
ctx.state.jimakuActiveTab = tab;
|
||||
renderTabs();
|
||||
if (getSearchQuery().query) {
|
||||
void performJimakuSearch();
|
||||
} else {
|
||||
resetJimakuLists();
|
||||
}
|
||||
}
|
||||
|
||||
function formatEntryLabel(entry: JimakuEntry): string {
|
||||
if (entry.english_name && entry.english_name !== entry.name) {
|
||||
return `${entry.name} / ${entry.english_name}`;
|
||||
@@ -133,9 +166,12 @@ export function createJimakuModal(
|
||||
setJimakuStatus('Searching Jimaku...');
|
||||
ctx.state.currentEpisodeFilter = episode;
|
||||
|
||||
const category = ctx.state.jimakuActiveTab;
|
||||
const generation = searchGeneration;
|
||||
const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries(
|
||||
{ query },
|
||||
{ query, category },
|
||||
);
|
||||
if (generation !== searchGeneration) return;
|
||||
if (!response.ok) {
|
||||
const retry = response.error.retryAfter
|
||||
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
||||
@@ -148,7 +184,11 @@ export function createJimakuModal(
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
|
||||
if (ctx.state.jimakuEntries.length === 0) {
|
||||
setJimakuStatus('No entries found.');
|
||||
setJimakuStatus(
|
||||
category === 'anime'
|
||||
? 'No anime entries found. Try the Live action tab.'
|
||||
: 'No live action entries found. Try the Anime tab.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,12 +207,15 @@ export function createJimakuModal(
|
||||
ctx.dom.jimakuFilesList.innerHTML = '';
|
||||
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
||||
|
||||
const generation = searchGeneration;
|
||||
const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles(
|
||||
{
|
||||
entryId,
|
||||
episode,
|
||||
},
|
||||
);
|
||||
// The user may have picked another entry or reset the modal meanwhile.
|
||||
if (generation !== searchGeneration || ctx.state.currentEntryId !== entryId) return;
|
||||
if (!response.ok) {
|
||||
const retry = response.error.retryAfter
|
||||
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
||||
@@ -262,10 +305,15 @@ export function createJimakuModal(
|
||||
|
||||
setJimakuStatus('Loading media info...');
|
||||
resetJimakuLists();
|
||||
renderTabs();
|
||||
|
||||
// Media info can resolve after the user already closed the modal or
|
||||
// started their own search; a stale reply must not touch the inputs.
|
||||
const generation = searchGeneration;
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then((info: JimakuMediaInfo) => {
|
||||
if (generation !== searchGeneration) return;
|
||||
ctx.dom.jimakuTitleInput.value = info.title || '';
|
||||
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : '';
|
||||
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||
@@ -280,6 +328,7 @@ export function createJimakuModal(
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (generation !== searchGeneration) return;
|
||||
setJimakuStatus('Failed to load media info.', true);
|
||||
});
|
||||
}
|
||||
@@ -315,6 +364,18 @@ export function createJimakuModal(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setActiveTab('anime');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setActiveTab('liveAction');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
@@ -367,6 +428,12 @@ export function createJimakuModal(
|
||||
ctx.dom.jimakuCloseButton.addEventListener('click', () => {
|
||||
closeJimakuModal();
|
||||
});
|
||||
ctx.dom.jimakuTabAnimeButton.addEventListener('click', () => {
|
||||
setActiveTab('anime');
|
||||
});
|
||||
ctx.dom.jimakuTabLiveActionButton.addEventListener('click', () => {
|
||||
setActiveTab('liveAction');
|
||||
});
|
||||
ctx.dom.jimakuBroadenButton.addEventListener('click', () => {
|
||||
if (ctx.state.currentEntryId !== null) {
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
TsukihimeEntry,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuEntry,
|
||||
JimakuSearchCategory,
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
@@ -43,6 +44,7 @@ export type RendererState = {
|
||||
persistedSubtitlePosition: SubtitlePosition;
|
||||
|
||||
jimakuModalOpen: boolean;
|
||||
jimakuActiveTab: JimakuSearchCategory;
|
||||
jimakuEntries: JimakuEntry[];
|
||||
jimakuFiles: JimakuFileEntry[];
|
||||
selectedEntryIndex: number;
|
||||
@@ -176,6 +178,7 @@ export function createRendererState(): RendererState {
|
||||
persistedSubtitlePosition: { yPercent: 10 },
|
||||
|
||||
jimakuModalOpen: false,
|
||||
jimakuActiveTab: 'anime',
|
||||
jimakuEntries: [],
|
||||
jimakuFiles: [],
|
||||
selectedEntryIndex: 0,
|
||||
|
||||
@@ -819,12 +819,14 @@ body:focus-visible,
|
||||
grid-template-columns: 1fr 120px auto;
|
||||
}
|
||||
|
||||
.jimaku-tabs,
|
||||
.tsukihime-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.jimaku-tab,
|
||||
.tsukihime-tab {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
@@ -842,6 +844,8 @@ body:focus-visible,
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.jimaku-tab:hover,
|
||||
.jimaku-tab:focus-visible,
|
||||
.tsukihime-tab:hover,
|
||||
.tsukihime-tab:focus-visible {
|
||||
border-color: rgba(138, 173, 244, 0.48);
|
||||
@@ -849,6 +853,7 @@ body:focus-visible,
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.jimaku-tab.active,
|
||||
.tsukihime-tab.active {
|
||||
border-color: rgba(238, 212, 159, 0.62);
|
||||
background: rgba(238, 212, 159, 0.16);
|
||||
@@ -2977,7 +2982,7 @@ iframe[id^='yomitan-popup'],
|
||||
}
|
||||
|
||||
.subtitle-generation-content {
|
||||
width: min(580px, 92%);
|
||||
width: min(860px, 92%);
|
||||
max-height: 92%;
|
||||
border-top: 3px solid var(--ctp-green);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export type RendererDom = {
|
||||
jimakuFilesSection: HTMLDivElement;
|
||||
jimakuFilesList: HTMLUListElement;
|
||||
jimakuBroadenButton: HTMLButtonElement;
|
||||
jimakuTabAnimeButton: HTMLButtonElement;
|
||||
jimakuTabLiveActionButton: HTMLButtonElement;
|
||||
|
||||
tsukihimeModal: HTMLDivElement;
|
||||
tsukihimeTitleInput: HTMLInputElement;
|
||||
@@ -218,6 +220,8 @@ export function resolveRendererDom(): RendererDom {
|
||||
jimakuFilesSection: getRequiredElement<HTMLDivElement>('jimakuFilesSection'),
|
||||
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
|
||||
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
|
||||
jimakuTabAnimeButton: getRequiredElement<HTMLButtonElement>('jimakuTabAnime'),
|
||||
jimakuTabLiveActionButton: getRequiredElement<HTMLButtonElement>('jimakuTabLiveAction'),
|
||||
|
||||
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
|
||||
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
|
||||
|
||||
@@ -393,7 +393,14 @@ export function parseKikuMergePreviewRequest(value: unknown): KikuMergePreviewRe
|
||||
|
||||
export function parseJimakuSearchQuery(value: unknown): JimakuSearchQuery | null {
|
||||
if (!isObject(value) || typeof value.query !== 'string') return null;
|
||||
return { query: value.query };
|
||||
if (
|
||||
value.category !== undefined &&
|
||||
value.category !== 'anime' &&
|
||||
value.category !== 'liveAction'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { query: value.query, category: value.category };
|
||||
}
|
||||
|
||||
export function parseJimakuFilesQuery(value: unknown): JimakuFilesQuery | null {
|
||||
|
||||
@@ -190,8 +190,12 @@ export interface JimakuMediaInfo {
|
||||
rawTitle: string;
|
||||
}
|
||||
|
||||
export type JimakuSearchCategory = 'anime' | 'liveAction';
|
||||
|
||||
export interface JimakuSearchQuery {
|
||||
query: string;
|
||||
// Which Jimaku catalogue to search; defaults to anime when omitted.
|
||||
category?: JimakuSearchCategory;
|
||||
}
|
||||
|
||||
export interface JimakuEntryFlags {
|
||||
|
||||
Reference in new issue
Block a user