mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 18:22:42 -08:00
Jellyfin and Subsync Fixes (#13)
This commit is contained in:
@@ -42,6 +42,30 @@ test('parseArgs ignores missing value after --log-level', () => {
|
||||
assert.equal(args.start, true);
|
||||
});
|
||||
|
||||
test('parseArgs handles jellyfin item listing controls', () => {
|
||||
const args = parseArgs([
|
||||
'--jellyfin-items',
|
||||
'--jellyfin-recursive=false',
|
||||
'--jellyfin-include-item-types',
|
||||
'Series,Movie,Folder',
|
||||
]);
|
||||
|
||||
assert.equal(args.jellyfinItems, true);
|
||||
assert.equal(args.jellyfinRecursive, false);
|
||||
assert.equal(args.jellyfinIncludeItemTypes, 'Series,Movie,Folder');
|
||||
});
|
||||
|
||||
test('parseArgs handles space-separated jellyfin recursive control', () => {
|
||||
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', 'false']);
|
||||
assert.equal(args.jellyfinRecursive, false);
|
||||
});
|
||||
|
||||
test('parseArgs ignores unrecognized space-separated jellyfin recursive values', () => {
|
||||
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', '--start']);
|
||||
assert.equal(args.jellyfinRecursive, undefined);
|
||||
assert.equal(args.start, true);
|
||||
});
|
||||
|
||||
test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
const stopOnly = parseArgs(['--stop']);
|
||||
assert.equal(hasExplicitCommand(stopOnly), true);
|
||||
@@ -118,6 +142,19 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
|
||||
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
|
||||
|
||||
const jellyfinPreviewAuth = parseArgs([
|
||||
'--jellyfin-preview-auth',
|
||||
'--jellyfin-response-path',
|
||||
'/tmp/subminer-jf-response.json',
|
||||
]);
|
||||
assert.equal(jellyfinPreviewAuth.jellyfinPreviewAuth, true);
|
||||
assert.equal(
|
||||
jellyfinPreviewAuth.jellyfinResponsePath,
|
||||
'/tmp/subminer-jf-response.json',
|
||||
);
|
||||
assert.equal(hasExplicitCommand(jellyfinPreviewAuth), true);
|
||||
assert.equal(shouldStartApp(jellyfinPreviewAuth), false);
|
||||
|
||||
const background = parseArgs(['--background']);
|
||||
assert.equal(background.background, true);
|
||||
assert.equal(hasExplicitCommand(background), true);
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface CliArgs {
|
||||
jellyfinSubtitleUrlsOnly: boolean;
|
||||
jellyfinPlay: boolean;
|
||||
jellyfinRemoteAnnounce: boolean;
|
||||
jellyfinPreviewAuth: boolean;
|
||||
texthooker: boolean;
|
||||
help: boolean;
|
||||
autoStartOverlay: boolean;
|
||||
@@ -49,8 +50,11 @@ export interface CliArgs {
|
||||
jellyfinItemId?: string;
|
||||
jellyfinSearch?: string;
|
||||
jellyfinLimit?: number;
|
||||
jellyfinRecursive?: boolean;
|
||||
jellyfinIncludeItemTypes?: string;
|
||||
jellyfinAudioStreamIndex?: number;
|
||||
jellyfinSubtitleStreamIndex?: number;
|
||||
jellyfinResponsePath?: string;
|
||||
debug: boolean;
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
}
|
||||
@@ -93,6 +97,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
@@ -147,6 +152,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
args.jellyfinSubtitleUrlsOnly = true;
|
||||
} else if (arg === '--jellyfin-play') args.jellyfinPlay = true;
|
||||
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
|
||||
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
||||
else if (arg === '--texthooker') args.texthooker = true;
|
||||
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
|
||||
else if (arg === '--generate-config') args.generateConfig = true;
|
||||
@@ -229,6 +235,25 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--jellyfin-limit') {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
|
||||
} else if (arg.startsWith('--jellyfin-recursive=')) {
|
||||
const value = arg.split('=', 2)[1]?.trim().toLowerCase();
|
||||
if (value === 'true' || value === '1' || value === 'yes') args.jellyfinRecursive = true;
|
||||
if (value === 'false' || value === '0' || value === 'no') args.jellyfinRecursive = false;
|
||||
} else if (arg === '--jellyfin-recursive') {
|
||||
const value = readValue(argv[i + 1])?.trim().toLowerCase();
|
||||
if (value === 'false' || value === '0' || value === 'no') {
|
||||
args.jellyfinRecursive = false;
|
||||
} else if (value === 'true' || value === '1' || value === 'yes') {
|
||||
args.jellyfinRecursive = true;
|
||||
}
|
||||
} else if (arg === '--jellyfin-non-recursive') {
|
||||
args.jellyfinRecursive = false;
|
||||
} else if (arg.startsWith('--jellyfin-include-item-types=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.jellyfinIncludeItemTypes = value;
|
||||
} else if (arg === '--jellyfin-include-item-types') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.jellyfinIncludeItemTypes = value;
|
||||
} else if (arg.startsWith('--jellyfin-audio-stream-index=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
|
||||
@@ -241,6 +266,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--jellyfin-subtitle-stream-index') {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
|
||||
} else if (arg.startsWith('--jellyfin-response-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.jellyfinResponsePath = value;
|
||||
} else if (arg === '--jellyfin-response-path') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.jellyfinResponsePath = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +313,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
args.texthooker ||
|
||||
args.generateConfig ||
|
||||
args.help
|
||||
@@ -350,6 +382,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.jellyfinSubtitles &&
|
||||
!args.jellyfinPlay &&
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.texthooker &&
|
||||
!args.help &&
|
||||
!args.autoStartOverlay &&
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
parseKikuFieldGroupingChoice,
|
||||
parseKikuMergePreviewRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
|
||||
|
||||
const logger = createLogger('main:anki-jimaku-ipc');
|
||||
|
||||
@@ -148,10 +149,11 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
if (!safeName) {
|
||||
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
||||
}
|
||||
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(currentMediaPath, safeName);
|
||||
|
||||
const ext = path.extname(safeName);
|
||||
const baseName = ext ? safeName.slice(0, -ext.length) : safeName;
|
||||
let targetPath = path.join(mediaDir, safeName);
|
||||
const ext = path.extname(subtitleFilename);
|
||||
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
|
||||
let targetPath = path.join(mediaDir, subtitleFilename);
|
||||
if (fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
|
||||
let counter = 2;
|
||||
|
||||
111
src/core/services/app-lifecycle.test.ts
Normal file
111
src/core/services/app-lifecycle.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CliArgs } from '../../cli/args';
|
||||
import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
hideVisibleOverlay: false,
|
||||
copySubtitle: false,
|
||||
copySubtitleMultiple: false,
|
||||
mineSentence: false,
|
||||
mineSentenceMultiple: false,
|
||||
updateLastCardFromClipboard: false,
|
||||
refreshKnownWords: false,
|
||||
toggleSecondarySub: false,
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
openRuntimeOptions: false,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinLibraries: false,
|
||||
jellyfinItems: false,
|
||||
jellyfinSubtitles: false,
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
backupOverwrite: false,
|
||||
debug: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
let lockCalls = 0;
|
||||
|
||||
const deps: AppLifecycleServiceDeps = {
|
||||
shouldStartApp: () => false,
|
||||
parseArgs: () => makeArgs(),
|
||||
requestSingleInstanceLock: () => {
|
||||
lockCalls += 1;
|
||||
return true;
|
||||
},
|
||||
quitApp: () => {
|
||||
calls.push('quitApp');
|
||||
},
|
||||
onSecondInstance: () => {},
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {
|
||||
calls.push('printHelp');
|
||||
},
|
||||
logNoRunningInstance: () => {
|
||||
calls.push('logNoRunningInstance');
|
||||
},
|
||||
whenReady: () => {},
|
||||
onWindowAllClosed: () => {},
|
||||
onWillQuit: () => {},
|
||||
onActivate: () => {},
|
||||
isDarwinPlatform: () => false,
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => true,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { deps, calls, getLockCalls: () => lockCalls };
|
||||
}
|
||||
|
||||
test('startAppLifecycle handles --help without acquiring single-instance lock', () => {
|
||||
const { deps, calls, getLockCalls } = createDeps({
|
||||
shouldStartApp: () => false,
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ help: true }), deps);
|
||||
|
||||
assert.equal(getLockCalls(), 0);
|
||||
assert.deepEqual(calls, ['printHelp', 'quitApp']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle still acquires lock for startup commands', () => {
|
||||
const { deps, getLockCalls } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
whenReady: () => {},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ start: true }), deps);
|
||||
|
||||
assert.equal(getLockCalls(), 1);
|
||||
});
|
||||
@@ -87,6 +87,12 @@ export function createAppLifecycleDepsRuntime(
|
||||
}
|
||||
|
||||
export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void {
|
||||
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
|
||||
deps.printHelp();
|
||||
deps.quitApp();
|
||||
return;
|
||||
}
|
||||
|
||||
const gotTheLock = deps.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
deps.quitApp();
|
||||
@@ -101,12 +107,6 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
}
|
||||
});
|
||||
|
||||
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
|
||||
deps.printHelp();
|
||||
deps.quitApp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deps.shouldStartApp(initialArgs)) {
|
||||
if (initialArgs.stop && !initialArgs.start) {
|
||||
deps.quitApp();
|
||||
|
||||
@@ -111,8 +111,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
||||
assert.equal(calls.includes('logConfigWarning'), false);
|
||||
assert.equal(calls.includes('handleInitialArgs'), true);
|
||||
assert.equal(calls.includes('loadYomitanExtension'), true);
|
||||
assert.equal(calls[0], 'loadYomitanExtension');
|
||||
assert.equal(calls[calls.length - 1], 'handleInitialArgs');
|
||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
|
||||
@@ -87,6 +87,10 @@ test('listItems supports search and formats title', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
assert.match(String(input), /SearchTerm=planet/);
|
||||
assert.match(
|
||||
String(input),
|
||||
/IncludeItemTypes=Movie%2CEpisode%2CAudio%2CSeries%2CSeason%2CFolder%2CCollectionFolder/,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
Items: [
|
||||
@@ -125,6 +129,64 @@ test('listItems supports search and formats title', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('listItems keeps playable-only include types when search is empty', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
assert.match(String(input), /IncludeItemTypes=Movie%2CEpisode%2CAudio/);
|
||||
assert.doesNotMatch(String(input), /CollectionFolder|Series|Season|Folder/);
|
||||
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const items = await listItems(
|
||||
{
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'u1',
|
||||
username: 'kyle',
|
||||
},
|
||||
clientInfo,
|
||||
{
|
||||
libraryId: 'lib-1',
|
||||
limit: 25,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(items, []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('listItems accepts explicit include types and recursive mode', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
assert.match(String(input), /Recursive=false/);
|
||||
assert.match(String(input), /IncludeItemTypes=Series%2CMovie%2CFolder/);
|
||||
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const items = await listItems(
|
||||
{
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'u1',
|
||||
username: 'kyle',
|
||||
},
|
||||
clientInfo,
|
||||
{
|
||||
libraryId: 'lib-1',
|
||||
includeItemTypes: 'Series,Movie,Folder',
|
||||
recursive: false,
|
||||
limit: 25,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(items, []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('resolvePlaybackPlan chooses direct play when allowed', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
|
||||
@@ -370,21 +370,29 @@ export async function listItems(
|
||||
libraryId: string;
|
||||
searchTerm?: string;
|
||||
limit?: number;
|
||||
recursive?: boolean;
|
||||
includeItemTypes?: string;
|
||||
},
|
||||
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
|
||||
if (!options.libraryId) throw new Error('Missing Jellyfin library id.');
|
||||
const normalizedSearchTerm = options.searchTerm?.trim() || '';
|
||||
const includeItemTypes =
|
||||
options.includeItemTypes?.trim() ||
|
||||
(normalizedSearchTerm
|
||||
? 'Movie,Episode,Audio,Series,Season,Folder,CollectionFolder'
|
||||
: 'Movie,Episode,Audio');
|
||||
|
||||
const query = new URLSearchParams({
|
||||
ParentId: options.libraryId,
|
||||
Recursive: 'true',
|
||||
IncludeItemTypes: 'Movie,Episode,Audio',
|
||||
Recursive: options.recursive === false ? 'false' : 'true',
|
||||
IncludeItemTypes: includeItemTypes,
|
||||
Fields: 'MediaSources,UserData',
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Limit: String(options.limit ?? 100),
|
||||
});
|
||||
if (options.searchTerm?.trim()) {
|
||||
query.set('SearchTerm', options.searchTerm.trim());
|
||||
if (normalizedSearchTerm) {
|
||||
query.set('SearchTerm', normalizedSearchTerm);
|
||||
}
|
||||
|
||||
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
||||
|
||||
28
src/core/services/jimaku-download-path.test.ts
Normal file
28
src/core/services/jimaku-download-path.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path.js';
|
||||
|
||||
test('buildJimakuSubtitleFilenameFromMediaPath uses media basename + ja + subtitle extension', () => {
|
||||
assert.equal(
|
||||
buildJimakuSubtitleFilenameFromMediaPath('/videos/anime.mkv', 'Subs.Release.1080p.srt'),
|
||||
'anime.ja.srt',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildJimakuSubtitleFilenameFromMediaPath falls back to .srt when subtitle name has no extension', () => {
|
||||
assert.equal(
|
||||
buildJimakuSubtitleFilenameFromMediaPath('/videos/anime.mkv', 'Subs Release'),
|
||||
'anime.ja.srt',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildJimakuSubtitleFilenameFromMediaPath supports remote media URLs', () => {
|
||||
assert.equal(
|
||||
buildJimakuSubtitleFilenameFromMediaPath(
|
||||
'https://cdn.example.org/library/Anime%20Episode%2001.mkv?token=abc',
|
||||
'anything.ass',
|
||||
),
|
||||
'Anime Episode 01.ja.ass',
|
||||
);
|
||||
});
|
||||
51
src/core/services/jimaku-download-path.ts
Normal file
51
src/core/services/jimaku-download-path.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as path from 'node:path';
|
||||
|
||||
const DEFAULT_JIMAKU_LANGUAGE_SUFFIX = 'ja';
|
||||
const DEFAULT_SUBTITLE_EXTENSION = '.srt';
|
||||
|
||||
function stripFileExtension(name: string): string {
|
||||
const ext = path.extname(name);
|
||||
return ext ? name.slice(0, -ext.length) : name;
|
||||
}
|
||||
|
||||
function sanitizeFilenameSegment(value: string, fallback: string): string {
|
||||
const sanitized = value
|
||||
.replace(/[\\/:*?"<>|]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
function resolveMediaFilename(mediaPath: string): string {
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath)) {
|
||||
return path.basename(path.resolve(mediaPath));
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(mediaPath);
|
||||
const decodedPath = decodeURIComponent(parsedUrl.pathname);
|
||||
const fromPath = path.basename(decodedPath);
|
||||
if (fromPath) {
|
||||
return fromPath;
|
||||
}
|
||||
return parsedUrl.hostname.replace(/^www\./, '') || 'subtitle';
|
||||
} catch {
|
||||
return path.basename(mediaPath);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildJimakuSubtitleFilenameFromMediaPath(
|
||||
mediaPath: string,
|
||||
downloadedSubtitleName: string,
|
||||
languageSuffix = DEFAULT_JIMAKU_LANGUAGE_SUFFIX,
|
||||
): string {
|
||||
const mediaFilename = resolveMediaFilename(mediaPath);
|
||||
const mediaBasename = sanitizeFilenameSegment(stripFileExtension(mediaFilename), 'subtitle');
|
||||
const subtitleName = path.basename(downloadedSubtitleName);
|
||||
const subtitleExt = path.extname(subtitleName) || DEFAULT_SUBTITLE_EXTENSION;
|
||||
const normalizedLanguageSuffix = sanitizeFilenameSegment(languageSuffix, 'ja').replace(
|
||||
/\s+/g,
|
||||
'-',
|
||||
);
|
||||
return `${mediaBasename}.${normalizedLanguageSuffix}${subtitleExt}`;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
|
||||
39
src/main-entry-runtime.test.ts
Normal file
39
src/main-entry-runtime.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
sanitizeHelpEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--start'], {}), false);
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
|
||||
});
|
||||
|
||||
test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeHelpEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
});
|
||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
|
||||
const env = sanitizeBackgroundEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
});
|
||||
assert.equal(env.SUBMINER_BACKGROUND_CHILD, '1');
|
||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('shouldDetachBackgroundLaunch only for first background invocation', () => {
|
||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], {}), true);
|
||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], { SUBMINER_BACKGROUND_CHILD: '1' }), false);
|
||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], { ELECTRON_RUN_AS_NODE: '1' }), false);
|
||||
assert.equal(shouldDetachBackgroundLaunch(['--start'], {}), false);
|
||||
});
|
||||
42
src/main-entry-runtime.ts
Normal file
42
src/main-entry-runtime.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CliArgs, parseArgs, shouldStartApp } from './cli/args';
|
||||
|
||||
const BACKGROUND_ARG = '--background';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
|
||||
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
||||
if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) {
|
||||
delete env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliArgs(argv: string[]): CliArgs {
|
||||
return parseArgs(argv);
|
||||
}
|
||||
|
||||
export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
if (!argv.includes(BACKGROUND_ARG)) return false;
|
||||
if (env[BACKGROUND_CHILD_ENV] === '1') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
const args = parseCliArgs(argv);
|
||||
return args.help && !shouldStartApp(args);
|
||||
}
|
||||
|
||||
export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = { ...baseEnv };
|
||||
if (!env.NODE_NO_WARNINGS) {
|
||||
env.NODE_NO_WARNINGS = '1';
|
||||
}
|
||||
removeLsfgLayer(env);
|
||||
return env;
|
||||
}
|
||||
|
||||
export function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = sanitizeHelpEnv(baseEnv);
|
||||
env[BACKGROUND_CHILD_ENV] = '1';
|
||||
return env;
|
||||
}
|
||||
@@ -1,26 +1,13 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { printHelp } from './cli/help';
|
||||
import {
|
||||
sanitizeBackgroundEnv,
|
||||
sanitizeHelpEnv,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
const BACKGROUND_ARG = '--background';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
|
||||
function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
if (!argv.includes(BACKGROUND_ARG)) return false;
|
||||
if (env[BACKGROUND_CHILD_ENV] === '1') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = { ...baseEnv };
|
||||
env[BACKGROUND_CHILD_ENV] = '1';
|
||||
if (!env.NODE_NO_WARNINGS) {
|
||||
env.NODE_NO_WARNINGS = '1';
|
||||
}
|
||||
if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) {
|
||||
delete env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
|
||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||
@@ -32,4 +19,14 @@ if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeHelpEnv(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
printHelp(DEFAULT_TEXTHOOKER_PORT);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
require('./main.js');
|
||||
|
||||
@@ -1498,6 +1498,10 @@ const {
|
||||
listJellyfinItemsRuntime(session, clientInfo, params),
|
||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||
listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId),
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
},
|
||||
logInfo: (message) => logger.info(message),
|
||||
},
|
||||
handleJellyfinPlayCommandMainDeps: {
|
||||
|
||||
@@ -111,6 +111,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
},
|
||||
handleJellyfinPlayCommandMainDeps: {
|
||||
|
||||
@@ -24,6 +24,7 @@ test('list handler no-ops when no list command is set', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -47,6 +48,7 @@ test('list handler logs libraries', async () => {
|
||||
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -67,14 +69,19 @@ test('list handler logs libraries', async () => {
|
||||
|
||||
test('list handler resolves items using default library id', async () => {
|
||||
let usedLibraryId = '';
|
||||
let usedRecursive: boolean | undefined;
|
||||
let usedIncludeItemTypes: string | undefined;
|
||||
const logs: string[] = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async (_session, _clientInfo, params) => {
|
||||
usedLibraryId = params.libraryId;
|
||||
usedRecursive = params.recursive;
|
||||
usedIncludeItemTypes = params.includeItemTypes;
|
||||
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
|
||||
},
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -86,6 +93,8 @@ test('list handler resolves items using default library id', async () => {
|
||||
jellyfinLibraryId: '',
|
||||
jellyfinSearch: 'episode',
|
||||
jellyfinLimit: 10,
|
||||
jellyfinRecursive: false,
|
||||
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
@@ -96,6 +105,8 @@ test('list handler resolves items using default library id', async () => {
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(usedLibraryId, 'default-lib');
|
||||
assert.equal(usedRecursive, false);
|
||||
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
|
||||
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
|
||||
});
|
||||
|
||||
@@ -104,6 +115,7 @@ test('list handler throws when items command has no library id', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -132,6 +144,7 @@ test('list handler logs subtitle urls only when requested', async () => {
|
||||
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
|
||||
{ index: 2, language: 'jpn' },
|
||||
],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -157,6 +170,7 @@ test('list handler throws when subtitle command has no item id', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -174,3 +188,65 @@ test('list handler throws when subtitle command has no item id', async () => {
|
||||
/Missing --jellyfin-item-id/,
|
||||
);
|
||||
});
|
||||
|
||||
test('list handler writes preview auth payload to response path', async () => {
|
||||
const writes: Array<{
|
||||
path: string;
|
||||
payload: { serverUrl: string; accessToken: string; userId: string };
|
||||
}> = [];
|
||||
const logs: string[] = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
writes.push({ path: responsePath, payload });
|
||||
},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
const handled = await handler({
|
||||
args: {
|
||||
jellyfinPreviewAuth: true,
|
||||
jellyfinResponsePath: '/tmp/subminer-preview-auth.json',
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: baseConfig,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
path: '/tmp/subminer-preview-auth.json',
|
||||
payload: {
|
||||
serverUrl: baseSession.serverUrl,
|
||||
accessToken: baseSession.accessToken,
|
||||
userId: baseSession.userId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(logs, ['Jellyfin preview auth written.']);
|
||||
});
|
||||
|
||||
test('list handler throws when preview auth command has no response path', async () => {
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
handler({
|
||||
args: {
|
||||
jellyfinPreviewAuth: true,
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: baseConfig,
|
||||
}),
|
||||
/Missing --jellyfin-response-path/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ type JellyfinConfig = {
|
||||
defaultLibraryId: string;
|
||||
};
|
||||
|
||||
type JellyfinPreviewAuthPayload = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export function createHandleJellyfinListCommands(deps: {
|
||||
listJellyfinLibraries: (
|
||||
session: JellyfinSession,
|
||||
@@ -25,7 +31,13 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
listJellyfinItems: (
|
||||
session: JellyfinSession,
|
||||
clientInfo: JellyfinClientInfo,
|
||||
params: { libraryId: string; searchTerm?: string; limit: number },
|
||||
params: {
|
||||
libraryId: string;
|
||||
searchTerm?: string;
|
||||
limit: number;
|
||||
recursive?: boolean;
|
||||
includeItemTypes?: string;
|
||||
},
|
||||
) => Promise<Array<{ id: string; title: string; type: string }>>;
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: JellyfinSession,
|
||||
@@ -42,8 +54,9 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
isForced?: boolean;
|
||||
isExternal?: boolean;
|
||||
deliveryUrl?: string | null;
|
||||
}>
|
||||
}>
|
||||
>;
|
||||
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
@@ -54,6 +67,20 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
}): Promise<boolean> => {
|
||||
const { args, session, clientInfo, jellyfinConfig } = params;
|
||||
|
||||
if (args.jellyfinPreviewAuth) {
|
||||
const responsePath = args.jellyfinResponsePath?.trim();
|
||||
if (!responsePath) {
|
||||
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
|
||||
}
|
||||
deps.writeJellyfinPreviewAuth(responsePath, {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
});
|
||||
deps.logInfo('Jellyfin preview auth written.');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.jellyfinLibraries) {
|
||||
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
|
||||
if (libraries.length === 0) {
|
||||
@@ -79,6 +106,8 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
libraryId,
|
||||
searchTerm: args.jellyfinSearch,
|
||||
limit: args.jellyfinLimit ?? 100,
|
||||
recursive: args.jellyfinRecursive,
|
||||
includeItemTypes: args.jellyfinIncludeItemTypes,
|
||||
});
|
||||
if (items.length === 0) {
|
||||
deps.logInfo('No Jellyfin items found for the selected library/search.');
|
||||
|
||||
@@ -31,6 +31,10 @@ test('jellyfin auth commands main deps builder maps callbacks', async () => {
|
||||
|
||||
test('jellyfin list commands main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const writes: Array<{
|
||||
responsePath: string;
|
||||
payload: { serverUrl: string; accessToken: string; userId: string };
|
||||
}> = [];
|
||||
const deps = createBuildHandleJellyfinListCommandsMainDepsHandler({
|
||||
listJellyfinLibraries: async () => {
|
||||
calls.push('libraries');
|
||||
@@ -44,14 +48,32 @@ test('jellyfin list commands main deps builder maps callbacks', async () => {
|
||||
calls.push('subtitles');
|
||||
return [];
|
||||
},
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
writes.push({ responsePath, payload });
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
})();
|
||||
|
||||
await deps.listJellyfinLibraries({} as never, {} as never);
|
||||
await deps.listJellyfinItems({} as never, {} as never, { libraryId: '', limit: 1 });
|
||||
await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'id');
|
||||
deps.writeJellyfinPreviewAuth('/tmp/jellyfin-preview.json', {
|
||||
serverUrl: 'https://example.test',
|
||||
accessToken: 'token',
|
||||
userId: 'user-id',
|
||||
});
|
||||
deps.logInfo('done');
|
||||
assert.deepEqual(calls, ['libraries', 'items', 'subtitles', 'info:done']);
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
responsePath: '/tmp/jellyfin-preview.json',
|
||||
payload: {
|
||||
serverUrl: 'https://example.test',
|
||||
accessToken: 'token',
|
||||
userId: 'user-id',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('jellyfin play command main deps builder maps callbacks', async () => {
|
||||
|
||||
@@ -32,6 +32,8 @@ export function createBuildHandleJellyfinListCommandsMainDepsHandler(
|
||||
deps.listJellyfinItems(session, clientInfo, params),
|
||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) =>
|
||||
deps.writeJellyfinPreviewAuth(responsePath, payload),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
});
|
||||
}
|
||||
|
||||
149
src/renderer/modals/jimaku.test.ts
Normal file
149
src/renderer/modals/jimaku.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -234,6 +234,7 @@ export function createJimakuModal(
|
||||
|
||||
if (result.ok) {
|
||||
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
||||
closeJimakuModal();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
226
src/renderer/modals/subsync.test.ts
Normal file
226
src/renderer/modals/subsync.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user