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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user