Jellyfin and Subsync Fixes (#13)

This commit is contained in:
2026-03-01 16:13:16 -08:00
committed by GitHub
parent 49434bf0cd
commit 7023a3263f
36 changed files with 2001 additions and 60 deletions

View File

@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI } from '../../types';
import { createRendererState } from '../state.js';
import { createJimakuModal } from './jimaku.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) {
tokens.add(entry);
}
},
remove: (...entries: string[]) => {
for (const entry of entries) {
tokens.delete(entry);
}
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
const classList = createClassList();
return {
textContent: '',
className: '',
style: {},
classList,
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
addEventListener: () => {},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
};
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
test('successful Jimaku subtitle selection closes modal', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const modalCloseNotifications: Array<'runtime-options' | 'subsync' | 'jimaku' | 'kiku'> = [];
const electronAPI = {
jimakuDownloadFile: async () => ({ ok: true, path: '/tmp/subtitles/episode01.ass' }),
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
modalCloseNotifications.push(modal);
},
} 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 overlayClassList = createClassList(['interactive']);
const jimakuModalClassList = createClassList();
const jimakuEntriesSectionClassList = createClassList(['hidden']);
const jimakuFilesSectionClassList = createClassList();
const jimakuBroadenButtonClassList = createClassList(['hidden']);
const state = createRendererState();
state.jimakuModalOpen = true;
state.currentEntryId = 42;
state.selectedFileIndex = 0;
state.jimakuFiles = [
{
name: 'episode01.ass',
url: 'https://jimaku.cc/files/episode01.ass',
size: 1000,
last_modified: '2026-03-01',
},
];
const ctx = {
dom: {
overlay: { classList: overlayClassList },
jimakuModal: {
classList: jimakuModalClassList,
setAttribute: () => {},
},
jimakuTitleInput: { value: '' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: { textContent: '', style: { color: '' } },
jimakuEntriesSection: { classList: jimakuEntriesSectionClassList },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: jimakuFilesSectionClassList },
jimakuFilesList: createListStub(),
jimakuBroadenButton: {
classList: jimakuBroadenButtonClassList,
addEventListener: () => {},
},
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
let prevented = false;
jimakuModal.handleJimakuKeydown({
key: 'Enter',
preventDefault: () => {
prevented = true;
},
} as KeyboardEvent);
await flushAsyncWork();
assert.equal(prevented, true);
assert.equal(state.jimakuModalOpen, false);
assert.equal(jimakuModalClassList.contains('hidden'), true);
assert.equal(overlayClassList.contains('interactive'), false);
assert.deepEqual(modalCloseNotifications, ['jimaku']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});

View File

@@ -234,6 +234,7 @@ export function createJimakuModal(
if (result.ok) {
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
closeJimakuModal();
return;
}

View File

@@ -0,0 +1,226 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubsyncModal } from './subsync.js';
type Listener = () => void;
function createClassList() {
const classes = new Set<string>();
return {
add: (...tokens: string[]) => {
for (const token of tokens) classes.add(token);
},
remove: (...tokens: string[]) => {
for (const token of tokens) classes.delete(token);
},
toggle: (token: string, force?: boolean) => {
if (force === undefined) {
if (classes.has(token)) classes.delete(token);
else classes.add(token);
return classes.has(token);
}
if (force) classes.add(token);
else classes.delete(token);
return force;
},
contains: (token: string) => classes.has(token),
};
}
function createEventTarget() {
const listeners = new Map<string, Listener[]>();
return {
addEventListener: (event: string, listener: Listener) => {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
},
dispatch: (event: string) => {
for (const listener of listeners.get(event) ?? []) {
listener();
}
},
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; message: string }>) {
const overlayClassList = createClassList();
const modalClassList = createClassList();
const statusClassList = createClassList();
const sourceLabelClassList = createClassList();
const runButtonEvents = createEventTarget();
const closeButtonEvents = createEventTarget();
const engineAlassEvents = createEventTarget();
const engineFfsubsyncEvents = createEventTarget();
const sourceOptions: Array<{ value: string; textContent: string }> = [];
const runButton = {
disabled: false,
addEventListener: runButtonEvents.addEventListener,
dispatch: runButtonEvents.dispatch,
};
const closeButton = {
addEventListener: closeButtonEvents.addEventListener,
dispatch: closeButtonEvents.dispatch,
};
const subsyncEngineAlass = {
checked: false,
addEventListener: engineAlassEvents.addEventListener,
dispatch: engineAlassEvents.dispatch,
};
const subsyncEngineFfsubsync = {
checked: false,
addEventListener: engineFfsubsyncEvents.addEventListener,
dispatch: engineFfsubsyncEvents.dispatch,
};
const sourceSelect = {
innerHTML: '',
value: '',
disabled: false,
appendChild: (option: { value: string; textContent: string }) => {
sourceOptions.push(option);
if (!sourceSelect.value) {
sourceSelect.value = option.value;
}
return option;
},
};
let notifyClosedCalls = 0;
let notifyOpenedCalls = 0;
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
runSubsyncManual,
notifyOverlayModalOpened: () => {
notifyOpenedCalls += 1;
},
notifyOverlayModalClosed: () => {
notifyClosedCalls += 1;
},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({ value: '', textContent: '' }),
},
});
const ctx = {
dom: {
overlay: { classList: overlayClassList },
subsyncModal: {
classList: modalClassList,
setAttribute: () => {},
},
subsyncCloseButton: closeButton,
subsyncEngineAlass,
subsyncEngineFfsubsync,
subsyncSourceLabel: { classList: sourceLabelClassList },
subsyncSourceSelect: sourceSelect,
subsyncRunButton: runButton,
subsyncStatus: {
textContent: '',
classList: statusClassList,
},
},
state: {
subsyncModalOpen: false,
subsyncSourceTracks: [],
subsyncSubmitting: false,
isOverSubtitle: false,
},
};
const modal = createSubsyncModal(ctx as never, {
modalStateReader: {
isAnyModalOpen: () => false,
},
syncSettingsModalSubtitleSuppression: () => {},
});
return {
ctx,
modal,
runButton,
statusClassList,
getNotifyClosedCalls: () => notifyClosedCalls,
getNotifyOpenedCalls: () => notifyOpenedCalls,
restoreGlobals: () => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
},
};
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
test('manual subsync failure closes during run, then reopens modal with error', async () => {
const deferred = createDeferred<{ ok: boolean; message: string }>();
const harness = createTestHarness(async () => deferred.promise);
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal({
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
});
harness.runButton.dispatch('click');
await Promise.resolve();
assert.equal(harness.ctx.state.subsyncModalOpen, false);
assert.equal(harness.getNotifyClosedCalls(), 1);
assert.equal(harness.getNotifyOpenedCalls(), 0);
deferred.resolve({
ok: false,
message: 'alass synchronization failed: code=1 stderr: invalid subtitle format',
});
await flushMicrotasks();
assert.equal(harness.ctx.state.subsyncModalOpen, true);
assert.equal(
harness.ctx.dom.subsyncStatus.textContent,
'alass synchronization failed: code=1 stderr: invalid subtitle format',
);
assert.equal(harness.statusClassList.contains('error'), true);
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
assert.equal(harness.ctx.dom.subsyncSourceSelect.value, '2');
assert.equal(harness.getNotifyClosedCalls(), 1);
assert.equal(harness.getNotifyOpenedCalls(), 1);
} finally {
harness.restoreGlobals();
}
});

View File

@@ -71,6 +71,30 @@ export function createSubsyncModal(
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false');
}
function reopenSubsyncModalWithError(
sourceTracks: SubsyncManualPayload['sourceTracks'],
engine: 'alass' | 'ffsubsync',
sourceTrackId: number | null,
message: string,
): void {
openSubsyncModal({ sourceTracks });
if (engine === 'alass' && sourceTracks.length > 0) {
ctx.dom.subsyncEngineAlass.checked = true;
ctx.dom.subsyncEngineFfsubsync.checked = false;
if (Number.isFinite(sourceTrackId)) {
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
}
} else {
ctx.dom.subsyncEngineAlass.checked = false;
ctx.dom.subsyncEngineFfsubsync.checked = true;
}
updateSubsyncSourceVisibility();
setSubsyncStatus(message, true);
window.electronAPI.notifyOverlayModalOpened('subsync');
}
async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return;
@@ -85,15 +109,25 @@ export function createSubsyncModal(
return;
}
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track }));
ctx.state.subsyncSubmitting = true;
ctx.dom.subsyncRunButton.disabled = true;
closeSubsyncModal();
try {
await window.electronAPI.runSubsyncManual({
const result = await window.electronAPI.runSubsyncManual({
engine,
sourceTrackId,
});
if (result.ok) return;
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
} catch (error) {
reopenSubsyncModalWithError(
sourceTracksSnapshot,
engine,
sourceTrackId,
`Subsync failed: ${(error as Error).message}`,
);
} finally {
ctx.state.subsyncSubmitting = false;
ctx.dom.subsyncRunButton.disabled = false;