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
@@ -929,9 +929,10 @@ test('handleCliCommand runs jellyfin command dispatcher', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
|
||||
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), 'initial', deps);
|
||||
handleCliCommand(makeArgs({ jellyfinPreviewAuth: true }), 'second-instance', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
|
||||
assert.equal(runJellyfinCallCount, 2);
|
||||
assert.equal(runJellyfinCallCount, 3);
|
||||
});
|
||||
|
||||
test('handleCliCommand reports jellyfin command errors to OSD', async () => {
|
||||
|
||||
@@ -771,7 +771,8 @@ export function handleCliCommand(
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth
|
||||
) {
|
||||
runAsyncWithOsd(
|
||||
() => deps.runJellyfinCommand(args),
|
||||
|
||||
@@ -131,7 +131,11 @@ export {
|
||||
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
||||
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
||||
} from './jellyfin';
|
||||
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export { JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export {
|
||||
buildJellyfinTimelinePayload,
|
||||
JellyfinPlaybackReporter,
|
||||
} from './jellyfin-playback-reporter';
|
||||
export {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
createOverlayManager,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildJellyfinTimelinePayload,
|
||||
JellyfinPlaybackReporter,
|
||||
} from './jellyfin-playback-reporter';
|
||||
|
||||
type FetchCall = { input: string; init: RequestInit };
|
||||
|
||||
function createReporter(respond: (input: string) => Response, logWarn?: (message: string) => void) {
|
||||
const calls: FetchCall[] = [];
|
||||
const reporter = new JellyfinPlaybackReporter({
|
||||
serverUrl: 'http://jellyfin.local/',
|
||||
accessToken: 'token-1',
|
||||
deviceId: 'device-1',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.2.3',
|
||||
deviceName: 'host',
|
||||
fetchImpl: (async (input, init) => {
|
||||
calls.push({ input: String(input), init: init ?? {} });
|
||||
return respond(String(input));
|
||||
}) as typeof fetch,
|
||||
logWarn,
|
||||
});
|
||||
return { reporter, calls };
|
||||
}
|
||||
|
||||
test('reportProgress posts the timeline payload as this device and treats failure as non-fatal', async () => {
|
||||
let fail = false;
|
||||
const { reporter, calls } = createReporter(
|
||||
() => new Response(fail ? 'boom' : null, { status: fail ? 500 : 200 }),
|
||||
);
|
||||
const state = {
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
};
|
||||
|
||||
assert.equal(await reporter.reportProgress(state), true);
|
||||
fail = true;
|
||||
assert.equal(await reporter.reportProgress({ itemId: 'movie-2', positionTicks: 999 }), false);
|
||||
|
||||
const call = calls[0]!;
|
||||
assert.equal(call.input, 'http://jellyfin.local/Sessions/Playing/Progress');
|
||||
assert.deepEqual(
|
||||
JSON.parse(String(call.init.body)),
|
||||
JSON.parse(JSON.stringify(buildJellyfinTimelinePayload(state))),
|
||||
);
|
||||
assert.equal(
|
||||
(call.init.headers as Record<string, string>).Authorization,
|
||||
'MediaBrowser Client="SubMiner", Device="host", DeviceId="device-1", Version="1.2.3", Token="token-1"',
|
||||
);
|
||||
});
|
||||
|
||||
test('timeline payload omits websocket-only event names', () => {
|
||||
const payload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
|
||||
assert.equal('EventName' in payload, false);
|
||||
});
|
||||
|
||||
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||
const { reporter, calls } = createReporter(() => new Response(null, { status: 200 }));
|
||||
|
||||
assert.equal(
|
||||
await reporter.reportStopped({ itemId: 'movie-stop', positionTicks: 7654321 }),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(calls[0]!.input, 'http://jellyfin.local/Sessions/Playing/Stopped');
|
||||
const posted = JSON.parse(String(calls[0]!.init.body));
|
||||
assert.equal(posted.PositionTicks, 7654321);
|
||||
assert.equal(posted.Failed, false);
|
||||
});
|
||||
|
||||
test('warns once per failing endpoint until it recovers', async () => {
|
||||
const warnings: string[] = [];
|
||||
let status = 400;
|
||||
const { reporter } = createReporter(
|
||||
() => new Response(null, { status }),
|
||||
(message) => warnings.push(message),
|
||||
);
|
||||
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||
|
||||
status = 200;
|
||||
assert.equal(await reporter.reportStopped(state), true);
|
||||
status = 500;
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
export interface JellyfinTimelinePlaybackState {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
playbackStartTimeTicks?: number;
|
||||
isPaused?: boolean;
|
||||
isMuted?: boolean;
|
||||
canSeek?: boolean;
|
||||
volumeLevel?: number;
|
||||
playbackRate?: number;
|
||||
playMethod?: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playlistItemId?: string | null;
|
||||
eventName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePayload {
|
||||
ItemId: string;
|
||||
MediaSourceId?: string;
|
||||
PositionTicks: number;
|
||||
PlaybackStartTimeTicks: number;
|
||||
IsPaused: boolean;
|
||||
IsMuted: boolean;
|
||||
CanSeek: boolean;
|
||||
VolumeLevel: number;
|
||||
PlaybackRate: number;
|
||||
PlayMethod: string;
|
||||
AudioStreamIndex?: number | null;
|
||||
SubtitleStreamIndex?: number | null;
|
||||
PlaylistItemId?: string | null;
|
||||
Failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinPlaybackReporterOptions {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
deviceId: string;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
deviceName?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
logWarn?: (message: string, details?: unknown) => void;
|
||||
}
|
||||
|
||||
const JELLYFIN_REPORT_TIMEOUT_MS = 5000;
|
||||
|
||||
function clampVolume(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeTicks(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function asNullableInteger(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildJellyfinTimelinePayload(
|
||||
state: JellyfinTimelinePlaybackState,
|
||||
): JellyfinTimelinePayload {
|
||||
return {
|
||||
ItemId: state.itemId,
|
||||
MediaSourceId: state.mediaSourceId,
|
||||
PositionTicks: normalizeTicks(state.positionTicks),
|
||||
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
|
||||
IsPaused: state.isPaused === true,
|
||||
IsMuted: state.isMuted === true,
|
||||
CanSeek: state.canSeek !== false,
|
||||
VolumeLevel: clampVolume(state.volumeLevel),
|
||||
PlaybackRate:
|
||||
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
|
||||
? state.playbackRate
|
||||
: 1,
|
||||
PlayMethod: state.playMethod || 'DirectPlay',
|
||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||
PlaylistItemId: state.playlistItemId,
|
||||
Failed: state.failed,
|
||||
};
|
||||
}
|
||||
|
||||
// Authenticated JSON POSTs as this device. Playback timeline reports (start, progress, stop)
|
||||
// are plain HTTP, so they work whether or not the cast-control websocket is connected; that
|
||||
// is how Jellyfin learns the resume position and marks items played for every playback.
|
||||
export class JellyfinPlaybackReporter {
|
||||
readonly authHeader: string;
|
||||
private readonly serverUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||
private readonly failedRequestPaths = new Set<string>();
|
||||
|
||||
constructor(options: JellyfinPlaybackReporterOptions) {
|
||||
this.serverUrl = options.serverUrl.trim().replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.logWarn = options.logWarn;
|
||||
const clientName = options.clientName || 'SubMiner';
|
||||
const clientVersion = options.clientVersion || '0.1.0';
|
||||
const deviceName = options.deviceName || clientName;
|
||||
this.authHeader = `MediaBrowser Client="${clientName}", Device="${deviceName}", DeviceId="${options.deviceId}", Version="${clientVersion}", Token="${options.accessToken}"`;
|
||||
}
|
||||
|
||||
reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing/Stopped', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
Failed: state.failed === true,
|
||||
});
|
||||
}
|
||||
|
||||
async postJson(path: string, payload: unknown): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
// The stop report waits for in-flight progress reports, so a hung request must not
|
||||
// hold it back indefinitely.
|
||||
signal: AbortSignal.timeout(JELLYFIN_REPORT_TIMEOUT_MS),
|
||||
});
|
||||
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
this.noteRequestOutcome(path, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||
// log without a warning per progress tick.
|
||||
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||
if (failure === null) {
|
||||
this.failedRequestPaths.delete(path);
|
||||
return;
|
||||
}
|
||||
if (this.failedRequestPaths.has(path)) return;
|
||||
this.failedRequestPaths.add(path);
|
||||
this.logWarn?.(`Jellyfin request failed: POST ${path}`, failure);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
import { JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
|
||||
class FakeWebSocket {
|
||||
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
||||
@@ -240,105 +240,6 @@ test('Jellyfin remote stop prevents further reconnect/network activity', () => {
|
||||
assert.equal(service.isConnected(), false);
|
||||
});
|
||||
|
||||
test('reportProgress posts timeline payload and treats failure as non-fatal', async () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
||||
let shouldFailTimeline = false;
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-4',
|
||||
deviceId: 'device-4',
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as any;
|
||||
},
|
||||
fetchImpl: (async (input, init) => {
|
||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
||||
if (String(input).endsWith('/Sessions/Playing/Progress') && shouldFailTimeline) {
|
||||
return new Response('boom', { status: 500 });
|
||||
}
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
service.start();
|
||||
sockets[0]!.emit('open');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const expectedPayload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
});
|
||||
const expectedPostedPayload = Object.fromEntries(
|
||||
Object.entries(structuredClone(expectedPayload)).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
const ok = await service.reportProgress({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
});
|
||||
shouldFailTimeline = true;
|
||||
const failed = await service.reportProgress({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 999,
|
||||
});
|
||||
|
||||
const timelineCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Progress'));
|
||||
assert.ok(timelineCall);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(failed, false);
|
||||
assert.ok(typeof timelineCall.init.body === 'string');
|
||||
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
|
||||
});
|
||||
|
||||
test('timeline payload omits websocket-only event names', () => {
|
||||
const payload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
|
||||
assert.equal('EventName' in payload, false);
|
||||
});
|
||||
|
||||
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-stop-payload',
|
||||
deviceId: 'device-stop-payload',
|
||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||
fetchImpl: (async (input, init) => {
|
||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
const ok = await service.reportStopped({
|
||||
itemId: 'movie-stop',
|
||||
positionTicks: 7654321,
|
||||
failed: false,
|
||||
});
|
||||
|
||||
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
|
||||
assert.equal(ok, true);
|
||||
assert.ok(stoppedCall);
|
||||
assert.ok(typeof stoppedCall.init.body === 'string');
|
||||
const posted = JSON.parse(String(stoppedCall.init.body));
|
||||
assert.equal(posted.PositionTicks, 7654321);
|
||||
assert.equal(posted.Failed, false);
|
||||
});
|
||||
|
||||
test('advertiseNow validates server registration using Sessions endpoint', async () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const calls: string[] = [];
|
||||
@@ -451,34 +352,6 @@ test('reconnects when the server stops answering keep-alives', () => {
|
||||
assert.equal(sockets.length, 2);
|
||||
});
|
||||
|
||||
test('warns once per failing timeline endpoint until it recovers', async () => {
|
||||
const warnings: string[] = [];
|
||||
let status = 400;
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-warn',
|
||||
deviceId: 'device-warn',
|
||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||
|
||||
status = 200;
|
||||
assert.equal(await service.reportStopped(state), true);
|
||||
status = 500;
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
|
||||
test('ignores messages from a superseded socket', () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const playPayloads: unknown[] = [];
|
||||
|
||||
@@ -1,45 +1,11 @@
|
||||
import WebSocket from 'ws';
|
||||
import { JellyfinPlaybackReporter } from './jellyfin-playback-reporter';
|
||||
|
||||
export interface JellyfinRemoteSessionMessage {
|
||||
MessageType?: string;
|
||||
Data?: unknown;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePlaybackState {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
playbackStartTimeTicks?: number;
|
||||
isPaused?: boolean;
|
||||
isMuted?: boolean;
|
||||
canSeek?: boolean;
|
||||
volumeLevel?: number;
|
||||
playbackRate?: number;
|
||||
playMethod?: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playlistItemId?: string | null;
|
||||
eventName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePayload {
|
||||
ItemId: string;
|
||||
MediaSourceId?: string;
|
||||
PositionTicks: number;
|
||||
PlaybackStartTimeTicks: number;
|
||||
IsPaused: boolean;
|
||||
IsMuted: boolean;
|
||||
CanSeek: boolean;
|
||||
VolumeLevel: number;
|
||||
PlaybackRate: number;
|
||||
PlayMethod: string;
|
||||
AudioStreamIndex?: number | null;
|
||||
SubtitleStreamIndex?: number | null;
|
||||
PlaylistItemId?: string | null;
|
||||
Failed?: boolean;
|
||||
}
|
||||
|
||||
interface JellyfinRemoteSocket {
|
||||
on(event: 'open', listener: () => void): this;
|
||||
on(event: 'close', listener: () => void): this;
|
||||
@@ -99,16 +65,6 @@ function normalizeServerUrl(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function clampVolume(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeTicks(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function parseMessageData(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
@@ -137,11 +93,6 @@ function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | n
|
||||
}
|
||||
}
|
||||
|
||||
function asNullableInteger(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function createDefaultCapabilities(): {
|
||||
PlayableMediaTypes: string;
|
||||
SupportedCommands: string;
|
||||
@@ -155,40 +106,6 @@ function createDefaultCapabilities(): {
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthorizationHeader(params: {
|
||||
clientName: string;
|
||||
deviceName: string;
|
||||
clientVersion: string;
|
||||
deviceId: string;
|
||||
accessToken: string;
|
||||
}): string {
|
||||
return `MediaBrowser Client="${params.clientName}", Device="${params.deviceName}", DeviceId="${params.deviceId}", Version="${params.clientVersion}", Token="${params.accessToken}"`;
|
||||
}
|
||||
|
||||
export function buildJellyfinTimelinePayload(
|
||||
state: JellyfinTimelinePlaybackState,
|
||||
): JellyfinTimelinePayload {
|
||||
return {
|
||||
ItemId: state.itemId,
|
||||
MediaSourceId: state.mediaSourceId,
|
||||
PositionTicks: normalizeTicks(state.positionTicks),
|
||||
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
|
||||
IsPaused: state.isPaused === true,
|
||||
IsMuted: state.isMuted === true,
|
||||
CanSeek: state.canSeek !== false,
|
||||
VolumeLevel: clampVolume(state.volumeLevel),
|
||||
PlaybackRate:
|
||||
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
|
||||
? state.playbackRate
|
||||
: 1,
|
||||
PlayMethod: state.playMethod || 'DirectPlay',
|
||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||
PlaylistItemId: state.playlistItemId,
|
||||
Failed: state.failed,
|
||||
};
|
||||
}
|
||||
|
||||
export class JellyfinRemoteSessionService {
|
||||
private readonly serverUrl: string;
|
||||
private readonly accessToken: string;
|
||||
@@ -210,6 +127,8 @@ export class JellyfinRemoteSessionService {
|
||||
SupportsMediaControl: boolean;
|
||||
};
|
||||
private readonly authHeader: string;
|
||||
// Shares this device's identity for capability posts; playback reports use their own reporter.
|
||||
private readonly http: JellyfinPlaybackReporter;
|
||||
private readonly onConnected?: () => void;
|
||||
private readonly onDisconnected?: () => void;
|
||||
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||
@@ -217,7 +136,6 @@ export class JellyfinRemoteSessionService {
|
||||
private keepAliveTimeoutMs: number;
|
||||
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastInboundAtMs = 0;
|
||||
private readonly failedRequestPaths = new Set<string>();
|
||||
|
||||
private readonly reconnectBaseDelayMs: number;
|
||||
private readonly reconnectMaxDelayMs: number;
|
||||
@@ -243,19 +161,20 @@ export class JellyfinRemoteSessionService {
|
||||
...createDefaultCapabilities(),
|
||||
...(options.capabilities ?? {}),
|
||||
};
|
||||
const clientName = options.clientName || 'SubMiner';
|
||||
const clientVersion = options.clientVersion || '0.1.0';
|
||||
const deviceName = options.deviceName || clientName;
|
||||
this.authHeader = buildAuthorizationHeader({
|
||||
clientName,
|
||||
deviceName,
|
||||
clientVersion,
|
||||
deviceId: this.deviceId,
|
||||
this.logWarn = options.logWarn;
|
||||
this.http = new JellyfinPlaybackReporter({
|
||||
serverUrl: this.serverUrl,
|
||||
accessToken: this.accessToken,
|
||||
deviceId: this.deviceId,
|
||||
clientName: options.clientName,
|
||||
clientVersion: options.clientVersion,
|
||||
deviceName: options.deviceName,
|
||||
fetchImpl: this.fetchImpl,
|
||||
logWarn: this.logWarn,
|
||||
});
|
||||
this.authHeader = this.http.authHeader;
|
||||
this.onConnected = options.onConnected;
|
||||
this.onDisconnected = options.onDisconnected;
|
||||
this.logWarn = options.logWarn;
|
||||
this.now = options.getNow ?? Date.now;
|
||||
this.keepAliveTimeoutMs = Math.max(
|
||||
1000,
|
||||
@@ -298,21 +217,6 @@ export class JellyfinRemoteSessionService {
|
||||
return this.isRegisteredOnServer();
|
||||
}
|
||||
|
||||
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Stopped', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
Failed: state.failed === true,
|
||||
});
|
||||
}
|
||||
|
||||
private connectSocket(): void {
|
||||
if (!this.running) return;
|
||||
if (this.reconnectTimer) {
|
||||
@@ -441,9 +345,9 @@ export class JellyfinRemoteSessionService {
|
||||
|
||||
private async postCapabilities(): Promise<void> {
|
||||
const payload = this.capabilities;
|
||||
const fullEndpointOk = await this.postJson('/Sessions/Capabilities/Full', payload);
|
||||
const fullEndpointOk = await this.http.postJson('/Sessions/Capabilities/Full', payload);
|
||||
if (fullEndpointOk) return;
|
||||
await this.postJson('/Sessions/Capabilities', payload);
|
||||
await this.http.postJson('/Sessions/Capabilities', payload);
|
||||
}
|
||||
|
||||
private async isRegisteredOnServer(): Promise<boolean> {
|
||||
@@ -462,40 +366,6 @@ export class JellyfinRemoteSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async postTimeline(path: string, payload: JellyfinTimelinePayload): Promise<boolean> {
|
||||
return this.postJson(path, payload);
|
||||
}
|
||||
|
||||
private async postJson(path: string, payload: unknown): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
this.noteRequestOutcome(path, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||
// log without a warning per progress tick.
|
||||
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||
if (failure === null) {
|
||||
this.failedRequestPaths.delete(path);
|
||||
return;
|
||||
}
|
||||
if (this.failedRequestPaths.has(path)) return;
|
||||
this.failedRequestPaths.add(path);
|
||||
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
|
||||
}
|
||||
|
||||
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
|
||||
const message = parseInboundMessage(rawData);
|
||||
if (!message) return;
|
||||
|
||||
+16
-8
@@ -58,6 +58,7 @@ import {
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from './main/runtime/jellyfin-remote-playback';
|
||||
import { getAppControlSocketPath } from './shared/app-control';
|
||||
import { writeJellyfinCliResponse } from './shared/jellyfin-cli-response';
|
||||
import {
|
||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
@@ -331,6 +332,7 @@ import {
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
showMpvOsdRuntime,
|
||||
JellyfinPlaybackReporter,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
upsertYomitanDictionarySettings,
|
||||
@@ -409,6 +411,7 @@ import {
|
||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
createHostDerivedJellyfinDeviceId,
|
||||
resolveJellyfinRemoteDeviceName,
|
||||
} from './main/runtime/jellyfin-device-identity';
|
||||
import {
|
||||
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
||||
@@ -3395,9 +3398,17 @@ const {
|
||||
setLastProgressAtMs: (value) => {
|
||||
jellyfinRemoteLastProgressAtMs = value;
|
||||
},
|
||||
reportPlaying: (payload) => {
|
||||
void appState.jellyfinRemoteSession?.reportPlaying(payload);
|
||||
},
|
||||
// Same device identity as the cast session, so Jellyfin attributes reports to one session.
|
||||
createPlaybackReporter: ({ session, clientInfo }) =>
|
||||
new JellyfinPlaybackReporter({
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
deviceId: clientInfo.deviceId,
|
||||
clientName: clientInfo.clientName,
|
||||
clientVersion: clientInfo.clientVersion,
|
||||
deviceName: resolveJellyfinRemoteDeviceName({ hostName: os.hostname() }),
|
||||
logWarn: (message, details) => logger.warn(message, details),
|
||||
}),
|
||||
showMpvOsd: (text) => {
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Jellyfin' });
|
||||
},
|
||||
@@ -3419,7 +3430,6 @@ const {
|
||||
clearActivePlayback: () => {
|
||||
activeJellyfinRemotePlayback = null;
|
||||
},
|
||||
getSession: () => appState.jellyfinRemoteSession,
|
||||
getNow: () => Date.now(),
|
||||
getLastProgressAtMs: () => jellyfinRemoteLastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
@@ -3448,10 +3458,7 @@ const {
|
||||
listJellyfinItemsRuntime(session, clientInfo, params),
|
||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||
listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId),
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
},
|
||||
writeJellyfinResponse: writeJellyfinCliResponse,
|
||||
logInfo: (message) => logger.info(message),
|
||||
},
|
||||
handleJellyfinPlayCommandMainDeps: {
|
||||
@@ -3488,6 +3495,7 @@ const {
|
||||
},
|
||||
runJellyfinCommandMainDeps: {
|
||||
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
||||
writeJellyfinResponse: writeJellyfinCliResponse,
|
||||
},
|
||||
maybeFocusExistingJellyfinSetupWindowMainDeps: {
|
||||
getSetupWindow: () => appState.jellyfinSetupWindow,
|
||||
|
||||
@@ -44,7 +44,6 @@ export type JellyfinRemoteComposerOptions = ComposerInputs<{
|
||||
>[0]['jellyfinTicksToSeconds'];
|
||||
getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback'];
|
||||
clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback'];
|
||||
getSession: JellyfinRemoteProgressMainDeps['getSession'];
|
||||
getNow: JellyfinRemoteProgressMainDeps['getNow'];
|
||||
getLastProgressAtMs: Parameters<
|
||||
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
|
||||
@@ -74,7 +73,6 @@ export function composeJellyfinRemoteHandlers(
|
||||
createBuildReportJellyfinRemoteProgressMainDepsHandler({
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
getMpvClient: options.getMpvClient,
|
||||
getNow: options.getNow,
|
||||
getLastProgressAtMs: options.getLastProgressAtMs,
|
||||
@@ -87,7 +85,6 @@ export function composeJellyfinRemoteHandlers(
|
||||
createBuildReportJellyfinRemoteStoppedMainDepsHandler({
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
getMpvClient: options.getMpvClient,
|
||||
getNow: options.getNow,
|
||||
ticksPerSecond: options.ticksPerSecond,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHandleJellyfinListCommands } from './jellyfin-cli-list';
|
||||
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||
|
||||
const baseSession = {
|
||||
serverUrl: 'http://localhost',
|
||||
@@ -24,7 +25,7 @@ test('list handler no-ops when no list command is set', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -42,13 +43,14 @@ test('list handler no-ops when no list command is set', async () => {
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
test('list handler logs libraries', async () => {
|
||||
test('list handler logs libraries and writes them to the response path', async () => {
|
||||
const logs: string[] = [];
|
||||
const writes: Array<{ path: string; response: JellyfinCliResponse }> = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: (path, response) => writes.push({ path, response }),
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -57,6 +59,7 @@ test('list handler logs libraries', async () => {
|
||||
jellyfinLibraries: true,
|
||||
jellyfinItems: false,
|
||||
jellyfinSubtitles: false,
|
||||
jellyfinResponsePath: '/tmp/reply.json',
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
@@ -65,6 +68,12 @@ test('list handler logs libraries', async () => {
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.ok(logs.some((line) => line.includes('Jellyfin library: Anime [lib1] (tvshows)')));
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
path: '/tmp/reply.json',
|
||||
response: { libraries: [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('list handler resolves items using default library id', async () => {
|
||||
@@ -72,6 +81,7 @@ test('list handler resolves items using default library id', async () => {
|
||||
let usedRecursive: boolean | undefined;
|
||||
let usedIncludeItemTypes: string | undefined;
|
||||
const logs: string[] = [];
|
||||
const writes: JellyfinCliResponse[] = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async (_session, _clientInfo, params) => {
|
||||
@@ -81,7 +91,7 @@ test('list handler resolves items using default library id', async () => {
|
||||
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
|
||||
},
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: (_path, response) => writes.push(response),
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -95,6 +105,7 @@ test('list handler resolves items using default library id', async () => {
|
||||
jellyfinLimit: 10,
|
||||
jellyfinRecursive: false,
|
||||
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
|
||||
jellyfinResponsePath: '/tmp/reply.json',
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
@@ -108,6 +119,7 @@ test('list handler resolves items using default library id', async () => {
|
||||
assert.equal(usedRecursive, false);
|
||||
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
|
||||
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
|
||||
assert.deepEqual(writes, [{ items: [{ id: 'item1', title: 'Episode 1', type: 'Episode' }] }]);
|
||||
});
|
||||
|
||||
test('list handler throws when items command has no library id', async () => {
|
||||
@@ -115,7 +127,7 @@ test('list handler throws when items command has no library id', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -144,7 +156,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: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -170,7 +182,7 @@ test('list handler throws when subtitle command has no item id', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -190,16 +202,13 @@ test('list handler throws when subtitle command has no item id', async () => {
|
||||
});
|
||||
|
||||
test('list handler writes preview auth payload to response path', async () => {
|
||||
const writes: Array<{
|
||||
path: string;
|
||||
payload: { serverUrl: string; accessToken: string; userId: string };
|
||||
}> = [];
|
||||
const writes: Array<{ path: string; payload: JellyfinCliResponse }> = [];
|
||||
const logs: string[] = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
writeJellyfinResponse: (responsePath, payload) => {
|
||||
writes.push({ path: responsePath, payload });
|
||||
},
|
||||
logInfo: (message) => logs.push(message),
|
||||
@@ -234,7 +243,7 @@ test('list handler throws when preview auth command has no response path', async
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
@@ -17,12 +18,6 @@ type JellyfinConfig = {
|
||||
defaultLibraryId: string;
|
||||
};
|
||||
|
||||
type JellyfinPreviewAuthPayload = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export function createHandleJellyfinListCommands(deps: {
|
||||
listJellyfinLibraries: (
|
||||
session: JellyfinSession,
|
||||
@@ -56,7 +51,7 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
deliveryUrl?: string | null;
|
||||
}>
|
||||
>;
|
||||
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
|
||||
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
@@ -66,13 +61,13 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
jellyfinConfig: JellyfinConfig;
|
||||
}): Promise<boolean> => {
|
||||
const { args, session, clientInfo, jellyfinConfig } = params;
|
||||
const responsePath = args.jellyfinResponsePath?.trim();
|
||||
|
||||
if (args.jellyfinPreviewAuth) {
|
||||
const responsePath = args.jellyfinResponsePath?.trim();
|
||||
if (!responsePath) {
|
||||
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
|
||||
}
|
||||
deps.writeJellyfinPreviewAuth(responsePath, {
|
||||
deps.writeJellyfinResponse(responsePath, {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
@@ -83,6 +78,15 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
|
||||
if (args.jellyfinLibraries) {
|
||||
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
|
||||
if (responsePath) {
|
||||
deps.writeJellyfinResponse(responsePath, {
|
||||
libraries: libraries.map((library) => ({
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
collectionType: library.collectionType || library.type || 'unknown',
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (libraries.length === 0) {
|
||||
deps.logInfo('No Jellyfin libraries found.');
|
||||
return true;
|
||||
@@ -109,6 +113,11 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
recursive: args.jellyfinRecursive,
|
||||
includeItemTypes: args.jellyfinIncludeItemTypes,
|
||||
});
|
||||
if (responsePath) {
|
||||
deps.writeJellyfinResponse(responsePath, {
|
||||
items: items.map((item) => ({ id: item.id, title: item.title, type: item.type })),
|
||||
});
|
||||
}
|
||||
if (items.length === 0) {
|
||||
deps.logInfo('No Jellyfin items found for the selected library/search.');
|
||||
return true;
|
||||
|
||||
@@ -32,8 +32,8 @@ export function createBuildHandleJellyfinListCommandsMainDepsHandler(
|
||||
deps.listJellyfinItems(session, clientInfo, params),
|
||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) =>
|
||||
deps.writeJellyfinPreviewAuth(responsePath, payload),
|
||||
writeJellyfinResponse: (responsePath, response) =>
|
||||
deps.writeJellyfinResponse(responsePath, response),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||
|
||||
type JellyfinConfigBase = {
|
||||
serverUrl?: string;
|
||||
@@ -37,6 +38,7 @@ export type RunJellyfinCommandMainDeps<TClientInfo, TConfig extends JellyfinConf
|
||||
clientInfo: TClientInfo;
|
||||
jellyfinConfig: TConfig;
|
||||
}) => Promise<boolean>;
|
||||
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||
};
|
||||
|
||||
export function createBuildRunJellyfinCommandMainDepsHandler<
|
||||
@@ -51,5 +53,7 @@ export function createBuildRunJellyfinCommandMainDepsHandler<
|
||||
handleRemoteAnnounceCommand: (args: CliArgs) => deps.handleRemoteAnnounceCommand(args),
|
||||
handleListCommands: (params) => deps.handleListCommands(params),
|
||||
handlePlayCommand: (params) => deps.handlePlayCommand(params),
|
||||
writeJellyfinResponse: (responsePath, response) =>
|
||||
deps.writeJellyfinResponse(responsePath, response),
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,7 @@ test('run jellyfin command returns after auth branch handles command', async ()
|
||||
calls.push('list');
|
||||
return false;
|
||||
},
|
||||
writeJellyfinResponse: () => {},
|
||||
handlePlayCommand: async () => {
|
||||
calls.push('play');
|
||||
return false;
|
||||
@@ -76,7 +77,8 @@ test('run jellyfin command returns after auth branch handles command', async ()
|
||||
assert.deepEqual(calls, ['auth']);
|
||||
});
|
||||
|
||||
test('run jellyfin command throws when session missing after auth', async () => {
|
||||
test('run jellyfin command throws when session missing and writes the error reply', async () => {
|
||||
const writes: Array<{ path: string; response: unknown }> = [];
|
||||
const run = createRunJellyfinCommandHandler({
|
||||
getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }),
|
||||
defaultServerUrl: '',
|
||||
@@ -84,10 +86,20 @@ test('run jellyfin command throws when session missing after auth', async () =>
|
||||
handleAuthCommands: async () => false,
|
||||
handleRemoteAnnounceCommand: async () => false,
|
||||
handleListCommands: async () => false,
|
||||
writeJellyfinResponse: (path, response) => writes.push({ path, response }),
|
||||
handlePlayCommand: async () => false,
|
||||
});
|
||||
|
||||
await assert.rejects(() => run(createArgs()), /Missing Jellyfin session/);
|
||||
await assert.rejects(
|
||||
() => run(createArgs({ jellyfinResponsePath: '/tmp/reply.json' })),
|
||||
/Missing Jellyfin session/,
|
||||
);
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
path: '/tmp/reply.json',
|
||||
response: { error: 'Missing Jellyfin session. Run --jellyfin-login first.' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('run jellyfin command dispatches remote/list/play in order until handled', async () => {
|
||||
@@ -116,6 +128,7 @@ test('run jellyfin command dispatches remote/list/play in order until handled',
|
||||
seenServerUrls.push(session.serverUrl);
|
||||
return true;
|
||||
},
|
||||
writeJellyfinResponse: () => {},
|
||||
handlePlayCommand: async () => {
|
||||
calls.push('play');
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||
|
||||
type JellyfinConfigBase = {
|
||||
serverUrl?: string;
|
||||
@@ -40,8 +41,9 @@ export function createRunJellyfinCommandHandler<
|
||||
clientInfo: TClientInfo;
|
||||
jellyfinConfig: TConfig;
|
||||
}) => Promise<boolean>;
|
||||
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||
}) {
|
||||
return async (args: CliArgs): Promise<void> => {
|
||||
const run = async (args: CliArgs): Promise<void> => {
|
||||
const jellyfinConfig = deps.getJellyfinConfig();
|
||||
const serverUrl =
|
||||
args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl;
|
||||
@@ -97,4 +99,23 @@ export function createRunJellyfinCommandHandler<
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// A launcher waiting on --jellyfin-response-path gets the failure reason instead of a timeout.
|
||||
return async (args: CliArgs): Promise<void> => {
|
||||
try {
|
||||
await run(args);
|
||||
} catch (error) {
|
||||
const responsePath = args.jellyfinResponsePath?.trim();
|
||||
if (responsePath) {
|
||||
try {
|
||||
deps.writeJellyfinResponse(responsePath, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} catch {
|
||||
// Keep the original failure; the launcher falls back to its timeout.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
preloadExternalSubtitles: (params) => deps.preloadExternalSubtitles(params),
|
||||
setActivePlayback: (state) => deps.setActivePlayback(state),
|
||||
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
||||
reportPlaying: (payload) => deps.reportPlaying(payload),
|
||||
createPlaybackReporter: (params) => deps.createPlaybackReporter(params),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { JellyfinPlaybackPlan } from '../../core/services/jellyfin';
|
||||
import type { JellyfinTimelinePlaybackState } from '../../core/services/jellyfin-playback-reporter';
|
||||
import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch';
|
||||
|
||||
type Deps = Parameters<typeof createPlayJellyfinItemInMpvHandler>[0];
|
||||
@@ -46,7 +47,13 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const events: string[] = [];
|
||||
const activeStates: Array<Parameters<Deps['setActivePlayback']>[0]> = [];
|
||||
const reports: Array<Parameters<Deps['reportPlaying']>[0]> = [];
|
||||
const reports: JellyfinTimelinePlaybackState[] = [];
|
||||
const reporterParams: Array<Parameters<Deps['createPlaybackReporter']>[0]> = [];
|
||||
const reporter = {
|
||||
reportPlaying: async (state: JellyfinTimelinePlaybackState) => void reports.push(state),
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
};
|
||||
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
@@ -67,7 +74,10 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
||||
events.push(`active:${String(state.loadedMediaPath)}`);
|
||||
},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: (payload) => reports.push(payload),
|
||||
createPlaybackReporter: (params) => {
|
||||
reporterParams.push(params);
|
||||
return reporter;
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
...options.deps,
|
||||
});
|
||||
@@ -77,6 +87,8 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
||||
events,
|
||||
activeStates,
|
||||
reports,
|
||||
reporter,
|
||||
reporterParams,
|
||||
play: (params: Partial<PlayParams> = {}) =>
|
||||
handler({
|
||||
session: baseSession,
|
||||
@@ -141,6 +153,8 @@ test('playback handler disables mpv subtitle selection, then loads media and rep
|
||||
assert.equal(harness.activeStates.length, 1);
|
||||
assert.equal(harness.activeStates[0]?.playMethod, 'DirectPlay');
|
||||
assert.equal(harness.activeStates[0]?.lastKnownPositionSeconds, 1.2);
|
||||
assert.equal(harness.activeStates[0]?.reporter, harness.reporter);
|
||||
assert.deepEqual(harness.reporterParams, [{ session: baseSession, clientInfo: baseClientInfo }]);
|
||||
assert.equal(harness.reports.length, 1);
|
||||
assert.equal(harness.reports[0]?.eventName, 'start');
|
||||
assert.equal(harness.reports[0]?.positionTicks, 12_000_000);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JellyfinAuthSession, JellyfinPlaybackPlan } from '../../core/services/jellyfin';
|
||||
import type { JellyfinConfig } from '../../types';
|
||||
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
|
||||
import type { JellyfinPlaybackReporterLike } from './jellyfin-remote-commands';
|
||||
|
||||
type JellyfinClientInfo = {
|
||||
clientName: string;
|
||||
@@ -10,6 +11,7 @@ type JellyfinClientInfo = {
|
||||
|
||||
type ActivePlaybackState = {
|
||||
itemId: string;
|
||||
reporter: JellyfinPlaybackReporterLike;
|
||||
mediaSourceId: undefined;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
@@ -134,16 +136,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
}) => void | Promise<void>;
|
||||
setActivePlayback: (state: ActivePlaybackState) => void;
|
||||
setLastProgressAtMs: (value: number) => void;
|
||||
reportPlaying: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId: undefined;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
positionTicks?: number;
|
||||
isPaused?: boolean;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'start';
|
||||
}) => void;
|
||||
// Reports go out as the device and account that started this playback, for CLI play and
|
||||
// casting alike, so Jellyfin keeps the resume point and played state either way.
|
||||
createPlaybackReporter: (params: {
|
||||
session: JellyfinAuthSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
}) => JellyfinPlaybackReporterLike;
|
||||
showMpvOsd: (text: string) => void;
|
||||
recordJellyfinPlaybackMetadata?: (
|
||||
metadata: JellyfinPlaybackStatsMetadata,
|
||||
@@ -210,8 +208,13 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
itemId: params.itemId,
|
||||
}),
|
||||
);
|
||||
const reporter = deps.createPlaybackReporter({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
});
|
||||
deps.setActivePlayback({
|
||||
itemId: params.itemId,
|
||||
reporter,
|
||||
mediaSourceId: undefined,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
@@ -237,7 +240,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
);
|
||||
deps.showVisibleOverlay();
|
||||
|
||||
deps.reportPlaying({
|
||||
void reporter.reportPlaying({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
playMethod,
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
type JellyfinRemotePlayHandlerDeps,
|
||||
} from './jellyfin-remote-commands';
|
||||
|
||||
const noopReporter = {
|
||||
reportPlaying: async () => {},
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
};
|
||||
|
||||
test('getConfiguredJellyfinSession returns null for incomplete config', () => {
|
||||
assert.equal(
|
||||
getConfiguredJellyfinSession({
|
||||
@@ -122,7 +128,11 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
|
||||
|
||||
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
|
||||
const { handlePlay, played } = makePlayHandler({
|
||||
getActivePlayback: () => ({ itemId: 'item-1', playMethod: 'DirectPlay' }),
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
reporter: noopReporter,
|
||||
}),
|
||||
});
|
||||
|
||||
await handlePlay({ ItemIds: ['item-1'] });
|
||||
@@ -207,6 +217,7 @@ test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices',
|
||||
const playback: ActiveJellyfinRemotePlaybackState = {
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
reporter: noopReporter,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { JellyfinTimelinePlaybackState } from '../../core/services/jellyfin-playback-reporter';
|
||||
|
||||
type JellyfinTimelineReport = (state: JellyfinTimelinePlaybackState) => Promise<unknown>;
|
||||
|
||||
export type JellyfinPlaybackReporterLike = {
|
||||
reportPlaying: JellyfinTimelineReport;
|
||||
reportProgress: JellyfinTimelineReport;
|
||||
reportStopped: JellyfinTimelineReport;
|
||||
};
|
||||
|
||||
export type ActiveJellyfinRemotePlaybackState = {
|
||||
itemId: string;
|
||||
// Bound at launch to the session that started playback, so reports flow for CLI play and
|
||||
// casting alike, independent of the cast-control websocket.
|
||||
reporter: JellyfinPlaybackReporterLike;
|
||||
mediaSourceId?: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
|
||||
@@ -53,7 +53,6 @@ export function createBuildReportJellyfinRemoteProgressMainDepsHandler(
|
||||
return (): JellyfinRemoteProgressReporterDeps => ({
|
||||
getActivePlayback: () => deps.getActivePlayback(),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
getSession: () => deps.getSession(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
getNow: () => deps.getNow(),
|
||||
getLastProgressAtMs: () => deps.getLastProgressAtMs(),
|
||||
@@ -70,7 +69,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
||||
return (): JellyfinRemoteStoppedReporterDeps => ({
|
||||
getActivePlayback: () => deps.getActivePlayback(),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
getSession: () => deps.getSession(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||
ticksPerSecond: deps.ticksPerSecond,
|
||||
|
||||
@@ -8,26 +8,31 @@ import {
|
||||
secondsToJellyfinTicks,
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from './jellyfin-remote-playback';
|
||||
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
|
||||
import type {
|
||||
ActiveJellyfinRemotePlaybackState,
|
||||
JellyfinPlaybackReporterLike,
|
||||
} from './jellyfin-remote-commands';
|
||||
|
||||
type ProgressDeps = Parameters<typeof createReportJellyfinRemoteProgressHandler>[0];
|
||||
type StoppedDeps = Parameters<typeof createReportJellyfinRemoteStoppedHandler>[0];
|
||||
type Session = NonNullable<ReturnType<ProgressDeps['getSession']>>;
|
||||
|
||||
const TICKS_PER_SECOND = 10_000_000;
|
||||
|
||||
const makeReporter = (
|
||||
overrides: Partial<JellyfinPlaybackReporterLike> = {},
|
||||
): JellyfinPlaybackReporterLike => ({
|
||||
reportPlaying: async () => {},
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makePlayback = (
|
||||
overrides: Partial<ActiveJellyfinRemotePlaybackState> = {},
|
||||
): ActiveJellyfinRemotePlaybackState => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSession = (overrides: Partial<Session> = {}): Session => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
reporter: makeReporter(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -35,7 +40,6 @@ function makeProgressDeps(overrides: Partial<ProgressDeps> = {}): ProgressDeps {
|
||||
return {
|
||||
getActivePlayback: () => makePlayback(),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => makeSession(),
|
||||
getMpvClient: () => ({ requestProperty: async () => 1 }),
|
||||
getNow: () => 5000,
|
||||
getLastProgressAtMs: () => 0,
|
||||
@@ -51,7 +55,6 @@ function makeStoppedDeps(overrides: Partial<StoppedDeps> = {}): StoppedDeps {
|
||||
return {
|
||||
getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8' }),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => makeSession(),
|
||||
getMpvClient: () => ({ currentTimePos: 0 }),
|
||||
ticksPerSecond: TICKS_PER_SECOND,
|
||||
logDebug: () => {},
|
||||
@@ -69,29 +72,21 @@ test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic seco
|
||||
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{ itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null },
|
||||
makePlayback({ loadedMediaPath: null }),
|
||||
'http://pve-main:8096/Videos/item/stream',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
||||
},
|
||||
makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item/stream' }),
|
||||
'http://pve-main:8096/Videos/item/stream',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
||||
},
|
||||
makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item/stream' }),
|
||||
'/tmp/local.mkv',
|
||||
),
|
||||
true,
|
||||
@@ -100,19 +95,21 @@ test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic seco
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
|
||||
let lastProgressAtMs = 0;
|
||||
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
|
||||
const reportPayloads: Array<{ itemId: string; positionTicks?: number; isPaused?: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
isPaused: payload.isPaused,
|
||||
});
|
||||
},
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
isPaused: payload.isPaused,
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
|
||||
@@ -135,43 +132,18 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
||||
assert.equal(lastProgressAtMs, 5000);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
|
||||
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
isConnected: () => false,
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
isPaused: payload.isPaused,
|
||||
});
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 42,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await reportProgress(true);
|
||||
|
||||
assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
|
||||
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
||||
const reportPayloads: Array<{ isPaused?: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({ isPaused: payload.isPaused });
|
||||
},
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({ isPaused: payload.isPaused });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
|
||||
@@ -188,11 +160,13 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
|
||||
let called = false;
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reportProgress: async () => {
|
||||
called = true;
|
||||
},
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async () => {
|
||||
called = true;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getNow: () => 4000,
|
||||
getLastProgressAtMs: () => 3500,
|
||||
@@ -207,18 +181,20 @@ test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during de
|
||||
let now = 5000;
|
||||
let lastProgressAtMs = 0;
|
||||
let position = 10;
|
||||
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
|
||||
const reportPayloads: Array<{ positionTicks?: number; eventName?: string }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
eventName: payload.eventName,
|
||||
});
|
||||
},
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
eventName: payload.eventName,
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: position,
|
||||
@@ -253,20 +229,23 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
makeStoppedDeps({
|
||||
getActivePlayback: () => makePlayback({ itemId: 'item-2', playMethod: 'Transcode' }),
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
itemId: 'item-2',
|
||||
playMethod: 'Transcode',
|
||||
reporter: makeReporter({
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 12.5,
|
||||
requestProperty: async () => {
|
||||
@@ -289,16 +268,18 @@ test('createReportJellyfinRemoteStoppedHandler clears aborted playback that neve
|
||||
let cleared = false;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
makeStoppedDeps({
|
||||
getActivePlayback: () => makePlayback({ loadedMediaPath: null }),
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
loadedMediaPath: null,
|
||||
reporter: makeReporter({
|
||||
reportStopped: async () => {
|
||||
throw new Error('should not report stopped for unloaded media');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reportStopped: async () => {
|
||||
throw new Error('should not report stopped for unloaded media');
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => null,
|
||||
}),
|
||||
);
|
||||
@@ -308,43 +289,6 @@ test('createReportJellyfinRemoteStoppedHandler clears aborted playback that neve
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
||||
let cleared = false;
|
||||
let stoppedPayload: {
|
||||
itemId: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
makeStoppedDeps({
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
isConnected: () => false,
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({ currentTimePos: 12.5 }),
|
||||
}),
|
||||
);
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.deepEqual(stoppedPayload, {
|
||||
itemId: 'item-1',
|
||||
positionTicks: 125_000_000,
|
||||
failed: false,
|
||||
});
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
|
||||
let cleared = false;
|
||||
const calls: Array<{ event: string; positionTicks?: number }> = [];
|
||||
@@ -354,19 +298,18 @@ test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv un
|
||||
makePlayback({
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
lastKnownPositionSeconds: 72.25,
|
||||
reporter: makeReporter({
|
||||
reportProgress: async (payload) => {
|
||||
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
|
||||
},
|
||||
reportStopped: async (payload) => {
|
||||
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reportProgress: async (payload) => {
|
||||
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
|
||||
},
|
||||
reportStopped: async (payload) => {
|
||||
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -423,16 +366,15 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
|
||||
makePlayback({
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
stopReportsAfterMs: 20_000,
|
||||
reporter: makeReporter({
|
||||
reportStopped: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reportStopped: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
getNow: () => 12_000,
|
||||
}),
|
||||
);
|
||||
@@ -445,34 +387,28 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
|
||||
const tracker = createJellyfinRemoteReportTracker();
|
||||
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
let releaseProgress: () => void = () => undefined;
|
||||
const progressGate = new Promise<void>((resolve) => {
|
||||
releaseProgress = resolve;
|
||||
});
|
||||
const session = {
|
||||
isConnected: () => true,
|
||||
reportProgress: async ({ eventName }: { eventName: string }) => {
|
||||
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
|
||||
if (calls.length === 1) await progressGate;
|
||||
return true;
|
||||
},
|
||||
reportStopped: async () => {
|
||||
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
let playback: ActiveJellyfinRemotePlaybackState | null = makePlayback({
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
||||
reporter: makeReporter({
|
||||
reportProgress: async ({ eventName }) => {
|
||||
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
|
||||
if (calls.length === 1) await progressGate;
|
||||
},
|
||||
reportStopped: async () => {
|
||||
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
|
||||
},
|
||||
}),
|
||||
});
|
||||
const shared = {
|
||||
getActivePlayback: () => playback,
|
||||
clearActivePlayback: () => {
|
||||
playback = null;
|
||||
},
|
||||
getSession: () => session,
|
||||
getMpvClient: () => ({ currentTimePos: 42 }),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => undefined,
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
|
||||
|
||||
type JellyfinRemoteSessionLike = {
|
||||
isConnected: () => boolean;
|
||||
reportProgress: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks: number;
|
||||
isPaused: boolean;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'TimeUpdate';
|
||||
}) => Promise<unknown>;
|
||||
reportStopped: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'stop';
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
currentTimePos?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
@@ -160,7 +136,6 @@ export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker
|
||||
export type JellyfinRemoteProgressReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
getSession: () => JellyfinRemoteSessionLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getNow: () => number;
|
||||
getLastProgressAtMs: () => number;
|
||||
@@ -179,9 +154,6 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
const report = async (force: boolean): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
const session = deps.getSession();
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) return;
|
||||
const now = deps.getNow();
|
||||
try {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
@@ -201,7 +173,7 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
return;
|
||||
}
|
||||
const paused = await mpvClient?.requestProperty?.('pause');
|
||||
await session.reportProgress({
|
||||
await playback.reporter.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||
@@ -228,7 +200,6 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
export type JellyfinRemoteStoppedReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
getSession: () => JellyfinRemoteSessionLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getNow?: () => number;
|
||||
ticksPerSecond: number;
|
||||
@@ -252,12 +223,6 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const session = deps.getSession();
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
// Clear before any network call so progress ticks fired during the stop find nothing to
|
||||
// report, then let reports already in flight finish so none can arrive after the stop.
|
||||
deps.clearActivePlayback();
|
||||
@@ -267,7 +232,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond);
|
||||
try {
|
||||
await session.reportProgress({
|
||||
await playback.reporter.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
@@ -280,7 +245,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
||||
}
|
||||
const reported = await session.reportStopped({
|
||||
const reported = await playback.reporter.reportStopped({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
|
||||
@@ -316,8 +316,9 @@ function installKeyboardTestGlobals() {
|
||||
shiftKey?: boolean;
|
||||
repeat?: boolean;
|
||||
target?: unknown;
|
||||
}): void {
|
||||
}): boolean {
|
||||
const listeners = documentListeners.get('keydown') ?? [];
|
||||
let prevented = false;
|
||||
const keyboardEvent = {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
@@ -326,12 +327,15 @@ function installKeyboardTestGlobals() {
|
||||
altKey: event.altKey ?? false,
|
||||
shiftKey: event.shiftKey ?? false,
|
||||
repeat: event.repeat ?? false,
|
||||
preventDefault: () => {},
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
target: event.target ?? null,
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(keyboardEvent);
|
||||
}
|
||||
return prevented;
|
||||
}
|
||||
|
||||
function dispatchMousedown(event: {
|
||||
@@ -485,6 +489,12 @@ function installKeyboardTestGlobals() {
|
||||
: null;
|
||||
return target;
|
||||
},
|
||||
createTextInputTarget: () => {
|
||||
const target = new TestElement();
|
||||
target.tagName = 'INPUT';
|
||||
target.closest = (selector: string) => (selector.includes('input') ? target : null);
|
||||
return target;
|
||||
},
|
||||
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
|
||||
getSessionBindingsImpl = value;
|
||||
},
|
||||
@@ -514,6 +524,7 @@ function createKeyboardHandlerHarness() {
|
||||
let openControllerDebugCount = 0;
|
||||
let playlistBrowserKeydownCount = 0;
|
||||
let changelogKeydownCount = 0;
|
||||
let jimakuKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
@@ -549,7 +560,10 @@ function createKeyboardHandlerHarness() {
|
||||
handleCharacterDictionaryKeydown: () => false,
|
||||
handleSubsyncKeydown: () => false,
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
handleJimakuKeydown: () => {
|
||||
jimakuKeydownCount += 1;
|
||||
return false;
|
||||
},
|
||||
handleTsukihimeKeydown: () => false,
|
||||
handleMediaTimingReviewKeydown: () => {
|
||||
mediaTimingReviewKeydownCount += 1;
|
||||
@@ -590,6 +604,7 @@ function createKeyboardHandlerHarness() {
|
||||
openControllerDebugCount: () => openControllerDebugCount,
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
changelogKeydownCount: () => changelogKeydownCount,
|
||||
jimakuKeydownCount: () => jimakuKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -1482,6 +1497,59 @@ test('keyboard mode: changelog modal handles h/l fold keys before lookup control
|
||||
}
|
||||
});
|
||||
|
||||
const KEYBOARD_MODE_LOOKUP_KEYS = [
|
||||
{ key: 'h', code: 'KeyH' },
|
||||
{ key: 'l', code: 'KeyL' },
|
||||
{ key: 'ArrowLeft', code: 'ArrowLeft' },
|
||||
{ key: 'ArrowRight', code: 'ArrowRight' },
|
||||
];
|
||||
|
||||
test('keyboard mode: an open modal receives h/l/arrows instead of lookup controls', async () => {
|
||||
const { ctx, testGlobals, handlers, jimakuKeydownCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.jimakuModalOpen = true;
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
const titleInput = testGlobals.createTextInputTarget();
|
||||
|
||||
for (const key of KEYBOARD_MODE_LOOKUP_KEYS) {
|
||||
const prevented = testGlobals.dispatchKeydown({ ...key, target: titleInput });
|
||||
assert.equal(prevented, false, `${key.code} must reach the focused modal input`);
|
||||
}
|
||||
await wait(0);
|
||||
|
||||
assert.equal(jimakuKeydownCount(), KEYBOARD_MODE_LOOKUP_KEYS.length);
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: h/l/arrows typed into a text field are not consumed by lookup controls', async () => {
|
||||
const { ctx, testGlobals, handlers, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
// No words means lookup controls would seek mpv to the adjacent subtitle.
|
||||
setWordCount(0);
|
||||
const input = testGlobals.createTextInputTarget();
|
||||
|
||||
for (const key of KEYBOARD_MODE_LOOKUP_KEYS) {
|
||||
const prevented = testGlobals.dispatchKeydown({ ...key, target: input });
|
||||
assert.equal(prevented, false, `${key.code} must reach the text field`);
|
||||
}
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -1748,7 +1816,7 @@ test('keyboard mode: visible-layer Ctrl+Shift+Y should not be toggled by rendere
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: modal-layer Ctrl+Shift+Y still toggles via renderer keydown', async () => {
|
||||
test('keyboard mode: modal layer never enables keyboard mode', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
@@ -1756,9 +1824,10 @@ test('keyboard mode: modal-layer Ctrl+Shift+Y still toggles via renderer keydown
|
||||
ctx.platform.isModalLayer = true;
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
||||
// Controller toggle path.
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
|
||||
@@ -408,11 +408,6 @@ export function createKeyboardHandlers(
|
||||
return e.ctrlKey || e.metaKey;
|
||||
}
|
||||
|
||||
function isKeyboardDrivenModeToggle(e: KeyboardEvent): boolean {
|
||||
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
||||
return isPrimaryModifierPressed(e) && !e.altKey && e.shiftKey && isYKey && !e.repeat;
|
||||
}
|
||||
|
||||
function isLookupWindowToggle(e: KeyboardEvent): boolean {
|
||||
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||
@@ -728,7 +723,10 @@ export function createKeyboardHandlers(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keyboard mode drives the visible overlay's subtitle words. The modal layer hides
|
||||
// subtitles, so enabling it there only lets arrows/H/L seek mpv from behind a modal.
|
||||
function handleKeyboardModeToggleRequested(): void {
|
||||
if (ctx.platform.isModalLayer) return;
|
||||
toggleKeyboardDrivenMode();
|
||||
}
|
||||
|
||||
@@ -1149,12 +1147,6 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
||||
e.preventDefault();
|
||||
handleKeyboardModeToggleRequested();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLookupWindowToggle(e)) {
|
||||
e.preventDefault();
|
||||
handleLookupWindowToggleRequested();
|
||||
@@ -1167,19 +1159,11 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
|
||||
// arrows/H/L for folding, and those would otherwise move the subtitle word
|
||||
// selection (and seek mpv) behind the open modal.
|
||||
if (ctx.state.changelogModalOpen) {
|
||||
options.handleChangelogKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
return;
|
||||
@@ -1227,6 +1211,13 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
// After open modals and text fields, which own arrows/H/L; before Yomitan popup
|
||||
// keybinds so word navigation keeps working while the popup is open.
|
||||
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlePendingNumericSelection(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Structured replies the app writes to `--jellyfin-response-path`. The launcher passes a temp
|
||||
// path and waits for the file instead of scraping logs, because the command usually runs in an
|
||||
// already-running app instance whose output never reaches the launcher's helper process.
|
||||
|
||||
export type JellyfinCliLibrary = {
|
||||
id: string;
|
||||
name: string;
|
||||
collectionType: string;
|
||||
};
|
||||
|
||||
export type JellyfinCliItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type JellyfinPreviewAuthPayload = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type JellyfinCliResponse =
|
||||
| { libraries: JellyfinCliLibrary[] }
|
||||
| { items: JellyfinCliItem[] }
|
||||
| { error: string }
|
||||
| JellyfinPreviewAuthPayload;
|
||||
|
||||
// Writes via rename so a polling reader never parses a half-written file.
|
||||
export function writeJellyfinCliResponse(
|
||||
responsePath: string,
|
||||
response: JellyfinCliResponse,
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
const tempPath = `${responsePath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(response), 'utf-8');
|
||||
fs.renameSync(tempPath, responsePath);
|
||||
}
|
||||
Reference in new issue
Block a user