diff --git a/backlog/tasks/task-79 - Jimaku-modal-auto-close-after-successful-subtitle-load.md b/backlog/tasks/task-79 - Jimaku-modal-auto-close-after-successful-subtitle-load.md new file mode 100644 index 0000000..8eee468 --- /dev/null +++ b/backlog/tasks/task-79 - Jimaku-modal-auto-close-after-successful-subtitle-load.md @@ -0,0 +1,48 @@ +--- +id: TASK-79 +title: 'Jimaku modal: auto-close after successful subtitle load' +status: Done +assignee: [] +created_date: '2026-03-01 13:52' +updated_date: '2026-03-01 14:06' +labels: [] +dependencies: [] +priority: medium +ordinal: 10000 +--- + +## Description + + + +Fix Jimaku modal UX so selecting a subtitle file closes the modal automatically once subtitle download+load succeeds. + +Current behavior: +- Subtitle file downloads and loads into mpv. +- Jimaku modal remains open until manual close. + +Expected behavior: +- On successful `jimakuDownloadFile` result, close modal immediately. +- Keep error behavior unchanged (stay open + show error). + + + +## Acceptance Criteria + + + +- [x] #1 Successful subtitle file selection/download in Jimaku closes modal automatically. +- [x] #2 Existing error path keeps modal open and shows error. +- [x] #3 Regression test covers success auto-close behavior. + + + +## Final Summary + + + +Fixed renderer Jimaku success flow to close modal immediately after successful `jimakuDownloadFile` result. Added regression test (`src/renderer/modals/jimaku.test.ts`) that reproduces keyboard file-selection success path and asserts modal close state + `notifyOverlayModalClosed('jimaku')` emission. Kept failure path unchanged. + +Also wired new test into `test:core:src` and `test:core:dist` package scripts. + + diff --git a/backlog/tasks/task-80 - Jimaku-download-rename-subtitle-to-match-current-video.md b/backlog/tasks/task-80 - Jimaku-download-rename-subtitle-to-match-current-video.md new file mode 100644 index 0000000..ab3e5fe --- /dev/null +++ b/backlog/tasks/task-80 - Jimaku-download-rename-subtitle-to-match-current-video.md @@ -0,0 +1,48 @@ +--- +id: TASK-80 +title: 'Jimaku download: rename subtitle to current video basename' +status: Done +assignee: [] +created_date: '2026-03-01 14:17' +updated_date: '2026-03-01 14:19' +labels: [] +dependencies: [] +priority: medium +ordinal: 11000 +--- + +## Description + + + +When user selects a Jimaku subtitle, save subtitle with filename derived from currently playing media filename instead of Jimaku release filename. + +Example: +- Current media: `anime.mkv` +- Downloaded subtitle extension: `.srt` +- Saved subtitle path: `anime.ja.srt` + +Scope: +- Apply in Jimaku download IPC path before writing file. +- Preserve collision-avoidance behavior (suffix with jimaku entry id/counter when target exists). +- Keep mpv load flow unchanged except using renamed path. + + + +## Acceptance Criteria + + + +- [x] #1 Jimaku subtitle destination name uses current media basename plus `.ja` and subtitle extension. +- [x] #2 Existing duplicate filename conflict handling still works. +- [x] #3 Regression tests cover renamed destination path behavior. + + + +## Final Summary + + + +Jimaku download path generation now derives subtitle filename from currently playing media basename and keeps subtitle extension from Jimaku file (`anime.mkv` + `.srt` => `anime.ja.srt`). Added pure helper `buildJimakuSubtitleFilenameFromMediaPath` and routed IPC download flow through it before existing duplicate-path conflict handling. Added regression tests for local path, missing extension fallback, and remote URL media paths. + + diff --git a/docs/configuration.md b/docs/configuration.md index 863b5f2..dcea8da 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -884,7 +884,7 @@ Launcher subcommands: - `subminer jellyfin -l --server ... --username ... --password ...` logs in. - `subminer jellyfin --logout` clears stored credentials. - `subminer jellyfin -p` opens play picker. -- `subminer jellyfin -d` starts cast discovery mode. +- `subminer jellyfin -d` starts cast discovery mode in background/tray mode. - These launcher commands also accept `--password-store=` to override the launcher-app forwarded Electron switch. See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide. diff --git a/docs/jellyfin-integration.md b/docs/jellyfin-integration.md index 6073435..15cbddb 100644 --- a/docs/jellyfin-integration.md +++ b/docs/jellyfin-integration.md @@ -60,12 +60,18 @@ Launcher wrapper equivalent for interactive playback flow: subminer jellyfin -p ``` -Launcher wrapper for Jellyfin cast discovery mode (foreground app process): +Launcher wrapper for Jellyfin cast discovery mode (background app + tray): ```bash subminer jellyfin -d ``` +Stop discovery session/app: + +```bash +subminer app --stop +``` + `subminer jf ...` is an alias for `subminer jellyfin ...`. To clear saved session credentials: @@ -80,6 +86,17 @@ subminer jellyfin --logout SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term ``` +Optional listing controls: + +- `--jellyfin-recursive=true|false` (default: true) +- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...` + +These are used by the launcher picker flow to: + +- keep root search focused on shows/folders/movies (exclude episode rows) +- browse selected anime/show directories as folder-or-file lists +- recurse for playable files only after selecting a folder + 5. Start playback: ```bash diff --git a/docs/usage.md b/docs/usage.md index f971976..50b12f6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -55,7 +55,8 @@ subminer jellyfin # Open Jellyfin setup window (subcommand form) subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret' subminer jellyfin --logout # Clear stored Jellyfin token/session data subminer jellyfin -p # Interactive Jellyfin library/item picker + playback -subminer jellyfin -d # Jellyfin cast-discovery mode (foreground app) +subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app) +subminer app --stop # Stop background app (including Jellyfin cast broadcast) subminer doctor # Dependency + config + socket diagnostics subminer config path # Print active config path subminer config show # Print active config contents diff --git a/launcher/commands/jellyfin-command.ts b/launcher/commands/jellyfin-command.ts index 4c2a37b..e359fef 100644 --- a/launcher/commands/jellyfin-command.ts +++ b/launcher/commands/jellyfin-command.ts @@ -65,7 +65,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi } if (args.jellyfinDiscovery) { - const forwarded = ['--start']; + const forwarded = ['--background', '--jellyfin-remote-announce']; if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts index e1d0a04..1bcedce 100644 --- a/launcher/jellyfin.ts +++ b/launcher/jellyfin.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import fs from 'node:fs'; +import os from 'node:os'; import { spawnSync } from 'node:child_process'; import type { Args, @@ -8,8 +9,8 @@ import type { JellyfinItemEntry, JellyfinGroupEntry, } from './types.js'; -import { log, fail } from './log.js'; -import { commandExists, resolvePathMaybe } from './util.js'; +import { log, fail, getMpvLogPath } from './log.js'; +import { commandExists, resolvePathMaybe, sleep } from './util.js'; import { pickLibrary, pickItem, @@ -18,12 +19,17 @@ import { findRofiTheme, } from './picker.js'; import { loadLauncherJellyfinConfig } from './config.js'; +import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js'; import { runAppCommandWithInheritLogged, + runAppCommandCaptureOutput, + launchAppStartDetached, launchMpvIdleDetached, waitForUnixSocketReady, } from './mpv.js'; +const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g; + export function sanitizeServerUrl(value: string): string { return value.trim().replace(/\/+$/, ''); } @@ -114,6 +120,605 @@ export function formatJellyfinItemDisplay(item: Record): string return `${name} (${type})`; } +function stripAnsi(value: string): string { + return value.replace(ANSI_ESCAPE_PATTERN, ''); +} + +function parseNamedJellyfinRecord(payload: string): { + name: string; + id: string; + type: string; +} | null { + const typeClose = payload.lastIndexOf(')'); + if (typeClose !== payload.length - 1) return null; + + const typeOpen = payload.lastIndexOf(' ('); + if (typeOpen <= 0 || typeOpen >= typeClose) return null; + + const idClose = payload.lastIndexOf(']', typeOpen); + if (idClose <= 0) return null; + + const idOpen = payload.lastIndexOf(' [', idClose); + if (idOpen <= 0 || idOpen >= idClose) return null; + + const name = payload.slice(0, idOpen).trim(); + const id = payload.slice(idOpen + 2, idClose).trim(); + const type = payload.slice(typeOpen + 2, typeClose).trim(); + if (!name || !id || !type) return null; + + return { name, id, type }; +} + +export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] { + const libraries: JellyfinLibraryEntry[] = []; + const seenIds = new Set(); + + for (const rawLine of output.split(/\r?\n/)) { + const line = stripAnsi(rawLine); + const markerIndex = line.indexOf('Jellyfin library:'); + if (markerIndex < 0) continue; + const payload = line.slice(markerIndex + 'Jellyfin library:'.length).trim(); + const parsed = parseNamedJellyfinRecord(payload); + if (!parsed || seenIds.has(parsed.id)) continue; + seenIds.add(parsed.id); + libraries.push({ + id: parsed.id, + name: parsed.name, + kind: parsed.type, + }); + } + + return libraries; +} + +export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] { + const items: JellyfinItemEntry[] = []; + const seenIds = new Set(); + + for (const rawLine of output.split(/\r?\n/)) { + const line = stripAnsi(rawLine); + const markerIndex = line.indexOf('Jellyfin item:'); + if (markerIndex < 0) continue; + const payload = line.slice(markerIndex + 'Jellyfin item:'.length).trim(); + const parsed = parseNamedJellyfinRecord(payload); + if (!parsed || seenIds.has(parsed.id)) continue; + seenIds.add(parsed.id); + items.push({ + id: parsed.id, + name: parsed.name, + type: parsed.type, + display: parsed.name, + }); + } + + return items; +} + +export function parseJellyfinErrorFromAppOutput(output: string): string { + const lines = output.split(/\r?\n/).map((line) => stripAnsi(line).trim()); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + if (!line) continue; + + const bracketedErrorIndex = line.indexOf('[ERROR]'); + if (bracketedErrorIndex >= 0) { + const message = line.slice(bracketedErrorIndex + '[ERROR]'.length).trim(); + if (message.length > 0) return message; + } + + const mainErrorIndex = line.indexOf(' - ERROR - '); + if (mainErrorIndex >= 0) { + const message = line.slice(mainErrorIndex + ' - ERROR - '.length).trim(); + if (message.length > 0) return message; + } + + if (line.includes('Missing Jellyfin session')) { + return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.'; + } + } + return ''; +} + +type JellyfinPreviewAuthResponse = { + serverUrl: string; + accessToken: string; + userId: string; +}; + +export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAuthResponse | null { + if (!raw || raw.trim().length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') return null; + + const candidate = parsed as Record; + const serverUrl = sanitizeServerUrl( + typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '', + ); + const accessToken = + typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : ''; + const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : ''; + if (!serverUrl || !accessToken) return null; + + return { + serverUrl, + accessToken, + userId, + }; +} + +export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean { + return errorMessage.includes('No running instance. Use --start to launch the app.'); +} + +export function deriveJellyfinTokenStorePath(configPath: string): string { + return path.join(path.dirname(configPath), 'jellyfin-token-store.json'); +} + +export function hasStoredJellyfinSession( + configPath: string, + exists: (candidate: string) => boolean = fs.existsSync, +): boolean { + return exists(deriveJellyfinTokenStorePath(configPath)); +} + +export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number): string { + try { + const buffer = fs.readFileSync(logPath); + if (buffer.length === 0) return ''; + const normalizedOffset = + Number.isFinite(offsetBytes) && offsetBytes >= 0 + ? Math.floor(offsetBytes) + : 0; + const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset; + return buffer.subarray(startOffset).toString('utf8'); + } catch { + return ''; + } +} + +export function parseEpisodePathFromDisplay( + display: string, +): { seriesName: string; seasonNumber: number } | null { + const normalized = display.trim().replace(/\s+/g, ' '); + const match = normalized.match(/^(.*?)\s+S(\d{1,2})E\d{1,3}\b/i); + if (!match) return null; + const seriesName = match[1].trim(); + const seasonNumber = Number.parseInt(match[2], 10); + if (!seriesName || !Number.isFinite(seasonNumber) || seasonNumber < 0) return null; + return { seriesName, seasonNumber }; +} + +function normalizeJellyfinType(type: string): string { + return type.trim().toLowerCase(); +} + +export function isJellyfinPlayableType(type: string): boolean { + const normalizedType = normalizeJellyfinType(type); + return ( + normalizedType === 'movie' || + normalizedType === 'episode' || + normalizedType === 'audio' || + normalizedType === 'video' || + normalizedType === 'musicvideo' + ); +} + +export function isJellyfinContainerType(type: string): boolean { + const normalizedType = normalizeJellyfinType(type); + return ( + normalizedType === 'series' || + normalizedType === 'season' || + normalizedType === 'folder' || + normalizedType === 'collectionfolder' + ); +} + +function isJellyfinRootSearchType(type: string): boolean { + const normalizedType = normalizeJellyfinType(type); + return ( + isJellyfinContainerType(normalizedType) || + normalizedType === 'movie' || + normalizedType === 'video' || + normalizedType === 'musicvideo' + ); +} + +export function buildRootSearchGroups(items: JellyfinItemEntry[]): JellyfinGroupEntry[] { + const seenIds = new Set(); + const groups: JellyfinGroupEntry[] = []; + for (const item of items) { + if (!item.id || seenIds.has(item.id) || !isJellyfinRootSearchType(item.type)) continue; + seenIds.add(item.id); + groups.push({ + id: item.id, + name: item.name, + type: item.type, + display: `${item.name} (${item.type})`, + }); + } + return groups; +} + +export type JellyfinChildSelection = + | { kind: 'playable'; id: string } + | { kind: 'container'; id: string }; + +export function classifyJellyfinChildSelection( + selectedChild: Pick, +): JellyfinChildSelection { + if (isJellyfinPlayableType(selectedChild.type)) { + return { kind: 'playable', id: selectedChild.id }; + } + if (isJellyfinContainerType(selectedChild.type)) { + return { kind: 'container', id: selectedChild.id }; + } + fail('Selected Jellyfin item is not playable.'); +} + +async function runAppJellyfinListCommand( + appPath: string, + args: Args, + appArgs: string[], + label: string, +): Promise { + const attempt = await runAppJellyfinCommand(appPath, args, appArgs, label); + if (attempt.status !== 0) { + const message = attempt.output.trim(); + fail(message || `${label} failed.`); + } + if (attempt.error) { + fail(attempt.error); + } + return attempt.output; +} + +async function runAppJellyfinCommand( + appPath: string, + args: Args, + appArgs: string[], + label: string, +): Promise<{ status: number; output: string; error: string; logOffset: number }> { + const forwardedBase = [...appArgs]; + const serverOverride = sanitizeServerUrl(args.jellyfinServer || ''); + if (serverOverride) { + forwardedBase.push('--jellyfin-server', serverOverride); + } + if (args.passwordStore) { + forwardedBase.push('--password-store', args.passwordStore); + } + + const readLogAppendedSince = (offset: number): string => { + const logPath = getMpvLogPath(); + return readUtf8FileAppendedSince(logPath, offset); + }; + + const hasCommandSignal = (output: string): boolean => { + if (label === 'jellyfin-libraries') { + return output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.'); + } + if (label === 'jellyfin-items') { + return ( + output.includes('Jellyfin item:') || + output.includes('No Jellyfin items found for the selected library/search.') + ); + } + if (label === 'jellyfin-preview-auth') { + return output.includes('Jellyfin preview auth written.'); + } + return output.trim().length > 0; + }; + + const runOnce = (): { status: number; output: string; error: string; logOffset: number } => { + const forwarded = [...forwardedBase]; + const logPath = getMpvLogPath(); + let logOffset = 0; + try { + if (fs.existsSync(logPath)) { + logOffset = fs.statSync(logPath).size; + } + } catch { + logOffset = 0; + } + log('debug', args.logLevel, `${label}: launching app with args: ${forwarded.join(' ')}`); + const result = runAppCommandCaptureOutput(appPath, forwarded); + log('debug', args.logLevel, `${label}: app command exited with status ${result.status}`); + let output = `${result.stdout || ''}\n${result.stderr || ''}\n${readLogAppendedSince(logOffset)}`; + let error = parseJellyfinErrorFromAppOutput(output); + + return { status: result.status, output, error, logOffset }; + }; + + let retriedAfterStart = false; + let attempt = runOnce(); + if (shouldRetryWithStartForNoRunningInstance(attempt.error)) { + log('debug', args.logLevel, `${label}: starting app detached, then retrying command`); + launchAppStartDetached(appPath, args.logLevel); + await sleep(1000); + retriedAfterStart = true; + attempt = runOnce(); + } + + if (attempt.status === 0 && !attempt.error && !hasCommandSignal(attempt.output)) { + // When app is already running, command handling happens in the primary process and log + // lines can land slightly after the helper process exits. + const settleWindowMs = (() => { + if (label === 'jellyfin-items') { + return retriedAfterStart ? 45000 : 30000; + } + return retriedAfterStart ? 12000 : 4000; + })(); + const settleDeadline = Date.now() + settleWindowMs; + const settleOffset = attempt.logOffset; + while (Date.now() < settleDeadline) { + await sleep(100); + const settledOutput = readLogAppendedSince(settleOffset); + if (!settledOutput.trim()) { + continue; + } + attempt.output = `${attempt.output}\n${settledOutput}`; + attempt.error = parseJellyfinErrorFromAppOutput(attempt.output); + if (attempt.error || hasCommandSignal(attempt.output)) { + break; + } + } + } + + return attempt; +} + +async function requestJellyfinPreviewAuthFromApp( + appPath: string, + args: Args, +): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-preview-auth-')); + const responsePath = path.join(tmpDir, 'response.json'); + try { + const attempt = await runAppJellyfinCommand( + appPath, + args, + ['--jellyfin-preview-auth', `--jellyfin-response-path=${responsePath}`], + 'jellyfin-preview-auth', + ); + if (attempt.status !== 0 || attempt.error) { + return null; + } + + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + try { + if (fs.existsSync(responsePath)) { + const raw = fs.readFileSync(responsePath, 'utf8'); + const parsed = parseJellyfinPreviewAuthResponse(raw); + if (parsed) { + return parsed; + } + } + } catch { + // retry until timeout + } + await sleep(100); + } + return null; + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } + } +} + +async function resolveJellyfinSelectionViaApp( + appPath: string, + args: Args, + session: JellyfinSessionConfig, + themePath: string | null = null, +): Promise { + const listLibrariesOutput = await runAppJellyfinListCommand( + appPath, + args, + ['--jellyfin-libraries'], + 'jellyfin-libraries', + ); + const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput); + if (libraries.length === 0) { + fail('No Jellyfin libraries found.'); + } + + const iconlessSession: JellyfinSessionConfig = { + ...session, + userId: session.userId || 'launcher', + }; + const noIcon = (): string | null => null; + const hasPreviewSession = Boolean(iconlessSession.serverUrl && iconlessSession.accessToken); + const pickerSession: JellyfinSessionConfig = { + ...iconlessSession, + pullPictures: hasPreviewSession && iconlessSession.pullPictures === true, + }; + const ensureIconForPicker = hasPreviewSession ? ensureJellyfinIcon : noIcon; + if (!hasPreviewSession) { + log( + 'debug', + args.logLevel, + 'Jellyfin picker image previews disabled (no launcher-accessible Jellyfin token).', + ); + } + + const configuredDefaultLibraryId = session.defaultLibraryId; + const hasConfiguredDefault = libraries.some((library) => library.id === configuredDefaultLibraryId); + let libraryId = hasConfiguredDefault ? configuredDefaultLibraryId : ''; + if (!libraryId) { + libraryId = pickLibrary( + pickerSession, + libraries, + args.useRofi, + ensureIconForPicker, + '', + themePath, + ); + if (!libraryId) fail('No Jellyfin library selected.'); + } + + const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath); + const normalizedSearch = searchTerm.trim(); + const searchLimit = 400; + const browseLimit = 2500; + const rootIncludeItemTypes = 'Series,Season,Folder,CollectionFolder,Movie,Video,MusicVideo'; + const directoryIncludeItemTypes = + 'Series,Season,Folder,CollectionFolder,Movie,Episode,Audio,Video,MusicVideo'; + const recursivePlayableIncludeItemTypes = 'Movie,Episode,Audio,Video,MusicVideo'; + const listItemsViaApp = async ( + parentId: string, + options: { + search?: string; + limit: number; + recursive?: boolean; + includeItemTypes?: string; + }, + ): Promise => { + const itemArgs = [ + '--jellyfin-items', + `--jellyfin-library-id=${parentId}`, + `--jellyfin-limit=${Math.max(1, options.limit)}`, + ]; + const normalized = (options.search || '').trim(); + if (normalized.length > 0) { + itemArgs.push(`--jellyfin-search=${normalized}`); + } + if (typeof options.recursive === 'boolean') { + itemArgs.push(`--jellyfin-recursive=${options.recursive ? 'true' : 'false'}`); + } + const includeItemTypes = options.includeItemTypes?.trim(); + if (includeItemTypes) { + itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`); + } + const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items'); + return parseJellyfinItemsFromAppOutput(output); + }; + + let rootItems = + normalizedSearch.length > 0 + ? await listItemsViaApp(libraryId, { + search: normalizedSearch, + limit: searchLimit, + recursive: true, + includeItemTypes: rootIncludeItemTypes, + }) + : await listItemsViaApp(libraryId, { + limit: browseLimit, + recursive: false, + includeItemTypes: rootIncludeItemTypes, + }); + if (normalizedSearch.length > 0 && rootItems.length === 0) { + // Compatibility fallback for older app binaries that may ignore custom search include types. + log( + 'debug', + args.logLevel, + `jellyfin-items: no direct search hits for "${normalizedSearch}", falling back to unfiltered library query`, + ); + rootItems = await listItemsViaApp(libraryId, { + limit: browseLimit, + recursive: false, + includeItemTypes: rootIncludeItemTypes, + }); + } + const rootGroups = buildRootSearchGroups(rootItems); + if (rootGroups.length === 0) { + fail('No Jellyfin shows or movies found.'); + } + + const rootById = new Map(rootGroups.map((group) => [group.id, group])); + const selectedRootId = pickGroup( + pickerSession, + rootGroups, + args.useRofi, + ensureIconForPicker, + normalizedSearch, + themePath, + ); + if (!selectedRootId) fail('No Jellyfin show/movie selected.'); + const selectedRoot = rootById.get(selectedRootId); + if (!selectedRoot) fail('Invalid Jellyfin root selection.'); + + if (isJellyfinPlayableType(selectedRoot.type)) { + return selectedRoot.id; + } + + const pickPlayableDescendants = async (parentId: string): Promise => { + const descendantItems = await listItemsViaApp(parentId, { + limit: browseLimit, + recursive: true, + includeItemTypes: recursivePlayableIncludeItemTypes, + }); + const playableItems = descendantItems.filter((item) => isJellyfinPlayableType(item.type)); + if (playableItems.length === 0) { + fail('No playable Jellyfin items found.'); + } + const selectedItemId = pickItem( + pickerSession, + playableItems, + args.useRofi, + ensureIconForPicker, + '', + themePath, + ); + if (!selectedItemId) { + fail('No Jellyfin item selected.'); + } + return selectedItemId; + }; + + let currentContainerId = selectedRoot.id; + while (true) { + const directoryEntries = await listItemsViaApp(currentContainerId, { + limit: browseLimit, + recursive: false, + includeItemTypes: directoryIncludeItemTypes, + }); + + const seenIds = new Set(); + const childGroups: JellyfinGroupEntry[] = []; + for (const item of directoryEntries) { + if (!item.id || seenIds.has(item.id)) continue; + if (!isJellyfinContainerType(item.type) && !isJellyfinPlayableType(item.type)) continue; + seenIds.add(item.id); + childGroups.push({ + id: item.id, + name: item.name, + type: item.type, + display: `${item.name} (${item.type})`, + }); + } + + if (childGroups.length === 0) { + return await pickPlayableDescendants(currentContainerId); + } + + const childById = new Map(childGroups.map((group) => [group.id, group])); + const selectedChildId = pickGroup( + pickerSession, + childGroups, + args.useRofi, + ensureIconForPicker, + '', + themePath, + ); + if (!selectedChildId) fail('No Jellyfin folder/file selected.'); + const selectedChild = childById.get(selectedChildId); + if (!selectedChild) fail('Invalid Jellyfin item selection.'); + const selection = classifyJellyfinChildSelection(selectedChild); + if (selection.kind === 'playable') { + return selection.id; + } + currentContainerId = selection.id; + } +} + export async function resolveJellyfinSelection( args: Args, session: JellyfinSessionConfig, @@ -367,18 +972,37 @@ export async function runJellyfinPlayMenu( iconCacheDir: config.iconCacheDir || '', }; - if (!session.serverUrl || !session.accessToken || !session.userId) { - fail( - 'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.', - ); - } - const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null; if (args.useRofi && !rofiTheme) { log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.'); } - const itemId = await resolveJellyfinSelection(args, session, rofiTheme); + const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId); + let itemId = ''; + if (hasDirectSession) { + itemId = await resolveJellyfinSelection(args, session, rofiTheme); + } else { + const configPath = resolveLauncherMainConfigPath(); + if (!hasStoredJellyfinSession(configPath)) { + fail( + 'Missing Jellyfin session. Run `subminer jellyfin -l --server --username --password ` first.', + ); + } + const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args); + if (previewAuth) { + session.serverUrl = previewAuth.serverUrl || session.serverUrl; + session.accessToken = previewAuth.accessToken; + session.userId = previewAuth.userId || session.userId; + log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.'); + } else { + log( + 'debug', + args.logLevel, + 'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.', + ); + } + itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme); + } log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`); log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`); let mpvReady = false; @@ -393,7 +1017,7 @@ export async function runJellyfinPlayMenu( if (!mpvReady) { fail(`MPV IPC socket not ready: ${mpvSocketPath}`); } - const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId]; + const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`]; if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.passwordStore) forwarded.push('--password-store', args.passwordStore); runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 0a98632..513c043 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -5,6 +5,19 @@ import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { resolveConfigFilePath } from '../src/config/path-resolution.js'; +import { + parseJellyfinLibrariesFromAppOutput, + parseJellyfinItemsFromAppOutput, + parseJellyfinErrorFromAppOutput, + parseJellyfinPreviewAuthResponse, + deriveJellyfinTokenStorePath, + hasStoredJellyfinSession, + shouldRetryWithStartForNoRunningInstance, + readUtf8FileAppendedSince, + parseEpisodePathFromDisplay, + buildRootSearchGroups, + classifyJellyfinChildSelection, +} from './jellyfin.js'; type RunResult = { status: number | null; @@ -149,7 +162,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () => }); }); -test('jellyfin discovery routes to app --start with log-level forwarding', () => { +test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); const xdgConfigHome = path.join(root, 'xdg'); @@ -169,7 +182,37 @@ test('jellyfin discovery routes to app --start with log-level forwarding', () => const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env); assert.equal(result.status, 0); - assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n'); + assert.equal( + fs.readFileSync(capturePath, 'utf8'), + '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n', + ); + }); +}); + +test('jellyfin discovery via jf alias forwards remote announce for cast visibility', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const appPath = path.join(root, 'fake-subminer.sh'); + const capturePath = path.join(root, 'captured-args.txt'); + fs.writeFileSync( + appPath, + '#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', + ); + fs.chmodSync(appPath, 0o755); + + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_CAPTURE: capturePath, + }; + const result = runLauncher(['-R', 'jf', '--discovery', '--log-level', 'debug'], env); + + assert.equal(result.status, 0); + assert.equal( + fs.readFileSync(capturePath, 'utf8'), + '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n', + ); }); }); @@ -238,3 +281,174 @@ test('jellyfin setup forwards password-store to app command', () => { ); }); }); + +test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => { + const parsed = parseJellyfinLibrariesFromAppOutput(` +[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows) +[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies) +`); + + assert.deepEqual(parsed, [ + { id: 'lib1', name: 'Anime', kind: 'tvshows' }, + { id: 'lib2', name: 'Movies', kind: 'movies' }, + ]); +}); + +test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => { + const parsed = parseJellyfinItemsFromAppOutput(` +[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode) +[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie) +`); + + assert.deepEqual(parsed, [ + { + id: 'item-10', + name: 'Solo Leveling S01E10', + type: 'Episode', + display: 'Solo Leveling S01E10', + }, + { + id: 'movie-1', + name: 'Movie [Alt]', + type: 'Movie', + display: 'Movie [Alt]', + }, + ]); +}); + +test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => { + const parsed = parseJellyfinErrorFromAppOutput(` +[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning +[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry. +`); + + assert.equal( + parsed, + 'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.', + ); +}); + +test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => { + const parsed = parseJellyfinErrorFromAppOutput(` +[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."} +`); + + assert.equal(parsed, '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}'); +}); + +test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => { + const parsed = parseJellyfinPreviewAuthResponse( + JSON.stringify({ + serverUrl: 'http://pve-main:8096/', + accessToken: 'token-123', + userId: 'user-1', + }), + ); + + assert.deepEqual(parsed, { + serverUrl: 'http://pve-main:8096', + accessToken: 'token-123', + userId: 'user-1', + }); +}); + +test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => { + assert.equal(parseJellyfinPreviewAuthResponse(''), null); + assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null); + assert.equal( + parseJellyfinPreviewAuthResponse( + JSON.stringify({ + serverUrl: 'http://pve-main:8096', + accessToken: '', + userId: 'user-1', + }), + ), + null, + ); +}); + +test('deriveJellyfinTokenStorePath resolves alongside config path', () => { + const tokenPath = deriveJellyfinTokenStorePath('/home/test/.config/SubMiner/config.jsonc'); + assert.equal(tokenPath, '/home/test/.config/SubMiner/jellyfin-token-store.json'); +}); + +test('hasStoredJellyfinSession checks token-store existence', () => { + const exists = (candidate: string): boolean => + candidate === '/home/test/.config/SubMiner/jellyfin-token-store.json'; + assert.equal(hasStoredJellyfinSession('/home/test/.config/SubMiner/config.jsonc', exists), true); + assert.equal(hasStoredJellyfinSession('/home/test/.config/Other/alt.jsonc', exists), false); +}); + +test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => { + assert.equal( + shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'), + true, + ); + assert.equal( + shouldRetryWithStartForNoRunningInstance('Missing Jellyfin session. Run --jellyfin-login first.'), + false, + ); +}); + +test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => { + withTempDir((root) => { + const logPath = path.join(root, 'SubMiner.log'); + const prefix = '[subminer] こんにちは\n'; + const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n'; + fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8'); + + const byteOffset = Buffer.byteLength(prefix, 'utf8'); + const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset); + assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/); + + const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999); + assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/); + }); +}); + +test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => { + assert.deepEqual(parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'), { + seriesName: 'KONOSUBA', + seasonNumber: 1, + }); + assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), { + seriesName: 'Frieren', + seasonNumber: 2, + }); +}); + +test('parseEpisodePathFromDisplay returns null for non-episode displays', () => { + assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null); + assert.equal(parseEpisodePathFromDisplay('Just A Name'), null); +}); + +test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => { + const groups = buildRootSearchGroups([ + { id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' }, + { id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' }, + { id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' }, + ]); + + assert.deepEqual(groups, [ + { + id: 'series-1', + name: 'The Eminence in Shadow', + type: 'Series', + display: 'The Eminence in Shadow (Series)', + }, + { + id: 'movie-1', + name: 'Spirited Away', + type: 'Movie', + display: 'Spirited Away (Movie)', + }, + ]); +}); + +test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => { + const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' }); + assert.deepEqual(next, { + kind: 'container', + id: 'season-2', + }); +}); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index a2743d5..5742343 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import net from 'node:net'; import { EventEmitter } from 'node:events'; import type { Args } from './types'; -import { startOverlay, state, waitForUnixSocketReady } from './mpv'; +import { runAppCommandCaptureOutput, startOverlay, state, waitForUnixSocketReady } from './mpv'; import * as mpvModule from './mpv'; function createTempSocketPath(): { dir: string; socketPath: string } { @@ -19,6 +19,18 @@ test('mpv module exposes only canonical socket readiness helper', () => { assert.equal('waitForSocket' in mpvModule, false); }); +test('runAppCommandCaptureOutput captures status and stdio', () => { + const result = runAppCommandCaptureOutput(process.execPath, [ + '-e', + 'process.stdout.write("stdout-line"); process.stderr.write("stderr-line");', + ]); + + assert.equal(result.status, 0); + assert.equal(result.stdout, 'stdout-line'); + assert.equal(result.stderr, 'stderr-line'); + assert.equal(result.error, undefined); +}); + test('waitForUnixSocketReady returns false when socket never appears', async () => { const { dir, socketPath } = createTempSocketPath(); try { diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 6abd753..bdcc3eb 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -658,6 +658,28 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): ne process.exit(result.status ?? 0); } +export function runAppCommandCaptureOutput( + appPath: string, + appArgs: string[], +): { + status: number; + stdout: string; + stderr: string; + error?: Error; +} { + const result = spawnSync(appPath, appArgs, { + env: buildAppEnv(), + encoding: 'utf8', + }); + + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error ?? undefined, + }; +} + export function runAppCommandWithInheritLogged( appPath: string, appArgs: string[], diff --git a/package.json b/package.json index 64f148a..cb1813b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subminer", - "version": "0.2.0", + "version": "0.2.1", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", @@ -23,8 +23,8 @@ "test:plugin:src": "lua scripts/test-plugin-start-gate.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", - "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", + "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 2d762b1..2351b4c 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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); diff --git a/src/cli/args.ts b/src/cli/args.ts index 752deb2..4ecd900 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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 && diff --git a/src/core/services/anki-jimaku-ipc.ts b/src/core/services/anki-jimaku-ipc.ts index e318750..3106fb2 100644 --- a/src/core/services/anki-jimaku-ipc.ts +++ b/src/core/services/anki-jimaku-ipc.ts @@ -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; diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts new file mode 100644 index 0000000..ebaa4b7 --- /dev/null +++ b/src/core/services/app-lifecycle.test.ts @@ -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 { + 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 = {}) { + 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); +}); diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index da4536a..83dbdfe 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -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(); diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 2bbd48b..b66894c 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -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 () => { diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 3935d24..7370c7a 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -39,6 +39,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { jellyfinSubtitleUrlsOnly: false, jellyfinPlay: false, jellyfinRemoteAnnounce: false, + jellyfinPreviewAuth: false, texthooker: false, help: false, autoStartOverlay: false, diff --git a/src/core/services/jellyfin.test.ts b/src/core/services/jellyfin.test.ts index 1c84bf9..99d039d 100644 --- a/src/core/services/jellyfin.test.ts +++ b/src/core/services/jellyfin.test.ts @@ -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 () => diff --git a/src/core/services/jellyfin.ts b/src/core/services/jellyfin.ts index 1c2a015..a9acaa8 100644 --- a/src/core/services/jellyfin.ts +++ b/src/core/services/jellyfin.ts @@ -370,21 +370,29 @@ export async function listItems( libraryId: string; searchTerm?: string; limit?: number; + recursive?: boolean; + includeItemTypes?: string; }, ): Promise> { 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( diff --git a/src/core/services/jimaku-download-path.test.ts b/src/core/services/jimaku-download-path.test.ts new file mode 100644 index 0000000..e9a1d83 --- /dev/null +++ b/src/core/services/jimaku-download-path.test.ts @@ -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', + ); +}); diff --git a/src/core/services/jimaku-download-path.ts b/src/core/services/jimaku-download-path.ts new file mode 100644 index 0000000..9ae1426 --- /dev/null +++ b/src/core/services/jimaku-download-path.ts @@ -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}`; +} diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index dff7c40..3372d79 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -39,6 +39,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { jellyfinSubtitleUrlsOnly: false, jellyfinPlay: false, jellyfinRemoteAnnounce: false, + jellyfinPreviewAuth: false, texthooker: false, help: false, autoStartOverlay: false, diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts new file mode 100644 index 0000000..c8d127e --- /dev/null +++ b/src/main-entry-runtime.test.ts @@ -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); +}); diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts new file mode 100644 index 0000000..3970c5c --- /dev/null +++ b/src/main-entry-runtime.ts @@ -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; +} diff --git a/src/main-entry.ts b/src/main-entry.ts index b0d5a49..4abe1bb 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -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'); diff --git a/src/main.ts b/src/main.ts index c9b34a8..89ef363 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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: { diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts index 193c2f9..ed68a1d 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -111,6 +111,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' listJellyfinLibraries: async () => [], listJellyfinItems: async () => [], listJellyfinSubtitleTracks: async () => [], + writeJellyfinPreviewAuth: () => {}, logInfo: () => {}, }, handleJellyfinPlayCommandMainDeps: { diff --git a/src/main/runtime/jellyfin-cli-list.test.ts b/src/main/runtime/jellyfin-cli-list.test.ts index 591bdf8..dd286f5 100644 --- a/src/main/runtime/jellyfin-cli-list.test.ts +++ b/src/main/runtime/jellyfin-cli-list.test.ts @@ -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/, + ); +}); diff --git a/src/main/runtime/jellyfin-cli-list.ts b/src/main/runtime/jellyfin-cli-list.ts index b711b3c..f6c3529 100644 --- a/src/main/runtime/jellyfin-cli-list.ts +++ b/src/main/runtime/jellyfin-cli-list.ts @@ -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>; 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 => { 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.'); diff --git a/src/main/runtime/jellyfin-cli-main-deps.test.ts b/src/main/runtime/jellyfin-cli-main-deps.test.ts index 66f8b08..780c5a0 100644 --- a/src/main/runtime/jellyfin-cli-main-deps.test.ts +++ b/src/main/runtime/jellyfin-cli-main-deps.test.ts @@ -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 () => { diff --git a/src/main/runtime/jellyfin-cli-main-deps.ts b/src/main/runtime/jellyfin-cli-main-deps.ts index 5f85d97..d0e163f 100644 --- a/src/main/runtime/jellyfin-cli-main-deps.ts +++ b/src/main/runtime/jellyfin-cli-main-deps.ts @@ -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), }); } diff --git a/src/renderer/modals/jimaku.test.ts b/src/renderer/modals/jimaku.test.ts new file mode 100644 index 0000000..d3328b8 --- /dev/null +++ b/src/renderer/modals/jimaku.test.ts @@ -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 { + 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 }); + } +}); diff --git a/src/renderer/modals/jimaku.ts b/src/renderer/modals/jimaku.ts index de7e440..e6278dc 100644 --- a/src/renderer/modals/jimaku.ts +++ b/src/renderer/modals/jimaku.ts @@ -234,6 +234,7 @@ export function createJimakuModal( if (result.ok) { setJimakuStatus(`Downloaded and loaded: ${result.path}`); + closeJimakuModal(); return; } diff --git a/src/renderer/modals/subsync.test.ts b/src/renderer/modals/subsync.test.ts new file mode 100644 index 0000000..bb64272 --- /dev/null +++ b/src/renderer/modals/subsync.test.ts @@ -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(); + 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(); + 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() { + let resolve!: (value: T) => void; + const promise = new Promise((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 { + 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(); + } +}); diff --git a/src/renderer/modals/subsync.ts b/src/renderer/modals/subsync.ts index acefa2e..21fdc7a 100644 --- a/src/renderer/modals/subsync.ts +++ b/src/renderer/modals/subsync.ts @@ -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 { 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;