import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; import { spawnSync } from 'node:child_process'; import type { Args, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry, } from './types.js'; import { shouldForwardLogLevel } from './types.js'; import type { JellyfinPreviewAuthPayload } from '../src/shared/jellyfin-cli-response.js'; import { log, fail } from './log.js'; import { nowMs } from './time.js'; import { commandExists, resolvePathMaybe, sleep } from './util.js'; import { pickLibrary, pickItem, pickGroup, promptOptionalJellyfinSearch, findRofiTheme, } from './picker.js'; import { loadLauncherJellyfinConfig } from './config.js'; import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js'; import { adoptLauncherStartedApp, isAppManagedByLauncher, runAppCommandWithInheritLogged, runAppCommandCaptureOutput, launchAppBackgroundDetached, launchMpvIdleDetached, resolveLauncherRuntimePluginPath, waitForUnixSocketClosed, waitForUnixSocketReady, } from './mpv.js'; import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js'; const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g; type JellyfinPlayMenuDeps = { loadLauncherJellyfinConfig: typeof loadLauncherJellyfinConfig; findRofiTheme: typeof findRofiTheme; resolveJellyfinSelection: typeof resolveJellyfinSelection; hasStoredJellyfinSession: typeof hasStoredJellyfinSession; requestJellyfinPreviewAuthFromApp: typeof requestJellyfinPreviewAuthFromApp; resolveLauncherMainConfigPath: typeof resolveLauncherMainConfigPath; resolveJellyfinSelectionViaApp: typeof resolveJellyfinSelectionViaApp; pathExists: (candidate: string) => boolean; ensureRuntimePluginReady: (options: { appPath: string; scriptPath: string; logLevel: Args['logLevel']; }) => Promise; waitForUnixSocketReady: typeof waitForUnixSocketReady; launchMpvIdleDetached: typeof launchMpvIdleDetached; resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath; runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged; isAppManagedByLauncher: typeof isAppManagedByLauncher; runAppCommandCaptureOutput: typeof runAppCommandCaptureOutput; waitForUnixSocketClosed: typeof waitForUnixSocketClosed; exitProcess: (code: number) => never; log: typeof log; }; const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = { loadLauncherJellyfinConfig, findRofiTheme, resolveJellyfinSelection, hasStoredJellyfinSession, requestJellyfinPreviewAuthFromApp, resolveLauncherMainConfigPath, resolveJellyfinSelectionViaApp, pathExists: (candidate) => fs.existsSync(candidate), ensureRuntimePluginReady: async ({ appPath, scriptPath, logLevel }) => { await ensureLinuxRuntimePluginAvailable({ appPath, scriptPath, logLevel, }); }, waitForUnixSocketReady, launchMpvIdleDetached, resolveLauncherRuntimePluginPath, runAppCommandWithInheritLogged, isAppManagedByLauncher, runAppCommandCaptureOutput, waitForUnixSocketClosed, exitProcess: (code) => process.exit(code), log, }; export function sanitizeServerUrl(value: string): string { return value.trim().replace(/\/+$/, ''); } export async function jellyfinApiRequest( session: JellyfinSessionConfig, requestPath: string, ): Promise { const url = `${session.serverUrl}${requestPath}`; const response = await fetch(url, { headers: { Authorization: `MediaBrowser Token="${session.accessToken}"`, }, }); if (response.status === 401 || response.status === 403) { fail('Jellyfin token invalid/expired. Run --jellyfin-login or --jellyfin.'); } if (!response.ok) { fail(`Jellyfin API failed: ${response.status} ${response.statusText}`); } return (await response.json()) as T; } function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string { return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&ApiKey=${encodeURIComponent(session.accessToken)}`; } function jellyfinIconCacheDir(session: JellyfinSessionConfig): string { const serverKey = session.serverUrl.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 96); const userKey = session.userId.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 96); const baseDir = session.iconCacheDir ? resolvePathMaybe(session.iconCacheDir) : path.join('/tmp', 'subminer-jellyfin-icons'); return path.join(baseDir, serverKey, userKey); } function jellyfinIconPath(session: JellyfinSessionConfig, id: string): string { const safeId = id.replace(/[^a-zA-Z0-9._-]+/g, '_'); return path.join(jellyfinIconCacheDir(session), `${safeId}.jpg`); } function ensureJellyfinIcon(session: JellyfinSessionConfig, id: string): string | null { if (!session.pullPictures || !id || !commandExists('curl')) return null; const iconPath = jellyfinIconPath(session, id); try { if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) { return iconPath; } } catch { // continue to download } try { fs.mkdirSync(path.dirname(iconPath), { recursive: true }); } catch { return null; } const result = spawnSync('curl', ['-fsSL', '-o', iconPath, itemPreviewUrl(session, id)], { stdio: 'ignore', }); if (result.error || result.status !== 0) return null; try { if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) { return iconPath; } } catch { return null; } return null; } export function formatJellyfinItemDisplay(item: Record): string { const type = typeof item.Type === 'string' ? item.Type : 'Item'; const name = typeof item.Name === 'string' ? item.Name : 'Untitled'; if (type === 'Episode') { const series = typeof item.SeriesName === 'string' ? item.SeriesName : ''; const season = typeof item.ParentIndexNumber === 'number' ? String(item.ParentIndexNumber).padStart(2, '0') : '00'; const episode = typeof item.IndexNumber === 'number' ? String(item.IndexNumber).padStart(2, '0') : '00'; return `${series} S${season}E${episode} ${name}`.trim(); } return `${name} (${type})`; } function stripAnsi(value: string): string { return value.replace(ANSI_ESCAPE_PATTERN, ''); } // Reply the app writes to --jellyfin-response-path; see src/shared/jellyfin-cli-response.ts. export type JellyfinAppReply = | { ok: true; payload: Record } | { ok: false; error: string }; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function readString(record: Record, key: string): string { const value = record[key]; return typeof value === 'string' ? value.trim() : ''; } export function parseJellyfinAppReply(raw: string): JellyfinAppReply | null { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } if (!isRecord(parsed)) return null; const error = readString(parsed, 'error'); if (error) return { ok: false, error: normalizeJellyfinAppError(error) }; return { ok: true, payload: parsed }; } export function parseJellyfinLibrariesReply( payload: Record, ): JellyfinLibraryEntry[] | null { if (!Array.isArray(payload.libraries)) return null; const libraries: JellyfinLibraryEntry[] = []; const seenIds = new Set(); for (const entry of payload.libraries) { if (!isRecord(entry)) continue; const id = readString(entry, 'id'); if (!id || seenIds.has(id)) continue; seenIds.add(id); libraries.push({ id, name: readString(entry, 'name') || 'Untitled', kind: readString(entry, 'collectionType') || 'unknown', }); } return libraries; } export function parseJellyfinItemsReply( payload: Record, ): JellyfinItemEntry[] | null { if (!Array.isArray(payload.items)) return null; const items: JellyfinItemEntry[] = []; const seenIds = new Set(); for (const entry of payload.items) { if (!isRecord(entry)) continue; const id = readString(entry, 'id'); if (!id || seenIds.has(id)) continue; seenIds.add(id); const title = readString(entry, 'title') || 'Untitled'; items.push({ id, name: title, type: readString(entry, 'type'), display: title }); } return items; } function normalizeJellyfinAppError(message: string): string { if (message.includes('Missing Jellyfin session')) { return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.'; } return message; } 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 normalizeJellyfinAppError(line); } } return ''; } export function parseJellyfinPreviewAuthResponse( payload: Record, ): JellyfinPreviewAuthPayload | null { const serverUrl = sanitizeServerUrl(readString(payload, 'serverUrl')); const accessToken = readString(payload, 'accessToken'); const userId = readString(payload, 'userId'); 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 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 seasonText = match[2]; if (!seriesName || !seasonText) return null; const seasonNumber = Number.parseInt(seasonText, 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.'); } export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): string[] { const forwarded = [...appArgs]; const serverOverride = sanitizeServerUrl(args.jellyfinServer || ''); if (serverOverride) { forwarded.push('--jellyfin-server', serverOverride); } if (args.passwordStore) { forwarded.push('--password-store', args.passwordStore); } if (!forwarded.some((arg) => arg === '--log-level' || arg.startsWith('--log-level='))) { forwarded.push('--log-level', args.logLevel); } return forwarded; } const JELLYFIN_APP_REPLY_TIMEOUT_MS = 60_000; const JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS = 15_000; function readJellyfinAppReply(responsePath: string): JellyfinAppReply | null { try { return parseJellyfinAppReply(fs.readFileSync(responsePath, 'utf8')); } catch { return null; } } // Runs a Jellyfin command in the app and waits for the reply it writes to a temp file. The helper // process usually hands the command to an already-running instance and exits at once with no // output, so the reply file is the only result channel that works in both cases. async function requestJellyfinReplyFromApp( appPath: string, args: Args, appArgs: string[], label: string, timeoutMs: number, ): Promise { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-reply-')); const responsePath = path.join(tmpDir, 'response.json'); const forwarded = buildForwardedJellyfinAppArgs(args, [ ...appArgs, `--jellyfin-response-path=${responsePath}`, ]); const runOnce = (): { status: number; output: string; error: string } => { 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}`); const output = `${result.stdout || ''}\n${result.stderr || ''}`; return { status: result.status, output, error: parseJellyfinErrorFromAppOutput(output) }; }; try { let attempt = runOnce(); if (shouldRetryWithStartForNoRunningInstance(attempt.error)) { // Start it the way `subminer app` does: the app outlives this picker and the playback, so it // needs its tray to be visible and quittable. log( 'debug', args.logLevel, `${label}: starting app in the background, then retrying command`, ); launchAppBackgroundDetached(appPath, args.logLevel); adoptLauncherStartedApp(appPath, args); // A cold start (an AppImage mount, first-run setup) can take a while to claim the instance // lock, so keep retrying until the app takes the command or the request deadline passes. const startDeadline = nowMs() + timeoutMs; do { await sleep(1000); attempt = runOnce(); } while (shouldRetryWithStartForNoRunningInstance(attempt.error) && nowMs() < startDeadline); } // A written reply is authoritative; the exit status and log lines only explain a missing one. const earlyReply = readJellyfinAppReply(responsePath); if (earlyReply) return earlyReply; if (attempt.status !== 0) { return { ok: false, error: attempt.error || attempt.output.trim() || `${label} failed.` }; } if (attempt.error) { return { ok: false, error: attempt.error }; } const deadline = nowMs() + timeoutMs; while (nowMs() < deadline) { const reply = readJellyfinAppReply(responsePath); if (reply) return reply; await sleep(100); } return { ok: false, error: `Timed out waiting for SubMiner to answer ${label}.` }; } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // ignore cleanup failures } } } async function requestJellyfinPreviewAuthFromApp( appPath: string, args: Args, ): Promise { const reply = await requestJellyfinReplyFromApp( appPath, args, ['--jellyfin-preview-auth'], 'jellyfin-preview-auth', JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS, ); if (!reply.ok) { log('debug', args.logLevel, `jellyfin-preview-auth: ${reply.error}`); return null; } return parseJellyfinPreviewAuthResponse(reply.payload); } async function requestJellyfinListFromApp( appPath: string, args: Args, appArgs: string[], label: string, parse: (payload: Record) => T[] | null, ): Promise { const reply = await requestJellyfinReplyFromApp( appPath, args, appArgs, label, JELLYFIN_APP_REPLY_TIMEOUT_MS, ); if (!reply.ok) fail(reply.error); const entries = parse(reply.payload); if (!entries) fail(`Unexpected reply from SubMiner for ${label}.`); return entries; } async function resolveJellyfinSelectionViaApp( appPath: string, args: Args, session: JellyfinSessionConfig, themePath: string | null = null, ): Promise { const libraries = await requestJellyfinListFromApp( appPath, args, ['--jellyfin-libraries'], 'jellyfin-libraries', parseJellyfinLibrariesReply, ); 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}`); } return requestJellyfinListFromApp( appPath, args, itemArgs, 'jellyfin-items', parseJellyfinItemsReply, ); }; 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, themePath: string | null = null, ): Promise { const asNumberOrNull = (value: unknown): number | null => { if (typeof value !== 'number' || !Number.isFinite(value)) return null; return value; }; const compareByName = (left: string, right: string): number => left.localeCompare(right, undefined, { sensitivity: 'base', numeric: true }); const sortEntries = ( entries: Array<{ id: string; type: string; name: string; parentIndex: number | null; index: number | null; display: string; }>, ) => entries.sort((left, right) => { if (left.type === 'Episode' && right.type === 'Episode') { const leftSeason = left.parentIndex ?? Number.MAX_SAFE_INTEGER; const rightSeason = right.parentIndex ?? Number.MAX_SAFE_INTEGER; if (leftSeason !== rightSeason) return leftSeason - rightSeason; const leftEpisode = left.index ?? Number.MAX_SAFE_INTEGER; const rightEpisode = right.index ?? Number.MAX_SAFE_INTEGER; if (leftEpisode !== rightEpisode) return leftEpisode - rightEpisode; } if (left.type !== right.type) { const leftEpisodeLike = left.type === 'Episode'; const rightEpisodeLike = right.type === 'Episode'; if (leftEpisodeLike && !rightEpisodeLike) return -1; if (!leftEpisodeLike && rightEpisodeLike) return 1; } return compareByName(left.display, right.display); }); const libsPayload = await jellyfinApiRequest<{ Items?: Array> }>( session, `/Users/${session.userId}/Views`, ); const libraries: JellyfinLibraryEntry[] = (libsPayload.Items || []) .map((item) => ({ id: typeof item.Id === 'string' ? item.Id : '', name: typeof item.Name === 'string' ? item.Name : 'Untitled', kind: typeof item.CollectionType === 'string' ? item.CollectionType : typeof item.Type === 'string' ? item.Type : 'unknown', })) .filter((item) => item.id.length > 0); let libraryId = session.defaultLibraryId; if (!libraryId) { libraryId = pickLibrary(session, libraries, args.useRofi, ensureJellyfinIcon, '', themePath); if (!libraryId) fail('No Jellyfin library selected.'); } const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath); const fetchItemsPaged = async (parentId: string) => { const out: Array> = []; let startIndex = 0; while (true) { const payload = await jellyfinApiRequest<{ Items?: Array>; TotalRecordCount?: number; }>( session, `/Users/${session.userId}/Items?ParentId=${encodeURIComponent(parentId)}&Recursive=false&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`, ); const page = payload.Items || []; if (page.length === 0) break; out.push(...page); startIndex += page.length; const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null; if (total !== null && startIndex >= total) break; if (page.length < 500) break; } return out; }; const topLevelEntries = await fetchItemsPaged(libraryId); const groups: JellyfinGroupEntry[] = topLevelEntries .filter((item) => { const type = typeof item.Type === 'string' ? item.Type : ''; return ( type === 'Series' || type === 'Folder' || type === 'CollectionFolder' || type === 'Season' ); }) .map((item) => { const type = typeof item.Type === 'string' ? item.Type : 'Folder'; const name = typeof item.Name === 'string' ? item.Name : 'Untitled'; return { id: typeof item.Id === 'string' ? item.Id : '', name, type, display: `${name} (${type})`, }; }) .filter((entry) => entry.id.length > 0); let contentParentId = libraryId; let contentRecursive = true; const selectedGroupId = pickGroup( session, groups, args.useRofi, ensureJellyfinIcon, searchTerm, themePath, ); if (selectedGroupId) { contentParentId = selectedGroupId; const nextLevelEntries = await fetchItemsPaged(selectedGroupId); const seasons: JellyfinGroupEntry[] = nextLevelEntries .filter((item) => { const type = typeof item.Type === 'string' ? item.Type : ''; return type === 'Season' || type === 'Folder'; }) .map((item) => { const type = typeof item.Type === 'string' ? item.Type : 'Season'; const name = typeof item.Name === 'string' ? item.Name : 'Untitled'; return { id: typeof item.Id === 'string' ? item.Id : '', name, type, display: `${name} (${type})`, }; }) .filter((entry) => entry.id.length > 0); if (seasons.length > 0) { const seasonsById = new Map(seasons.map((entry) => [entry.id, entry])); const selectedSeasonId = pickGroup( session, seasons, args.useRofi, ensureJellyfinIcon, '', themePath, ); if (!selectedSeasonId) fail('No Jellyfin season selected.'); contentParentId = selectedSeasonId; const selectedSeason = seasonsById.get(selectedSeasonId); if (selectedSeason?.type === 'Season') { contentRecursive = false; } } } const fetchPage = async (startIndex: number) => jellyfinApiRequest<{ Items?: Array>; TotalRecordCount?: number; }>( session, `/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=${contentRecursive ? 'true' : 'false'}&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`, ); const allEntries: Array> = []; let startIndex = 0; while (true) { const payload = await fetchPage(startIndex); const page = payload.Items || []; if (page.length === 0) break; allEntries.push(...page); startIndex += page.length; const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null; if (total !== null && startIndex >= total) break; if (page.length < 500) break; } let items: JellyfinItemEntry[] = sortEntries( allEntries .filter((item) => { const type = typeof item.Type === 'string' ? item.Type : ''; return type === 'Movie' || type === 'Episode' || type === 'Audio'; }) .map((item) => ({ id: typeof item.Id === 'string' ? item.Id : '', name: typeof item.Name === 'string' ? item.Name : '', type: typeof item.Type === 'string' ? item.Type : 'Item', parentIndex: asNumberOrNull(item.ParentIndexNumber), index: asNumberOrNull(item.IndexNumber), display: formatJellyfinItemDisplay(item), })) .filter((item) => item.id.length > 0), ).map(({ id, name, type, display }) => ({ id, name, type, display, })); if (items.length === 0) { items = sortEntries( allEntries .filter((item) => { const type = typeof item.Type === 'string' ? item.Type : ''; if (type === 'Folder' || type === 'CollectionFolder') return false; const mediaType = typeof item.MediaType === 'string' ? item.MediaType.toLowerCase() : ''; if (mediaType === 'video' || mediaType === 'audio') return true; return ( type === 'Movie' || type === 'Episode' || type === 'Audio' || type === 'Video' || type === 'MusicVideo' ); }) .map((item) => ({ id: typeof item.Id === 'string' ? item.Id : '', name: typeof item.Name === 'string' ? item.Name : '', type: typeof item.Type === 'string' ? item.Type : 'Item', parentIndex: asNumberOrNull(item.ParentIndexNumber), index: asNumberOrNull(item.IndexNumber), display: formatJellyfinItemDisplay(item), })) .filter((item) => item.id.length > 0), ).map(({ id, name, type, display }) => ({ id, name, type, display, })); } const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, '', themePath); if (!itemId) fail('No Jellyfin item selected.'); return itemId; } export async function runJellyfinPlayMenu( appPath: string, args: Args, scriptPath: string, mpvSocketPath: string, ): Promise { return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath); } export async function runJellyfinPlayMenuWithDeps( appPath: string, args: Args, scriptPath: string, mpvSocketPath: string, deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps, ): Promise { const config = deps.loadLauncherJellyfinConfig(); const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim(); const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim(); const session: JellyfinSessionConfig = { serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ''), accessToken: envAccessToken, userId: envUserId, defaultLibraryId: config.defaultLibraryId || '', pullPictures: config.pullPictures === true, iconCacheDir: config.iconCacheDir || '', }; const rofiTheme = args.useRofi ? deps.findRofiTheme(scriptPath) : null; if (args.useRofi && !rofiTheme) { deps.log( 'warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.', ); } const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId); let itemId = ''; if (hasDirectSession) { itemId = await deps.resolveJellyfinSelection(args, session, rofiTheme); } else { const configPath = deps.resolveLauncherMainConfigPath(); if (!deps.hasStoredJellyfinSession(configPath)) { fail( 'Missing Jellyfin session. Run `subminer jellyfin -l --server --username --password ` first.', ); } const previewAuth = await deps.requestJellyfinPreviewAuthFromApp(appPath, args); if (previewAuth) { session.serverUrl = previewAuth.serverUrl || session.serverUrl; session.accessToken = previewAuth.accessToken; session.userId = previewAuth.userId || session.userId; deps.log( 'debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.', ); } else { deps.log( 'debug', args.logLevel, 'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.', ); } itemId = await deps.resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme); } deps.log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`); deps.log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`); let mpvReady = false; if (deps.pathExists(mpvSocketPath)) { mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 250); } if (!mpvReady) { await deps.ensureRuntimePluginReady({ appPath, scriptPath, logLevel: args.logLevel }); await deps.launchMpvIdleDetached( mpvSocketPath, appPath, args, deps.resolveLauncherRuntimePluginPath({ appPath, scriptPath }), ); mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 8000); } deps.log( 'debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`, ); if (!mpvReady) { fail(`MPV IPC socket not ready: ${mpvSocketPath}`); } const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`]; if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel); if (args.passwordStore) forwarded.push('--password-store', args.passwordStore); if (!deps.isAppManagedByLauncher()) { // A running app keeps running after playback; a cold `--start --jellyfin-play` makes the app // quit with mpv on its own. deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); } // The picker started the app, so this playback owns it, as with local playback: hand over the // item, wait for mpv to close, then exit, which stops the app. const handoff = deps.runAppCommandCaptureOutput(appPath, forwarded); if (handoff.status !== 0) { fail(handoff.error?.message || handoff.stderr.trim() || 'Failed to start Jellyfin playback.'); } deps.log( 'debug', args.logLevel, 'jellyfin-play: waiting for mpv to close before stopping the app', ); await deps.waitForUnixSocketClosed(mpvSocketPath); return deps.exitProcess(0); }