mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
Merge remote-tracking branch 'origin/main' into add-hachidori-backend
# Conflicts: # src/renderer/handlers/keyboard.test.ts
This commit is contained in:
54 files changed
+2423
-885
No files matched your search
+82
-75
@@ -1,7 +1,5 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildForwardedJellyfinAppArgs,
|
||||
@@ -11,25 +9,16 @@ import {
|
||||
hasStoredJellyfinSession,
|
||||
parseEpisodePathFromDisplay,
|
||||
parseJellyfinErrorFromAppOutput,
|
||||
parseJellyfinItemsFromAppOutput,
|
||||
parseJellyfinLibrariesFromAppOutput,
|
||||
parseJellyfinAppReply,
|
||||
parseJellyfinItemsReply,
|
||||
parseJellyfinLibrariesReply,
|
||||
parseJellyfinPreviewAuthResponse,
|
||||
readUtf8FileAppendedSince,
|
||||
runJellyfinPlayMenuWithDeps,
|
||||
shouldRetryWithStartForNoRunningInstance,
|
||||
} from './jellyfin.js';
|
||||
import { makeLauncherArgs } from './test-support/args.js';
|
||||
import { withEnv } from './test-support/env.js';
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
class HandoffComplete extends Error {}
|
||||
|
||||
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
|
||||
@@ -68,6 +57,16 @@ async function runPlayMenu(options: {
|
||||
handoff.appArgs = appArgs;
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
isAppManagedByLauncher: () => false,
|
||||
runAppCommandCaptureOutput: () => {
|
||||
throw new Error('unexpected owned-app handoff');
|
||||
},
|
||||
waitForUnixSocketClosed: async () => {
|
||||
throw new Error('unexpected wait for mpv to close');
|
||||
},
|
||||
exitProcess: () => {
|
||||
throw new Error('unexpected exit');
|
||||
},
|
||||
log: () => {},
|
||||
...options.overrides,
|
||||
};
|
||||
@@ -105,41 +104,71 @@ test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv'
|
||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||
});
|
||||
|
||||
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)
|
||||
`);
|
||||
test('Jellyfin playback in an app the picker started waits for mpv to close before exiting', async () => {
|
||||
const events: string[] = [];
|
||||
const { appArgs } = await runPlayMenu({
|
||||
socketReady: true,
|
||||
overrides: {
|
||||
isAppManagedByLauncher: () => true,
|
||||
runAppCommandCaptureOutput: (_appPath, appArgs) => {
|
||||
events.push(`handoff ${appArgs.join(' ')}`);
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
waitForUnixSocketClosed: async (socketPath) => {
|
||||
events.push(`wait ${socketPath}`);
|
||||
},
|
||||
exitProcess: (code) => {
|
||||
events.push(`exit ${code}`);
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed, [
|
||||
assert.equal(appArgs, null);
|
||||
assert.deepEqual(events, [
|
||||
'handoff --start --jellyfin-play --jellyfin-item-id=item-123',
|
||||
'wait /tmp/subminer.sock',
|
||||
'exit 0',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinAppReply maps library and item replies to picker entries', () => {
|
||||
const libraries = parseJellyfinAppReply(
|
||||
JSON.stringify({
|
||||
libraries: [
|
||||
{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' },
|
||||
{ id: 'lib1', name: 'Duplicate', collectionType: 'tvshows' },
|
||||
{ name: 'No id' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.ok(libraries?.ok);
|
||||
assert.deepEqual(parseJellyfinLibrariesReply(libraries.payload), [
|
||||
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
||||
{ id: 'lib2', name: 'Movies', kind: 'movies' },
|
||||
]);
|
||||
|
||||
const items = parseJellyfinAppReply(
|
||||
JSON.stringify({ items: [{ id: 'movie-1', title: 'Movie [Alt]', type: 'Movie' }] }),
|
||||
);
|
||||
assert.ok(items?.ok);
|
||||
assert.deepEqual(parseJellyfinItemsReply(items.payload), [
|
||||
{ id: 'movie-1', name: 'Movie [Alt]', type: 'Movie', display: 'Movie [Alt]' },
|
||||
]);
|
||||
assert.equal(parseJellyfinLibrariesReply(items.payload), null);
|
||||
});
|
||||
|
||||
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('parseJellyfinAppReply surfaces app errors and rejects partial files', () => {
|
||||
assert.deepEqual(
|
||||
parseJellyfinAppReply(
|
||||
JSON.stringify({ error: 'Missing Jellyfin session. Run --jellyfin-login first.' }),
|
||||
),
|
||||
{ ok: false, error: 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.' },
|
||||
);
|
||||
assert.equal(parseJellyfinAppReply(''), null);
|
||||
assert.equal(parseJellyfinAppReply('{"libraries": ['), null);
|
||||
});
|
||||
|
||||
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
|
||||
test('buildForwardedJellyfinAppArgs appends server, password store, and log level', () => {
|
||||
const forwarded = buildForwardedJellyfinAppArgs(
|
||||
{
|
||||
jellyfinServer: 'https://jf.example.test/',
|
||||
@@ -184,13 +213,11 @@ test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () =>
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
||||
const parsed = parseJellyfinPreviewAuthResponse(
|
||||
JSON.stringify({
|
||||
serverUrl: 'http://pve-main:8096/',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
);
|
||||
const parsed = parseJellyfinPreviewAuthResponse({
|
||||
serverUrl: 'http://pve-main:8096/',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
@@ -199,17 +226,13 @@ test('parseJellyfinPreviewAuthResponse parses valid structured response payload'
|
||||
});
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
|
||||
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
|
||||
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
|
||||
test('parseJellyfinPreviewAuthResponse returns null without a token', () => {
|
||||
assert.equal(
|
||||
parseJellyfinPreviewAuthResponse(
|
||||
JSON.stringify({
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: '',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
),
|
||||
parseJellyfinPreviewAuthResponse({
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: '',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -238,22 +261,6 @@ test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle er
|
||||
);
|
||||
});
|
||||
|
||||
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!'),
|
||||
|
||||
+195
-222
@@ -10,7 +10,8 @@ import type {
|
||||
JellyfinGroupEntry,
|
||||
} from './types.js';
|
||||
import { shouldForwardLogLevel } from './types.js';
|
||||
import { log, fail, getMpvLogPath } from './log.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 {
|
||||
@@ -23,11 +24,14 @@ import {
|
||||
import { loadLauncherJellyfinConfig } from './config.js';
|
||||
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
|
||||
import {
|
||||
adoptLauncherStartedApp,
|
||||
isAppManagedByLauncher,
|
||||
runAppCommandWithInheritLogged,
|
||||
runAppCommandCaptureOutput,
|
||||
launchAppStartDetached,
|
||||
launchAppBackgroundDetached,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
waitForUnixSocketClosed,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
||||
@@ -52,6 +56,10 @@ type JellyfinPlayMenuDeps = {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -75,6 +83,10 @@ const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
runAppCommandWithInheritLogged,
|
||||
isAppManagedByLauncher,
|
||||
runAppCommandCaptureOutput,
|
||||
waitForUnixSocketClosed,
|
||||
exitProcess: (code) => process.exit(code),
|
||||
log,
|
||||
};
|
||||
|
||||
@@ -171,76 +183,77 @@ 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;
|
||||
// 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 };
|
||||
|
||||
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 };
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
|
||||
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 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);
|
||||
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: parsed.id,
|
||||
name: parsed.name,
|
||||
kind: parsed.type,
|
||||
id,
|
||||
name: readString(entry, 'name') || 'Untitled',
|
||||
kind: readString(entry, 'collectionType') || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
|
||||
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 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,
|
||||
});
|
||||
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) {
|
||||
@@ -260,41 +273,20 @@ export function parseJellyfinErrorFromAppOutput(output: string): string {
|
||||
}
|
||||
|
||||
if (line.includes('Missing Jellyfin session')) {
|
||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
||||
return normalizeJellyfinAppError(line);
|
||||
}
|
||||
}
|
||||
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() : '';
|
||||
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,
|
||||
};
|
||||
return { serverUrl, accessToken, userId };
|
||||
}
|
||||
|
||||
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
|
||||
@@ -312,19 +304,6 @@ export function hasStoredJellyfinSession(
|
||||
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 {
|
||||
@@ -421,145 +400,79 @@ export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): st
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
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.`);
|
||||
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;
|
||||
}
|
||||
if (attempt.error) {
|
||||
fail(attempt.error);
|
||||
}
|
||||
return attempt.output;
|
||||
}
|
||||
|
||||
async function runAppJellyfinCommand(
|
||||
// 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,
|
||||
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
||||
const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
|
||||
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 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;
|
||||
}
|
||||
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}`);
|
||||
let output = `${result.stdout || ''}\n${result.stderr || ''}\n${readLogAppendedSince(logOffset)}`;
|
||||
let error = parseJellyfinErrorFromAppOutput(output);
|
||||
|
||||
return { status: result.status, output, error, logOffset };
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
return { status: result.status, output, error: parseJellyfinErrorFromAppOutput(output) };
|
||||
};
|
||||
|
||||
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 = nowMs() + settleWindowMs;
|
||||
const settleOffset = attempt.logOffset;
|
||||
while (nowMs() < 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;
|
||||
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() + 4000;
|
||||
const deadline = nowMs() + timeoutMs;
|
||||
while (nowMs() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
const raw = fs.readFileSync(responsePath, 'utf8');
|
||||
const parsed = parseJellyfinPreviewAuthResponse(raw);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// retry until timeout
|
||||
}
|
||||
const reply = readJellyfinAppReply(responsePath);
|
||||
if (reply) return reply;
|
||||
await sleep(100);
|
||||
}
|
||||
return null;
|
||||
return { ok: false, error: `Timed out waiting for SubMiner to answer ${label}.` };
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
@@ -569,19 +482,57 @@ async function requestJellyfinPreviewAuthFromApp(
|
||||
}
|
||||
}
|
||||
|
||||
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 listLibrariesOutput = await runAppJellyfinListCommand(
|
||||
const libraries = await requestJellyfinListFromApp(
|
||||
appPath,
|
||||
args,
|
||||
['--jellyfin-libraries'],
|
||||
'jellyfin-libraries',
|
||||
parseJellyfinLibrariesReply,
|
||||
);
|
||||
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
|
||||
if (libraries.length === 0) {
|
||||
fail('No Jellyfin libraries found.');
|
||||
}
|
||||
@@ -655,8 +606,13 @@ async function resolveJellyfinSelectionViaApp(
|
||||
if (includeItemTypes) {
|
||||
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
|
||||
}
|
||||
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
|
||||
return parseJellyfinItemsFromAppOutput(output);
|
||||
return requestJellyfinListFromApp(
|
||||
appPath,
|
||||
args,
|
||||
itemArgs,
|
||||
'jellyfin-items',
|
||||
parseJellyfinItemsReply,
|
||||
);
|
||||
};
|
||||
|
||||
let rootItems =
|
||||
@@ -1106,5 +1062,22 @@ export async function runJellyfinPlayMenuWithDeps(
|
||||
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);
|
||||
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
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);
|
||||
}
|
||||
@@ -1121,6 +1121,21 @@ function clearOverlayManagedByLauncher(): void {
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
|
||||
// For commands that start the app ahead of playback (the Jellyfin picker): the launcher now owns
|
||||
// it, as with local playback, and stops it when the launcher exits for any reason, including a
|
||||
// cancelled picker or Ctrl+C.
|
||||
export function adoptLauncherStartedApp(appPath: string, args: Args): void {
|
||||
if (state.overlayManagedByLauncher) return;
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
process.once('exit', () => stopManagedOverlayApp(args));
|
||||
process.once('SIGINT', () => process.exit(130));
|
||||
process.once('SIGTERM', () => process.exit(143));
|
||||
}
|
||||
|
||||
export function isAppManagedByLauncher(): boolean {
|
||||
return state.overlayManagedByLauncher;
|
||||
}
|
||||
|
||||
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
|
||||
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
|
||||
if (result.error) {
|
||||
@@ -1723,6 +1738,13 @@ export async function canConnectUnixSocket(socketPath: string): Promise<boolean>
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves once mpv stops accepting connections on its IPC socket, i.e. the player was closed.
|
||||
export async function waitForUnixSocketClosed(socketPath: string, pollMs = 1000): Promise<void> {
|
||||
while (await canConnectUnixSocket(socketPath)) {
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForUnixSocketReady(
|
||||
socketPath: string,
|
||||
timeoutMs: number,
|
||||
|
||||
+19
-3
@@ -119,11 +119,27 @@ export async function promptOptionalJellyfinSearch(
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
if (useRofi && commandExists('rofi')) {
|
||||
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
||||
// Input-only prompt: the shipped theme pins a tall window and has no message widget, so
|
||||
// drop the listview (an empty row there reads as "no results") and show the hint instead.
|
||||
const inputOnlyTheme =
|
||||
'window { height: 0; } mainbox { children: [inputbar, message]; } ' +
|
||||
'message { background-color: transparent; padding: 10px 18px 4px 18px; } ' +
|
||||
'textbox { background-color: transparent; }';
|
||||
const rofiArgs = [
|
||||
'-dmenu',
|
||||
'-i',
|
||||
'-p',
|
||||
formatRofiPrompt('Jellyfin Search (optional)'),
|
||||
'-mesg',
|
||||
'Type to search, or press Enter to browse all',
|
||||
];
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
rofiArgs.push('-theme', themePath, '-theme-str', inputOnlyTheme);
|
||||
} else {
|
||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
||||
rofiArgs.push(
|
||||
'-theme-str',
|
||||
`configuration { font: "Noto Sans CJK JP Regular 8";} ${inputOnlyTheme}`,
|
||||
);
|
||||
}
|
||||
const result = spawnSync('rofi', rofiArgs, {
|
||||
input: '\n',
|
||||
|
||||
Reference in new issue
Block a user