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

@@ -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;

View 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);
});

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false,
help: false,
autoStartOverlay: false,

View File

@@ -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 () =>

View File

@@ -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>(

View 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',
);
});

View 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}`;
}

View File

@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false,
help: false,
autoStartOverlay: false,