import path from 'node:path'; import fs from 'node:fs'; import { spawnSync } from 'node:child_process'; import type { Args, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry, } from './types.js'; import { log, fail } from './log.js'; import { commandExists, resolvePathMaybe } from './util.js'; import { pickLibrary, pickItem, pickGroup, promptOptionalJellyfinSearch, findRofiTheme, } from './picker.js'; import { loadLauncherJellyfinConfig } from './config.js'; import { runAppCommandWithInheritLogged, launchMpvIdleDetached, waitForUnixSocketReady, } from './mpv.js'; 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: { 'X-Emby-Token': session.accessToken, 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&api_key=${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})`; } 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 { const config = 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 || '', }; 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); log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`); log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`); let mpvReady = false; if (fs.existsSync(mpvSocketPath)) { mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250); } if (!mpvReady) { await launchMpvIdleDetached(mpvSocketPath, appPath, args); mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000); } 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 (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); }