feat(jimaku): add live-action subtitle search (#251)

This commit is contained in:
2026-09-18 22:50:13 -07:00
committed by GitHub
parent dd76782d30
commit 026d495fac
11 changed files with 571 additions and 28 deletions
+3 -2
View File
@@ -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
View File
@@ -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>
+426
View File
@@ -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 });
}
});
+69 -2
View File
@@ -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');
+3
View File
@@ -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,
+5
View File
@@ -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);
+4
View File
@@ -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'),
+8 -1
View File
@@ -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 {
+4
View File
@@ -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 {