mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-22 05:01:24 -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;
|
||||
|
||||
Reference in new issue
Block a user