fix(tsukihime): filter releases by subtitle language (#235)

This commit is contained in:
2026-09-02 20:29:59 -07:00
committed by GitHub
parent c055359be1
commit 99266294b8
4 changed files with 324 additions and 28 deletions
+213 -4
View File
@@ -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();
}
});
+105 -23
View File
@@ -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();