Jellyfin and Subsync Fixes (#13)

This commit is contained in:
2026-03-01 16:13:16 -08:00
committed by GitHub
parent 49434bf0cd
commit 7023a3263f
36 changed files with 2001 additions and 60 deletions

View File

@@ -65,7 +65,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
}
if (args.jellyfinDiscovery) {
const forwarded = ['--start'];
const forwarded = ['--background', '--jellyfin-remote-announce'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);

View File

@@ -1,5 +1,6 @@
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import type {
Args,
@@ -8,8 +9,8 @@ import type {
JellyfinItemEntry,
JellyfinGroupEntry,
} from './types.js';
import { log, fail } from './log.js';
import { commandExists, resolvePathMaybe } from './util.js';
import { log, fail, getMpvLogPath } from './log.js';
import { commandExists, resolvePathMaybe, sleep } from './util.js';
import {
pickLibrary,
pickItem,
@@ -18,12 +19,17 @@ import {
findRofiTheme,
} from './picker.js';
import { loadLauncherJellyfinConfig } from './config.js';
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
import {
runAppCommandWithInheritLogged,
runAppCommandCaptureOutput,
launchAppStartDetached,
launchMpvIdleDetached,
waitForUnixSocketReady,
} from './mpv.js';
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
@@ -114,6 +120,605 @@ export function formatJellyfinItemDisplay(item: Record<string, unknown>): 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<string>();
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<string>();
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<string, unknown>;
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<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.');
}
async function runAppJellyfinListCommand(
appPath: string,
args: Args,
appArgs: string[],
label: string,
): Promise<string> {
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<JellyfinPreviewAuthResponse | null> {
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<string> {
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<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}`);
}
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<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,
@@ -367,18 +972,37 @@ export async function runJellyfinPlayMenu(
iconCacheDir: config.iconCacheDir || '',
};
if (!session.serverUrl || !session.accessToken || !session.userId) {
fail(
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
}
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) {
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
}
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
let itemId = '';
if (hasDirectSession) {
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
} else {
const configPath = resolveLauncherMainConfigPath();
if (!hasStoredJellyfinSession(configPath)) {
fail(
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
);
}
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
if (previewAuth) {
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
session.accessToken = previewAuth.accessToken;
session.userId = previewAuth.userId || session.userId;
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
} else {
log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
);
}
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
}
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false;
@@ -393,7 +1017,7 @@ export async function runJellyfinPlayMenu(
if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
}
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');

View File

@@ -5,6 +5,19 @@ import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
import {
parseJellyfinLibrariesFromAppOutput,
parseJellyfinItemsFromAppOutput,
parseJellyfinErrorFromAppOutput,
parseJellyfinPreviewAuthResponse,
deriveJellyfinTokenStorePath,
hasStoredJellyfinSession,
shouldRetryWithStartForNoRunningInstance,
readUtf8FileAppendedSince,
parseEpisodePathFromDisplay,
buildRootSearchGroups,
classifyJellyfinChildSelection,
} from './jellyfin.js';
type RunResult = {
status: number | null;
@@ -149,7 +162,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
});
});
test('jellyfin discovery routes to app --start with log-level forwarding', () => {
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -169,7 +182,37 @@ test('jellyfin discovery routes to app --start with log-level forwarding', () =>
const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n');
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
);
});
});
test('jellyfin discovery via jf alias forwards remote announce for cast visibility', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['-R', 'jf', '--discovery', '--log-level', 'debug'], env);
assert.equal(result.status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
);
});
});
@@ -238,3 +281,174 @@ test('jellyfin setup forwards password-store to app command', () => {
);
});
});
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
const parsed = parseJellyfinLibrariesFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
`);
assert.deepEqual(parsed, [
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
{ id: 'lib2', name: 'Movies', kind: 'movies' },
]);
});
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
const parsed = parseJellyfinItemsFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
`);
assert.deepEqual(parsed, [
{
id: 'item-10',
name: 'Solo Leveling S01E10',
type: 'Episode',
display: 'Solo Leveling S01E10',
},
{
id: 'movie-1',
name: 'Movie [Alt]',
type: 'Movie',
display: 'Movie [Alt]',
},
]);
});
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
`);
assert.equal(
parsed,
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
});
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
`);
assert.equal(parsed, '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}');
});
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
const parsed = parseJellyfinPreviewAuthResponse(
JSON.stringify({
serverUrl: 'http://pve-main:8096/',
accessToken: 'token-123',
userId: 'user-1',
}),
);
assert.deepEqual(parsed, {
serverUrl: 'http://pve-main:8096',
accessToken: 'token-123',
userId: 'user-1',
});
});
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
assert.equal(
parseJellyfinPreviewAuthResponse(
JSON.stringify({
serverUrl: 'http://pve-main:8096',
accessToken: '',
userId: 'user-1',
}),
),
null,
);
});
test('deriveJellyfinTokenStorePath resolves alongside config path', () => {
const tokenPath = deriveJellyfinTokenStorePath('/home/test/.config/SubMiner/config.jsonc');
assert.equal(tokenPath, '/home/test/.config/SubMiner/jellyfin-token-store.json');
});
test('hasStoredJellyfinSession checks token-store existence', () => {
const exists = (candidate: string): boolean =>
candidate === '/home/test/.config/SubMiner/jellyfin-token-store.json';
assert.equal(hasStoredJellyfinSession('/home/test/.config/SubMiner/config.jsonc', exists), true);
assert.equal(hasStoredJellyfinSession('/home/test/.config/Other/alt.jsonc', exists), false);
});
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
assert.equal(
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
true,
);
assert.equal(
shouldRetryWithStartForNoRunningInstance('Missing Jellyfin session. Run --jellyfin-login first.'),
false,
);
});
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
withTempDir((root) => {
const logPath = path.join(root, 'SubMiner.log');
const prefix = '[subminer] こんにちは\n';
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
const byteOffset = Buffer.byteLength(prefix, 'utf8');
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
});
});
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
assert.deepEqual(parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'), {
seriesName: 'KONOSUBA',
seasonNumber: 1,
});
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
seriesName: 'Frieren',
seasonNumber: 2,
});
});
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
});
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
const groups = buildRootSearchGroups([
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
]);
assert.deepEqual(groups, [
{
id: 'series-1',
name: 'The Eminence in Shadow',
type: 'Series',
display: 'The Eminence in Shadow (Series)',
},
{
id: 'movie-1',
name: 'Spirited Away',
type: 'Movie',
display: 'Spirited Away (Movie)',
},
]);
});
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
assert.deepEqual(next, {
kind: 'container',
id: 'season-2',
});
});

View File

@@ -5,7 +5,7 @@ import path from 'node:path';
import net from 'node:net';
import { EventEmitter } from 'node:events';
import type { Args } from './types';
import { startOverlay, state, waitForUnixSocketReady } from './mpv';
import { runAppCommandCaptureOutput, startOverlay, state, waitForUnixSocketReady } from './mpv';
import * as mpvModule from './mpv';
function createTempSocketPath(): { dir: string; socketPath: string } {
@@ -19,6 +19,18 @@ test('mpv module exposes only canonical socket readiness helper', () => {
assert.equal('waitForSocket' in mpvModule, false);
});
test('runAppCommandCaptureOutput captures status and stdio', () => {
const result = runAppCommandCaptureOutput(process.execPath, [
'-e',
'process.stdout.write("stdout-line"); process.stderr.write("stderr-line");',
]);
assert.equal(result.status, 0);
assert.equal(result.stdout, 'stdout-line');
assert.equal(result.stderr, 'stderr-line');
assert.equal(result.error, undefined);
});
test('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath();
try {

View File

@@ -658,6 +658,28 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): ne
process.exit(result.status ?? 0);
}
export function runAppCommandCaptureOutput(
appPath: string,
appArgs: string[],
): {
status: number;
stdout: string;
stderr: string;
error?: Error;
} {
const result = spawnSync(appPath, appArgs, {
env: buildAppEnv(),
encoding: 'utf8',
});
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
error: result.error ?? undefined,
};
}
export function runAppCommandWithInheritLogged(
appPath: string,
appArgs: string[],