mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-08 17:16:18 -07:00
fix(tsukihime): filter releases by subtitle language (#235)
This commit is contained in:
@@ -40,13 +40,19 @@ function createElementStub() {
|
||||
}
|
||||
|
||||
function createListStub() {
|
||||
return {
|
||||
innerHTML: '',
|
||||
const list = {
|
||||
children: [] as unknown[],
|
||||
appendChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
list.children.push(child);
|
||||
},
|
||||
};
|
||||
// The modal clears lists through innerHTML before re-rendering.
|
||||
return Object.defineProperty(list, 'innerHTML', {
|
||||
get: () => '',
|
||||
set: () => {
|
||||
list.children.length = 0;
|
||||
},
|
||||
}) as typeof list & { innerHTML: string };
|
||||
}
|
||||
|
||||
function createTabStub(active: boolean) {
|
||||
@@ -118,6 +124,7 @@ function createModalHarness(
|
||||
files: TsukihimeSubtitleFile[],
|
||||
options: {
|
||||
secondaryLanguages?: string[];
|
||||
secondaryLanguagesGate?: Promise<void>;
|
||||
downloadFile?: (query: unknown) => Promise<unknown>;
|
||||
listFiles?: (entryId: number) => Promise<unknown>;
|
||||
searchEntries?: (query: unknown) => Promise<unknown>;
|
||||
@@ -138,7 +145,10 @@ function createModalHarness(
|
||||
if (options.downloadFile) return options.downloadFile(query);
|
||||
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
|
||||
},
|
||||
tsukihimeGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
||||
tsukihimeGetSecondaryLanguages: async () => {
|
||||
await options.secondaryLanguagesGate;
|
||||
return options.secondaryLanguages ?? ['en', 'eng'];
|
||||
},
|
||||
tsukihimeListFiles: async ({ entryId }: { entryId: number }) =>
|
||||
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
||||
tsukihimeSearchEntries: async (query: unknown) =>
|
||||
@@ -613,3 +623,202 @@ test('renderFiles omits the size detail when the API does not report one', () =>
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
const ENGLISH_ONLY_ENTRY = {
|
||||
id: 606713,
|
||||
title: 'english only release',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: 1,
|
||||
sublangs: ['en'],
|
||||
};
|
||||
|
||||
const MULTI_SUB_ENTRY = {
|
||||
id: 12255,
|
||||
title: 'multi-sub release',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: 1,
|
||||
sublangs: ['en-US', 'ja'],
|
||||
};
|
||||
|
||||
const UNLABELED_ENTRY = {
|
||||
id: 12256,
|
||||
title: 'release without langs',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: 1,
|
||||
sublangs: [],
|
||||
};
|
||||
|
||||
function visibleEntryTitles(harness: ModalHarness): string[] {
|
||||
return (harness.entriesList.children as Array<{ textContent: string }>).map(
|
||||
(li) => li.textContent,
|
||||
);
|
||||
}
|
||||
|
||||
test('Japanese tab lists only releases that carry Japanese subtitles', async () => {
|
||||
const SECOND_JAPANESE_TRACK: TsukihimeSubtitleFile = {
|
||||
...JAPANESE_TRACK,
|
||||
attachmentId: 1955401,
|
||||
filename: 'episode01.jpn.sdh.ass',
|
||||
};
|
||||
const harness = createModalHarness([], {
|
||||
// Two tracks so the modal does not auto-download a lone match.
|
||||
listFiles: async () => ({ ok: true, data: [JAPANESE_TRACK, SECOND_JAPANESE_TRACK] }),
|
||||
});
|
||||
try {
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, MULTI_SUB_ENTRY, UNLABELED_ENTRY];
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
|
||||
|
||||
// Enter addresses the visible list, so it must pick the multi-sub release
|
||||
// rather than the hidden first search result.
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, MULTI_SUB_ENTRY.id);
|
||||
assert.equal(harness.status.textContent, 'Select a subtitle track.');
|
||||
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
assert.deepEqual(visibleEntryTitles(harness), [
|
||||
'english only release',
|
||||
'multi-sub release',
|
||||
'release without langs',
|
||||
]);
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, MULTI_SUB_ENTRY.id);
|
||||
assert.equal(harness.state.selectedTsukihimeEntryIndex, 1);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('Japanese tab reports when no release carries Japanese subtitles', () => {
|
||||
const harness = createModalHarness([]);
|
||||
try {
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, UNLABELED_ENTRY];
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.deepEqual(visibleEntryTitles(harness), []);
|
||||
assert.equal(
|
||||
harness.status.textContent,
|
||||
'No releases with Japanese subtitles. Switch to the English tab.',
|
||||
);
|
||||
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
assert.deepEqual(visibleEntryTitles(harness), [
|
||||
'english only release',
|
||||
'release without langs',
|
||||
]);
|
||||
assert.equal(harness.status.textContent, 'Select a release.');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('search reports when no release carries the secondary language', async () => {
|
||||
const harness = createModalHarness([], {
|
||||
searchEntries: async () => ({
|
||||
ok: true,
|
||||
data: [{ ...MULTI_SUB_ENTRY, sublangs: ['ja'] }],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(visibleEntryTitles(harness), []);
|
||||
assert.equal(
|
||||
harness.status.textContent,
|
||||
'No releases with English subtitles. Switch to the Japanese tab.',
|
||||
);
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('switching to a tab that hides the selected release clears its tracks', () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, MULTI_SUB_ENTRY];
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, null);
|
||||
assert.deepEqual(harness.state.tsukihimeFiles, []);
|
||||
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
|
||||
assert.equal(harness.status.textContent, 'Select a release.');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a search waits for the configured secondary languages before filtering', async () => {
|
||||
let openGate!: () => void;
|
||||
const harness = createModalHarness([], {
|
||||
secondaryLanguages: ['de'],
|
||||
secondaryLanguagesGate: new Promise<void>((resolve) => {
|
||||
openGate = resolve;
|
||||
}),
|
||||
searchEntries: async () => ({
|
||||
ok: true,
|
||||
data: [{ ...MULTI_SUB_ENTRY, title: 'german release', sublangs: ['de'] }],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.state.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
// Searching before the config arrives must not filter against the English
|
||||
// fallback, which would hide this German-only release.
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(visibleEntryTitles(harness), []);
|
||||
|
||||
openGate();
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(visibleEntryTitles(harness), ['german release']);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a search from a prior modal session cannot repopulate a reopened modal', async () => {
|
||||
let openGate!: () => void;
|
||||
const harness = createModalHarness([], {
|
||||
secondaryLanguagesGate: new Promise<void>((resolve) => {
|
||||
openGate = resolve;
|
||||
}),
|
||||
searchEntries: async () => ({ ok: true, data: [MULTI_SUB_ENTRY] }),
|
||||
});
|
||||
try {
|
||||
harness.state.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
// The search parks on the language config, then the user closes and
|
||||
// reopens the modal before it resolves.
|
||||
pressKey(harness, 'Enter');
|
||||
harness.modal.closeTsukihimeModal();
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
harness.status.textContent = 'Fresh modal session';
|
||||
|
||||
openGate();
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.state.tsukihimeEntries, []);
|
||||
assert.deepEqual(visibleEntryTitles(harness), []);
|
||||
assert.equal(harness.status.textContent, 'Fresh modal session');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,7 +41,13 @@ export function createTsukihimeModal(
|
||||
|
||||
// Defaults to English until the configured secondary languages arrive.
|
||||
let secondaryLanguages: string[] = ['en'];
|
||||
// Both tab filters read the configured languages, so a search must wait for
|
||||
// them rather than filtering against the English fallback.
|
||||
let secondaryLanguagesReady: Promise<void> = Promise.resolve();
|
||||
let activeDownloadToken = 0;
|
||||
// Bumped by every new search and by closing the modal, so results that
|
||||
// arrive late cannot repopulate a reopened modal or a newer search.
|
||||
let activeSearchToken = 0;
|
||||
|
||||
function secondaryTabLabel(): string {
|
||||
return describeTsukihimeTabLanguages(secondaryLanguages);
|
||||
@@ -61,6 +67,47 @@ export function createTsukihimeModal(
|
||||
);
|
||||
}
|
||||
|
||||
// Releases are filtered by the languages the search index reports for
|
||||
// them. Most releases carry no Japanese track, so the primary tab hides
|
||||
// them outright. A release with no language data cannot be classified and
|
||||
// stays on the secondary tab, mirroring how unlabeled tracks are handled.
|
||||
function entryMatchesTab(entry: TsukihimeEntry, tab: 'secondary' | 'primary'): boolean {
|
||||
if (tab === 'primary') {
|
||||
return entry.sublangs.some((lang) => normalizeTsukihimeLangCode(lang) === 'ja');
|
||||
}
|
||||
if (entry.sublangs.length === 0) return true;
|
||||
return entry.sublangs.some(
|
||||
(lang) =>
|
||||
normalizeTsukihimeLangCode(lang) !== 'ja' &&
|
||||
tsukihimeTrackMatchesLanguages(lang, secondaryLanguages),
|
||||
);
|
||||
}
|
||||
|
||||
function getVisibleEntries(): TsukihimeEntry[] {
|
||||
return ctx.state.tsukihimeEntries.filter((entry) =>
|
||||
entryMatchesTab(entry, ctx.state.tsukihimeActiveTab),
|
||||
);
|
||||
}
|
||||
|
||||
function describeEmptyReleases(): string {
|
||||
const otherTab = ctx.state.tsukihimeActiveTab === 'primary' ? 'secondary' : 'primary';
|
||||
const otherTabHasReleases = ctx.state.tsukihimeEntries.some((entry) =>
|
||||
entryMatchesTab(entry, otherTab),
|
||||
);
|
||||
const language = ctx.state.tsukihimeActiveTab === 'primary' ? 'Japanese' : secondaryTabLabel();
|
||||
const otherLabel = otherTab === 'primary' ? 'Japanese' : secondaryTabLabel();
|
||||
return otherTabHasReleases
|
||||
? `No releases with ${language} subtitles. Switch to the ${otherLabel} tab.`
|
||||
: `No releases with ${language} subtitles.`;
|
||||
}
|
||||
|
||||
function clearFiles(): void {
|
||||
ctx.state.tsukihimeFiles = [];
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
function renderTabs(): void {
|
||||
const primaryActive = ctx.state.tsukihimeActiveTab === 'primary';
|
||||
ctx.dom.tsukihimeTabSecondaryButton.setAttribute(
|
||||
@@ -98,12 +145,37 @@ export function createTsukihimeModal(
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
renderTabs();
|
||||
|
||||
if (ctx.state.tsukihimeFiles.length === 0) return;
|
||||
renderFiles();
|
||||
if (getVisibleFiles().length === 0) {
|
||||
setTsukihimeStatus(describeEmptyTab());
|
||||
} else {
|
||||
setTsukihimeStatus('Select a subtitle track.');
|
||||
const currentEntry = ctx.state.tsukihimeEntries.find(
|
||||
(entry) => entry.id === ctx.state.currentTsukihimeEntryId,
|
||||
);
|
||||
if (currentEntry && !entryMatchesTab(currentEntry, tab)) {
|
||||
// The selected release is hidden on this tab; drop its tracks so the
|
||||
// list matches what the tab claims to show.
|
||||
ctx.state.currentTsukihimeEntryId = null;
|
||||
ctx.state.selectedTsukihimeEntryIndex = 0;
|
||||
clearFiles();
|
||||
renderEntries();
|
||||
setTsukihimeStatus(
|
||||
getVisibleEntries().length === 0 ? describeEmptyReleases() : 'Select a release.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleEntries = getVisibleEntries();
|
||||
ctx.state.selectedTsukihimeEntryIndex = currentEntry ? visibleEntries.indexOf(currentEntry) : 0;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.tsukihimeFiles.length > 0) {
|
||||
renderFiles();
|
||||
setTsukihimeStatus(
|
||||
getVisibleFiles().length === 0 ? describeEmptyTab() : 'Select a subtitle track.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!currentEntry && ctx.state.tsukihimeEntries.length > 0) {
|
||||
setTsukihimeStatus(
|
||||
visibleEntries.length === 0 ? describeEmptyReleases() : 'Select a release.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,13 +193,14 @@ export function createTsukihimeModal(
|
||||
|
||||
function renderEntries(): void {
|
||||
ctx.dom.tsukihimeEntriesList.innerHTML = '';
|
||||
if (ctx.state.tsukihimeEntries.length === 0) {
|
||||
const visibleEntries = getVisibleEntries();
|
||||
if (visibleEntries.length === 0) {
|
||||
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.tsukihimeEntriesSection.classList.remove('hidden');
|
||||
ctx.state.tsukihimeEntries.forEach((entry, index) => {
|
||||
visibleEntries.forEach((entry, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = entry.title;
|
||||
|
||||
@@ -210,11 +283,15 @@ export function createTsukihimeModal(
|
||||
return;
|
||||
}
|
||||
|
||||
const searchToken = ++activeSearchToken;
|
||||
resetTsukihimeLists();
|
||||
setTsukihimeStatus('Searching TsukiHime...');
|
||||
await secondaryLanguagesReady;
|
||||
if (searchToken !== activeSearchToken) return;
|
||||
|
||||
const response: TsukihimeApiResponse<TsukihimeEntry[]> =
|
||||
await window.electronAPI.tsukihimeSearchEntries({ query });
|
||||
if (searchToken !== activeSearchToken) return;
|
||||
if (!response.ok) {
|
||||
setTsukihimeStatus(response.error.error, true);
|
||||
return;
|
||||
@@ -228,20 +305,22 @@ export function createTsukihimeModal(
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleEntries = getVisibleEntries();
|
||||
if (visibleEntries.length === 0) {
|
||||
setTsukihimeStatus(describeEmptyReleases());
|
||||
return;
|
||||
}
|
||||
|
||||
setTsukihimeStatus('Select a release.');
|
||||
renderEntries();
|
||||
if (ctx.state.tsukihimeEntries.length === 1) {
|
||||
if (visibleEntries.length === 1) {
|
||||
selectEntry(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(entryId: number): Promise<void> {
|
||||
setTsukihimeStatus('Loading subtitle tracks...');
|
||||
ctx.state.tsukihimeFiles = [];
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
clearFiles();
|
||||
|
||||
const response: TsukihimeApiResponse<TsukihimeSubtitleFile[]> =
|
||||
await window.electronAPI.tsukihimeListFiles({ entryId });
|
||||
@@ -279,11 +358,14 @@ export function createTsukihimeModal(
|
||||
}
|
||||
}
|
||||
|
||||
// `index` addresses the entries visible on the active tab, not the full
|
||||
// search result list.
|
||||
function selectEntry(index: number): void {
|
||||
if (index < 0 || index >= ctx.state.tsukihimeEntries.length) return;
|
||||
const visibleEntries = getVisibleEntries();
|
||||
if (index < 0 || index >= visibleEntries.length) return;
|
||||
|
||||
ctx.state.selectedTsukihimeEntryIndex = index;
|
||||
ctx.state.currentTsukihimeEntryId = ctx.state.tsukihimeEntries[index]!.id;
|
||||
ctx.state.currentTsukihimeEntryId = visibleEntries[index]!.id;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.currentTsukihimeEntryId !== null) {
|
||||
@@ -363,16 +445,15 @@ export function createTsukihimeModal(
|
||||
resetTsukihimeLists();
|
||||
renderTabs();
|
||||
|
||||
const secondaryLanguagesReady = loadSecondaryLanguages();
|
||||
secondaryLanguagesReady = loadSecondaryLanguages();
|
||||
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then(async (info: JimakuMediaInfo) => {
|
||||
.then((info: JimakuMediaInfo) => {
|
||||
ctx.dom.tsukihimeTitleInput.value = info.title || '';
|
||||
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||
|
||||
if (info.confidence === 'high' && info.title && info.episode) {
|
||||
await secondaryLanguagesReady;
|
||||
void performTsukihimeSearch();
|
||||
} else if (info.title) {
|
||||
setTsukihimeStatus('Check title/episode and press Search.');
|
||||
@@ -389,6 +470,7 @@ export function createTsukihimeModal(
|
||||
if (!ctx.state.tsukihimeModalOpen) return;
|
||||
|
||||
activeDownloadToken += 1;
|
||||
activeSearchToken += 1;
|
||||
ctx.state.tsukihimeModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.tsukihimeModal.classList.add('hidden');
|
||||
@@ -438,9 +520,9 @@ export function createTsukihimeModal(
|
||||
ctx.state.selectedTsukihimeFileIndex + 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
} else if (getVisibleEntries().length > 0) {
|
||||
ctx.state.selectedTsukihimeEntryIndex = Math.min(
|
||||
ctx.state.tsukihimeEntries.length - 1,
|
||||
getVisibleEntries().length - 1,
|
||||
ctx.state.selectedTsukihimeEntryIndex + 1,
|
||||
);
|
||||
renderEntries();
|
||||
@@ -456,7 +538,7 @@ export function createTsukihimeModal(
|
||||
ctx.state.selectedTsukihimeFileIndex - 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
} else if (getVisibleEntries().length > 0) {
|
||||
ctx.state.selectedTsukihimeEntryIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedTsukihimeEntryIndex - 1,
|
||||
@@ -470,7 +552,7 @@ export function createTsukihimeModal(
|
||||
e.preventDefault();
|
||||
if (getVisibleFiles().length > 0) {
|
||||
void selectFile(ctx.state.selectedTsukihimeFileIndex);
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
} else if (getVisibleEntries().length > 0) {
|
||||
selectEntry(ctx.state.selectedTsukihimeEntryIndex);
|
||||
} else {
|
||||
void performTsukihimeSearch();
|
||||
|
||||
Reference in New Issue
Block a user