mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
1084 lines
36 KiB
TypeScript
1084 lines
36 KiB
TypeScript
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<void>;
|
|
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<T>(
|
|
session: JellyfinSessionConfig,
|
|
requestPath: string,
|
|
): Promise<T> {
|
|
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, unknown>): 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<string, unknown> }
|
|
| { ok: false; error: string };
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readString(record: Record<string, unknown>, 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<string, unknown>,
|
|
): JellyfinLibraryEntry[] | null {
|
|
if (!Array.isArray(payload.libraries)) return null;
|
|
const libraries: JellyfinLibraryEntry[] = [];
|
|
const seenIds = new Set<string>();
|
|
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<string, unknown>,
|
|
): JellyfinItemEntry[] | null {
|
|
if (!Array.isArray(payload.items)) return null;
|
|
const items: JellyfinItemEntry[] = [];
|
|
const seenIds = new Set<string>();
|
|
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<string, unknown>,
|
|
): 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<string>();
|
|
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<JellyfinGroupEntry, 'id' | 'type'>,
|
|
): 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<JellyfinAppReply> {
|
|
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<JellyfinPreviewAuthPayload | null> {
|
|
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<T>(
|
|
appPath: string,
|
|
args: Args,
|
|
appArgs: string[],
|
|
label: string,
|
|
parse: (payload: Record<string, unknown>) => T[] | null,
|
|
): Promise<T[]> {
|
|
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<string> {
|
|
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<JellyfinItemEntry[]> => {
|
|
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<string> => {
|
|
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<string>();
|
|
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<string> {
|
|
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<Record<string, unknown>> }>(
|
|
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<Record<string, unknown>> = [];
|
|
let startIndex = 0;
|
|
while (true) {
|
|
const payload = await jellyfinApiRequest<{
|
|
Items?: Array<Record<string, unknown>>;
|
|
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<Record<string, unknown>>;
|
|
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<Record<string, unknown>> = [];
|
|
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<never> {
|
|
return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath);
|
|
}
|
|
|
|
export async function runJellyfinPlayMenuWithDeps(
|
|
appPath: string,
|
|
args: Args,
|
|
scriptPath: string,
|
|
mpvSocketPath: string,
|
|
deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps,
|
|
): Promise<never> {
|
|
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 <url> --username <user> --password <pass>` 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);
|
|
}
|