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/launcher/jellyfin.ts b/launcher/jellyfin.ts index e1d0a04..7ab7e6d 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,591 @@ 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; +} + +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.'); + if (isJellyfinPlayableType(selectedChild.type)) { + return selectedChild.id; + } + if (isJellyfinContainerType(selectedChild.type)) { + return await pickPlayableDescendants(selectedChild.id); + } + fail('Selected Jellyfin item is not playable.'); + } +} + export async function resolveJellyfinSelection( args: Args, session: JellyfinSessionConfig, @@ -367,18 +958,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 +1003,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..9fc0034 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -5,6 +5,18 @@ 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, +} from './jellyfin.js'; type RunResult = { status: number | null; @@ -149,7 +161,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 +181,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 +280,166 @@ 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)', + }, + ]); +}); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 2d762b1..55bb04e 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -42,6 +42,24 @@ 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('hasExplicitCommand and shouldStartApp preserve command intent', () => { const stopOnly = parseArgs(['--stop']); assert.equal(hasExplicitCommand(stopOnly), true); @@ -118,6 +136,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..0932fae 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,27 @@ 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 { + 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 +268,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 +315,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.jellyfinSubtitles || args.jellyfinPlay || args.jellyfinRemoteAnnounce || + args.jellyfinPreviewAuth || args.texthooker || args.generateConfig || args.help @@ -350,6 +384,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/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/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.');