mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
fix(jellyfin): use container-first search and folder drilldown
This commit is contained in:
@@ -60,12 +60,18 @@ Launcher wrapper equivalent for interactive playback flow:
|
||||
subminer jellyfin -p
|
||||
```
|
||||
|
||||
Launcher wrapper for Jellyfin cast discovery mode (foreground app process):
|
||||
Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
||||
|
||||
```bash
|
||||
subminer jellyfin -d
|
||||
```
|
||||
|
||||
Stop discovery session/app:
|
||||
|
||||
```bash
|
||||
subminer app --stop
|
||||
```
|
||||
|
||||
`subminer jf ...` is an alias for `subminer jellyfin ...`.
|
||||
|
||||
To clear saved session credentials:
|
||||
@@ -80,6 +86,17 @@ subminer jellyfin --logout
|
||||
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
|
||||
```
|
||||
|
||||
Optional listing controls:
|
||||
|
||||
- `--jellyfin-recursive=true|false` (default: true)
|
||||
- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...`
|
||||
|
||||
These are used by the launcher picker flow to:
|
||||
|
||||
- keep root search focused on shows/folders/movies (exclude episode rows)
|
||||
- browse selected anime/show directories as folder-or-file lists
|
||||
- recurse for playable files only after selecting a folder
|
||||
|
||||
5. Start playback:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type {
|
||||
Args,
|
||||
@@ -8,8 +9,8 @@ import type {
|
||||
JellyfinItemEntry,
|
||||
JellyfinGroupEntry,
|
||||
} from './types.js';
|
||||
import { log, fail } from './log.js';
|
||||
import { commandExists, resolvePathMaybe } from './util.js';
|
||||
import { log, fail, getMpvLogPath } from './log.js';
|
||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
||||
import {
|
||||
pickLibrary,
|
||||
pickItem,
|
||||
@@ -18,12 +19,17 @@ import {
|
||||
findRofiTheme,
|
||||
} from './picker.js';
|
||||
import { loadLauncherJellyfinConfig } from './config.js';
|
||||
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
|
||||
import {
|
||||
runAppCommandWithInheritLogged,
|
||||
runAppCommandCaptureOutput,
|
||||
launchAppStartDetached,
|
||||
launchMpvIdleDetached,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
|
||||
|
||||
export function sanitizeServerUrl(value: string): string {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
@@ -114,6 +120,591 @@ export function formatJellyfinItemDisplay(item: Record<string, 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;
|
||||
}
|
||||
|
||||
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.');
|
||||
if (isJellyfinPlayableType(selectedChild.type)) {
|
||||
return selectedChild.id;
|
||||
}
|
||||
if (isJellyfinContainerType(selectedChild.type)) {
|
||||
return await pickPlayableDescendants(selectedChild.id);
|
||||
}
|
||||
fail('Selected Jellyfin item is not playable.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveJellyfinSelection(
|
||||
args: Args,
|
||||
session: JellyfinSessionConfig,
|
||||
@@ -367,18 +958,37 @@ export async function runJellyfinPlayMenu(
|
||||
iconCacheDir: config.iconCacheDir || '',
|
||||
};
|
||||
|
||||
if (!session.serverUrl || !session.accessToken || !session.userId) {
|
||||
fail(
|
||||
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
||||
);
|
||||
}
|
||||
|
||||
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||
if (args.useRofi && !rofiTheme) {
|
||||
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
|
||||
}
|
||||
|
||||
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
||||
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
||||
let itemId = '';
|
||||
if (hasDirectSession) {
|
||||
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
||||
} else {
|
||||
const configPath = resolveLauncherMainConfigPath();
|
||||
if (!hasStoredJellyfinSession(configPath)) {
|
||||
fail(
|
||||
'Missing Jellyfin session. Run `subminer jellyfin -l --server <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 +1003,7 @@ export async function runJellyfinPlayMenu(
|
||||
if (!mpvReady) {
|
||||
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
||||
}
|
||||
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
|
||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
|
||||
@@ -5,6 +5,18 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
|
||||
import {
|
||||
parseJellyfinLibrariesFromAppOutput,
|
||||
parseJellyfinItemsFromAppOutput,
|
||||
parseJellyfinErrorFromAppOutput,
|
||||
parseJellyfinPreviewAuthResponse,
|
||||
deriveJellyfinTokenStorePath,
|
||||
hasStoredJellyfinSession,
|
||||
shouldRetryWithStartForNoRunningInstance,
|
||||
readUtf8FileAppendedSince,
|
||||
parseEpisodePathFromDisplay,
|
||||
buildRootSearchGroups,
|
||||
} from './jellyfin.js';
|
||||
|
||||
type RunResult = {
|
||||
status: number | null;
|
||||
@@ -149,7 +161,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin discovery routes to app --start with log-level forwarding', () => {
|
||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
@@ -169,7 +181,37 @@ test('jellyfin discovery routes to app --start with log-level forwarding', () =>
|
||||
const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n');
|
||||
assert.equal(
|
||||
fs.readFileSync(capturePath, 'utf8'),
|
||||
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin discovery via jf alias forwards remote announce for cast visibility', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['-R', 'jf', '--discovery', '--log-level', 'debug'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(
|
||||
fs.readFileSync(capturePath, 'utf8'),
|
||||
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,3 +280,166 @@ test('jellyfin setup forwards password-store to app command', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
|
||||
const parsed = parseJellyfinLibrariesFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
|
||||
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
|
||||
`);
|
||||
|
||||
assert.deepEqual(parsed, [
|
||||
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
||||
{ id: 'lib2', name: 'Movies', kind: 'movies' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
|
||||
const parsed = parseJellyfinItemsFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
|
||||
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
|
||||
`);
|
||||
|
||||
assert.deepEqual(parsed, [
|
||||
{
|
||||
id: 'item-10',
|
||||
name: 'Solo Leveling S01E10',
|
||||
type: 'Episode',
|
||||
display: 'Solo Leveling S01E10',
|
||||
},
|
||||
{
|
||||
id: 'movie-1',
|
||||
name: 'Movie [Alt]',
|
||||
type: 'Movie',
|
||||
display: 'Movie [Alt]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
|
||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
|
||||
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
parsed,
|
||||
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
|
||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
|
||||
`);
|
||||
|
||||
assert.equal(parsed, '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}');
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
||||
const parsed = parseJellyfinPreviewAuthResponse(
|
||||
JSON.stringify({
|
||||
serverUrl: 'http://pve-main:8096/',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
|
||||
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
|
||||
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
|
||||
assert.equal(
|
||||
parseJellyfinPreviewAuthResponse(
|
||||
JSON.stringify({
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: '',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('deriveJellyfinTokenStorePath resolves alongside config path', () => {
|
||||
const tokenPath = deriveJellyfinTokenStorePath('/home/test/.config/SubMiner/config.jsonc');
|
||||
assert.equal(tokenPath, '/home/test/.config/SubMiner/jellyfin-token-store.json');
|
||||
});
|
||||
|
||||
test('hasStoredJellyfinSession checks token-store existence', () => {
|
||||
const exists = (candidate: string): boolean =>
|
||||
candidate === '/home/test/.config/SubMiner/jellyfin-token-store.json';
|
||||
assert.equal(hasStoredJellyfinSession('/home/test/.config/SubMiner/config.jsonc', exists), true);
|
||||
assert.equal(hasStoredJellyfinSession('/home/test/.config/Other/alt.jsonc', exists), false);
|
||||
});
|
||||
|
||||
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
|
||||
assert.equal(
|
||||
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRetryWithStartForNoRunningInstance('Missing Jellyfin session. Run --jellyfin-login first.'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
|
||||
withTempDir((root) => {
|
||||
const logPath = path.join(root, 'SubMiner.log');
|
||||
const prefix = '[subminer] こんにちは\n';
|
||||
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
|
||||
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
|
||||
|
||||
const byteOffset = Buffer.byteLength(prefix, 'utf8');
|
||||
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
|
||||
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
||||
|
||||
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
|
||||
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
||||
});
|
||||
});
|
||||
|
||||
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
|
||||
assert.deepEqual(parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'), {
|
||||
seriesName: 'KONOSUBA',
|
||||
seasonNumber: 1,
|
||||
});
|
||||
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
|
||||
seriesName: 'Frieren',
|
||||
seasonNumber: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
|
||||
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
|
||||
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
|
||||
});
|
||||
|
||||
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
|
||||
const groups = buildRootSearchGroups([
|
||||
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
|
||||
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
|
||||
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(groups, [
|
||||
{
|
||||
id: 'series-1',
|
||||
name: 'The Eminence in Shadow',
|
||||
type: 'Series',
|
||||
display: 'The Eminence in Shadow (Series)',
|
||||
},
|
||||
{
|
||||
id: 'movie-1',
|
||||
name: 'Spirited Away',
|
||||
type: 'Movie',
|
||||
display: 'Spirited Away (Movie)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,24 @@ test('parseArgs ignores missing value after --log-level', () => {
|
||||
assert.equal(args.start, true);
|
||||
});
|
||||
|
||||
test('parseArgs handles jellyfin item listing controls', () => {
|
||||
const args = parseArgs([
|
||||
'--jellyfin-items',
|
||||
'--jellyfin-recursive=false',
|
||||
'--jellyfin-include-item-types',
|
||||
'Series,Movie,Folder',
|
||||
]);
|
||||
|
||||
assert.equal(args.jellyfinItems, true);
|
||||
assert.equal(args.jellyfinRecursive, false);
|
||||
assert.equal(args.jellyfinIncludeItemTypes, 'Series,Movie,Folder');
|
||||
});
|
||||
|
||||
test('parseArgs handles space-separated jellyfin recursive control', () => {
|
||||
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', 'false']);
|
||||
assert.equal(args.jellyfinRecursive, false);
|
||||
});
|
||||
|
||||
test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
const stopOnly = parseArgs(['--stop']);
|
||||
assert.equal(hasExplicitCommand(stopOnly), true);
|
||||
@@ -118,6 +136,19 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
|
||||
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
|
||||
|
||||
const jellyfinPreviewAuth = parseArgs([
|
||||
'--jellyfin-preview-auth',
|
||||
'--jellyfin-response-path',
|
||||
'/tmp/subminer-jf-response.json',
|
||||
]);
|
||||
assert.equal(jellyfinPreviewAuth.jellyfinPreviewAuth, true);
|
||||
assert.equal(
|
||||
jellyfinPreviewAuth.jellyfinResponsePath,
|
||||
'/tmp/subminer-jf-response.json',
|
||||
);
|
||||
assert.equal(hasExplicitCommand(jellyfinPreviewAuth), true);
|
||||
assert.equal(shouldStartApp(jellyfinPreviewAuth), false);
|
||||
|
||||
const background = parseArgs(['--background']);
|
||||
assert.equal(background.background, true);
|
||||
assert.equal(hasExplicitCommand(background), true);
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface CliArgs {
|
||||
jellyfinSubtitleUrlsOnly: boolean;
|
||||
jellyfinPlay: boolean;
|
||||
jellyfinRemoteAnnounce: boolean;
|
||||
jellyfinPreviewAuth: boolean;
|
||||
texthooker: boolean;
|
||||
help: boolean;
|
||||
autoStartOverlay: boolean;
|
||||
@@ -49,8 +50,11 @@ export interface CliArgs {
|
||||
jellyfinItemId?: string;
|
||||
jellyfinSearch?: string;
|
||||
jellyfinLimit?: number;
|
||||
jellyfinRecursive?: boolean;
|
||||
jellyfinIncludeItemTypes?: string;
|
||||
jellyfinAudioStreamIndex?: number;
|
||||
jellyfinSubtitleStreamIndex?: number;
|
||||
jellyfinResponsePath?: string;
|
||||
debug: boolean;
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
}
|
||||
@@ -93,6 +97,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
@@ -147,6 +152,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
args.jellyfinSubtitleUrlsOnly = true;
|
||||
} else if (arg === '--jellyfin-play') args.jellyfinPlay = true;
|
||||
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
|
||||
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
||||
else if (arg === '--texthooker') args.texthooker = true;
|
||||
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
|
||||
else if (arg === '--generate-config') args.generateConfig = true;
|
||||
@@ -229,6 +235,27 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--jellyfin-limit') {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
|
||||
} else if (arg.startsWith('--jellyfin-recursive=')) {
|
||||
const value = arg.split('=', 2)[1]?.trim().toLowerCase();
|
||||
if (value === 'true' || value === '1' || value === 'yes') args.jellyfinRecursive = true;
|
||||
if (value === 'false' || value === '0' || value === 'no') args.jellyfinRecursive = false;
|
||||
} else if (arg === '--jellyfin-recursive') {
|
||||
const value = readValue(argv[i + 1])?.trim().toLowerCase();
|
||||
if (value === 'false' || value === '0' || value === 'no') {
|
||||
args.jellyfinRecursive = false;
|
||||
} else if (value === 'true' || value === '1' || value === 'yes') {
|
||||
args.jellyfinRecursive = true;
|
||||
} else {
|
||||
args.jellyfinRecursive = true;
|
||||
}
|
||||
} else if (arg === '--jellyfin-non-recursive') {
|
||||
args.jellyfinRecursive = false;
|
||||
} else if (arg.startsWith('--jellyfin-include-item-types=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.jellyfinIncludeItemTypes = value;
|
||||
} else if (arg === '--jellyfin-include-item-types') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.jellyfinIncludeItemTypes = value;
|
||||
} else if (arg.startsWith('--jellyfin-audio-stream-index=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
|
||||
@@ -241,6 +268,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--jellyfin-subtitle-stream-index') {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
|
||||
} else if (arg.startsWith('--jellyfin-response-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.jellyfinResponsePath = value;
|
||||
} else if (arg === '--jellyfin-response-path') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.jellyfinResponsePath = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +315,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
args.texthooker ||
|
||||
args.generateConfig ||
|
||||
args.help
|
||||
@@ -350,6 +384,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.jellyfinSubtitles &&
|
||||
!args.jellyfinPlay &&
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.texthooker &&
|
||||
!args.help &&
|
||||
!args.autoStartOverlay &&
|
||||
|
||||
@@ -87,6 +87,10 @@ test('listItems supports search and formats title', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
assert.match(String(input), /SearchTerm=planet/);
|
||||
assert.match(
|
||||
String(input),
|
||||
/IncludeItemTypes=Movie%2CEpisode%2CAudio%2CSeries%2CSeason%2CFolder%2CCollectionFolder/,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
Items: [
|
||||
@@ -125,6 +129,64 @@ test('listItems supports search and formats title', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('listItems keeps playable-only include types when search is empty', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
assert.match(String(input), /IncludeItemTypes=Movie%2CEpisode%2CAudio/);
|
||||
assert.doesNotMatch(String(input), /CollectionFolder|Series|Season|Folder/);
|
||||
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const items = await listItems(
|
||||
{
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'u1',
|
||||
username: 'kyle',
|
||||
},
|
||||
clientInfo,
|
||||
{
|
||||
libraryId: 'lib-1',
|
||||
limit: 25,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(items, []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('listItems accepts explicit include types and recursive mode', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
assert.match(String(input), /Recursive=false/);
|
||||
assert.match(String(input), /IncludeItemTypes=Series%2CMovie%2CFolder/);
|
||||
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const items = await listItems(
|
||||
{
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'u1',
|
||||
username: 'kyle',
|
||||
},
|
||||
clientInfo,
|
||||
{
|
||||
libraryId: 'lib-1',
|
||||
includeItemTypes: 'Series,Movie,Folder',
|
||||
recursive: false,
|
||||
limit: 25,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(items, []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('resolvePlaybackPlan chooses direct play when allowed', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
|
||||
@@ -370,21 +370,29 @@ export async function listItems(
|
||||
libraryId: string;
|
||||
searchTerm?: string;
|
||||
limit?: number;
|
||||
recursive?: boolean;
|
||||
includeItemTypes?: string;
|
||||
},
|
||||
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
|
||||
if (!options.libraryId) throw new Error('Missing Jellyfin library id.');
|
||||
const normalizedSearchTerm = options.searchTerm?.trim() || '';
|
||||
const includeItemTypes =
|
||||
options.includeItemTypes?.trim() ||
|
||||
(normalizedSearchTerm
|
||||
? 'Movie,Episode,Audio,Series,Season,Folder,CollectionFolder'
|
||||
: 'Movie,Episode,Audio');
|
||||
|
||||
const query = new URLSearchParams({
|
||||
ParentId: options.libraryId,
|
||||
Recursive: 'true',
|
||||
IncludeItemTypes: 'Movie,Episode,Audio',
|
||||
Recursive: options.recursive === false ? 'false' : 'true',
|
||||
IncludeItemTypes: includeItemTypes,
|
||||
Fields: 'MediaSources,UserData',
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Limit: String(options.limit ?? 100),
|
||||
});
|
||||
if (options.searchTerm?.trim()) {
|
||||
query.set('SearchTerm', options.searchTerm.trim());
|
||||
if (normalizedSearchTerm) {
|
||||
query.set('SearchTerm', normalizedSearchTerm);
|
||||
}
|
||||
|
||||
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
||||
|
||||
@@ -24,6 +24,7 @@ test('list handler no-ops when no list command is set', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -47,6 +48,7 @@ test('list handler logs libraries', async () => {
|
||||
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -67,14 +69,19 @@ test('list handler logs libraries', async () => {
|
||||
|
||||
test('list handler resolves items using default library id', async () => {
|
||||
let usedLibraryId = '';
|
||||
let usedRecursive: boolean | undefined;
|
||||
let usedIncludeItemTypes: string | undefined;
|
||||
const logs: string[] = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async (_session, _clientInfo, params) => {
|
||||
usedLibraryId = params.libraryId;
|
||||
usedRecursive = params.recursive;
|
||||
usedIncludeItemTypes = params.includeItemTypes;
|
||||
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
|
||||
},
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -86,6 +93,8 @@ test('list handler resolves items using default library id', async () => {
|
||||
jellyfinLibraryId: '',
|
||||
jellyfinSearch: 'episode',
|
||||
jellyfinLimit: 10,
|
||||
jellyfinRecursive: false,
|
||||
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
@@ -96,6 +105,8 @@ test('list handler resolves items using default library id', async () => {
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(usedLibraryId, 'default-lib');
|
||||
assert.equal(usedRecursive, false);
|
||||
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
|
||||
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
|
||||
});
|
||||
|
||||
@@ -104,6 +115,7 @@ test('list handler throws when items command has no library id', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -132,6 +144,7 @@ test('list handler logs subtitle urls only when requested', async () => {
|
||||
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
|
||||
{ index: 2, language: 'jpn' },
|
||||
],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -157,6 +170,7 @@ test('list handler throws when subtitle command has no item id', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -174,3 +188,65 @@ test('list handler throws when subtitle command has no item id', async () => {
|
||||
/Missing --jellyfin-item-id/,
|
||||
);
|
||||
});
|
||||
|
||||
test('list handler writes preview auth payload to response path', async () => {
|
||||
const writes: Array<{
|
||||
path: string;
|
||||
payload: { serverUrl: string; accessToken: string; userId: string };
|
||||
}> = [];
|
||||
const logs: string[] = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
writes.push({ path: responsePath, payload });
|
||||
},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
const handled = await handler({
|
||||
args: {
|
||||
jellyfinPreviewAuth: true,
|
||||
jellyfinResponsePath: '/tmp/subminer-preview-auth.json',
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: baseConfig,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
path: '/tmp/subminer-preview-auth.json',
|
||||
payload: {
|
||||
serverUrl: baseSession.serverUrl,
|
||||
accessToken: baseSession.accessToken,
|
||||
userId: baseSession.userId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(logs, ['Jellyfin preview auth written.']);
|
||||
});
|
||||
|
||||
test('list handler throws when preview auth command has no response path', async () => {
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
handler({
|
||||
args: {
|
||||
jellyfinPreviewAuth: true,
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: baseConfig,
|
||||
}),
|
||||
/Missing --jellyfin-response-path/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ type JellyfinConfig = {
|
||||
defaultLibraryId: string;
|
||||
};
|
||||
|
||||
type JellyfinPreviewAuthPayload = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export function createHandleJellyfinListCommands(deps: {
|
||||
listJellyfinLibraries: (
|
||||
session: JellyfinSession,
|
||||
@@ -25,7 +31,13 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
listJellyfinItems: (
|
||||
session: JellyfinSession,
|
||||
clientInfo: JellyfinClientInfo,
|
||||
params: { libraryId: string; searchTerm?: string; limit: number },
|
||||
params: {
|
||||
libraryId: string;
|
||||
searchTerm?: string;
|
||||
limit: number;
|
||||
recursive?: boolean;
|
||||
includeItemTypes?: string;
|
||||
},
|
||||
) => Promise<Array<{ id: string; title: string; type: string }>>;
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: JellyfinSession,
|
||||
@@ -42,8 +54,9 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
isForced?: boolean;
|
||||
isExternal?: boolean;
|
||||
deliveryUrl?: string | null;
|
||||
}>
|
||||
}>
|
||||
>;
|
||||
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
@@ -54,6 +67,20 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
}): Promise<boolean> => {
|
||||
const { args, session, clientInfo, jellyfinConfig } = params;
|
||||
|
||||
if (args.jellyfinPreviewAuth) {
|
||||
const responsePath = args.jellyfinResponsePath?.trim();
|
||||
if (!responsePath) {
|
||||
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
|
||||
}
|
||||
deps.writeJellyfinPreviewAuth(responsePath, {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
});
|
||||
deps.logInfo('Jellyfin preview auth written.');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.jellyfinLibraries) {
|
||||
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
|
||||
if (libraries.length === 0) {
|
||||
@@ -79,6 +106,8 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
libraryId,
|
||||
searchTerm: args.jellyfinSearch,
|
||||
limit: args.jellyfinLimit ?? 100,
|
||||
recursive: args.jellyfinRecursive,
|
||||
includeItemTypes: args.jellyfinIncludeItemTypes,
|
||||
});
|
||||
if (items.length === 0) {
|
||||
deps.logInfo('No Jellyfin items found for the selected library/search.');
|
||||
|
||||
Reference in New Issue
Block a user