feat(jellyfin): add remote playback and config plumbing

This commit is contained in:
2026-02-17 19:00:18 -08:00
parent a6a28f52f3
commit e38a1c945e
42 changed files with 5608 additions and 1013 deletions

View File

@@ -32,6 +32,15 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
texthooker: false,
help: false,
autoStartOverlay: false,
@@ -147,6 +156,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openAnilistSetup: () => {
calls.push("openAnilistSetup");
},
openJellyfinSetup: () => {
calls.push("openJellyfinSetup");
},
getAnilistQueueStatus: () => ({
pending: 2,
ready: 1,
@@ -158,6 +170,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push("retryAnilistQueue");
return { ok: true, message: "AniList retry processed." };
},
runJellyfinCommand: async () => {
calls.push("runJellyfinCommand");
},
printHelp: () => {
calls.push("printHelp");
},
@@ -187,8 +202,13 @@ test("handleCliCommand ignores --start for second-instance without actions", ()
handleCliCommand(args, "second-instance", deps);
assert.ok(calls.includes("log:Ignoring --start because SubMiner is already running."));
assert.equal(calls.some((value) => value.includes("connectMpvClient")), false);
assert.ok(
calls.includes("log:Ignoring --start because SubMiner is already running."),
);
assert.equal(
calls.some((value) => value.includes("connectMpvClient")),
false,
);
});
test("handleCliCommand runs texthooker flow with browser open", () => {
@@ -198,9 +218,7 @@ test("handleCliCommand runs texthooker flow with browser open", () => {
handleCliCommand(args, "initial", deps);
assert.ok(calls.includes("ensureTexthookerRunning:5174"));
assert.ok(
calls.includes("openTexthookerInBrowser:http://127.0.0.1:5174"),
);
assert.ok(calls.includes("openTexthookerInBrowser:http://127.0.0.1:5174"));
});
test("handleCliCommand reports async mine errors to OSD", async () => {
@@ -213,7 +231,9 @@ test("handleCliCommand reports async mine errors to OSD", async () => {
handleCliCommand(makeArgs({ mineSentence: true }), "initial", deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith("error:mineSentenceCard failed:")));
assert.ok(
calls.some((value) => value.startsWith("error:mineSentenceCard failed:")),
);
assert.ok(osd.some((value) => value.includes("Mine sentence failed: boom")));
});
@@ -247,7 +267,10 @@ test("handleCliCommand warns when texthooker port override used while running",
"warn:Ignoring --port override because the texthooker server is already running.",
),
);
assert.equal(calls.some((value) => value === "setTexthookerPort:9999"), false);
assert.equal(
calls.some((value) => value === "setTexthookerPort:9999"),
false,
);
});
test("handleCliCommand prints help and stops app when no window exists", () => {
@@ -272,9 +295,13 @@ test("handleCliCommand reports async trigger-subsync errors to OSD", async () =>
await new Promise((resolve) => setImmediate(resolve));
assert.ok(
calls.some((value) => value.startsWith("error:triggerSubsyncFromConfig failed:")),
calls.some((value) =>
value.startsWith("error:triggerSubsyncFromConfig failed:"),
),
);
assert.ok(
osd.some((value) => value.includes("Subsync failed: subsync boom")),
);
assert.ok(osd.some((value) => value.includes("Subsync failed: subsync boom")));
});
test("handleCliCommand stops app for --stop command", () => {
@@ -292,7 +319,10 @@ test("handleCliCommand still runs non-start actions on second-instance", () => {
deps,
);
assert.ok(calls.includes("toggleVisibleOverlay"));
assert.equal(calls.some((value) => value === "connectMpvClient"), true);
assert.equal(
calls.some((value) => value === "connectMpvClient"),
true,
);
});
test("handleCliCommand handles visibility and utility command dispatches", () => {
@@ -300,22 +330,44 @@ test("handleCliCommand handles visibility and utility command dispatches", () =>
args: Partial<CliArgs>;
expected: string;
}> = [
{ args: { toggleInvisibleOverlay: true }, expected: "toggleInvisibleOverlay" },
{
args: { toggleInvisibleOverlay: true },
expected: "toggleInvisibleOverlay",
},
{ args: { settings: true }, expected: "openYomitanSettingsDelayed:1000" },
{ args: { showVisibleOverlay: true }, expected: "setVisibleOverlayVisible:true" },
{ args: { hideVisibleOverlay: true }, expected: "setVisibleOverlayVisible:false" },
{ args: { showInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:true" },
{ args: { hideInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:false" },
{
args: { showVisibleOverlay: true },
expected: "setVisibleOverlayVisible:true",
},
{
args: { hideVisibleOverlay: true },
expected: "setVisibleOverlayVisible:false",
},
{
args: { showInvisibleOverlay: true },
expected: "setInvisibleOverlayVisible:true",
},
{
args: { hideInvisibleOverlay: true },
expected: "setInvisibleOverlayVisible:false",
},
{ args: { copySubtitle: true }, expected: "copyCurrentSubtitle" },
{ args: { copySubtitleMultiple: true }, expected: "startPendingMultiCopy:2500" },
{
args: { copySubtitleMultiple: true },
expected: "startPendingMultiCopy:2500",
},
{
args: { mineSentenceMultiple: true },
expected: "startPendingMineSentenceMultiple:2500",
},
{ args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" },
{ args: { openRuntimeOptions: true }, expected: "openRuntimeOptionsPalette" },
{
args: { openRuntimeOptions: true },
expected: "openRuntimeOptionsPalette",
},
{ args: { anilistLogout: true }, expected: "clearAnilistToken" },
{ args: { anilistSetup: true }, expected: "openAnilistSetup" },
{ args: { jellyfin: true }, expected: "openJellyfinSetup" },
];
for (const entry of cases) {
@@ -331,7 +383,9 @@ test("handleCliCommand handles visibility and utility command dispatches", () =>
test("handleCliCommand logs AniList status details", () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), "initial", deps);
assert.ok(calls.some((value) => value.startsWith("log:AniList token status:")));
assert.ok(
calls.some((value) => value.startsWith("log:AniList token status:")),
);
assert.ok(calls.some((value) => value.startsWith("log:AniList queue:")));
});
@@ -342,6 +396,57 @@ test("handleCliCommand runs AniList retry command", async () => {
assert.ok(calls.includes("retryAnilistQueue"));
assert.ok(calls.includes("log:AniList retry processed."));
});
test("handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands", () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true },
{ copySubtitle: true },
{ toggleVisibleOverlay: true },
];
for (const args of nonJellyfinArgs) {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs(args), "initial", deps);
const runJellyfinCallCount = calls.filter(
(value) => value === "runJellyfinCommand",
).length;
assert.equal(
runJellyfinCallCount,
0,
`Unexpected Jellyfin dispatch for args ${JSON.stringify(args)}`,
);
}
});
test("handleCliCommand runs jellyfin command dispatcher", async () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ jellyfinLibraries: true }), "initial", deps);
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), "initial", deps);
await new Promise((resolve) => setImmediate(resolve));
const runJellyfinCallCount = calls.filter(
(value) => value === "runJellyfinCommand",
).length;
assert.equal(runJellyfinCallCount, 2);
});
test("handleCliCommand reports jellyfin command errors to OSD", async () => {
const { deps, calls, osd } = createDeps({
runJellyfinCommand: async () => {
throw new Error("server offline");
},
});
handleCliCommand(makeArgs({ jellyfinLibraries: true }), "initial", deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(
calls.some((value) => value.startsWith("error:runJellyfinCommand failed:")),
);
assert.ok(
osd.some((value) => value.includes("Jellyfin command failed: server offline")),
);
});
test("handleCliCommand runs refresh-known-words command", () => {
const { deps, calls } = createDeps();
@@ -363,5 +468,9 @@ test("handleCliCommand reports async refresh-known-words errors to OSD", async (
assert.ok(
calls.some((value) => value.startsWith("error:refreshKnownWords failed:")),
);
assert.ok(osd.some((value) => value.includes("Refresh known words failed: refresh boom")));
assert.ok(
osd.some((value) =>
value.includes("Refresh known words failed: refresh boom"),
),
);
});

View File

@@ -49,6 +49,7 @@ export interface CliCommandServiceDeps {
};
clearAnilistToken: () => void;
openAnilistSetup: () => void;
openJellyfinSetup: () => void;
getAnilistQueueStatus: () => {
pending: number;
ready: number;
@@ -57,6 +58,7 @@ export interface CliCommandServiceDeps {
lastError: string | null;
};
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
printHelp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
@@ -138,6 +140,10 @@ export interface CliCommandDepsRuntimeOptions {
overlay: OverlayCliRuntime;
mining: MiningCliRuntime;
anilist: AnilistCliRuntime;
jellyfin: {
openSetup: () => void;
runCommand: (args: CliArgs) => Promise<void>;
};
ui: UiCliRuntime;
app: AppCliRuntime;
getMultiCopyTimeoutMs: () => number;
@@ -201,8 +207,10 @@ export function createCliCommandDepsRuntime(
getAnilistStatus: options.anilist.getStatus,
clearAnilistToken: options.anilist.clearToken,
openAnilistSetup: options.anilist.openSetup,
openJellyfinSetup: options.jellyfin.openSetup,
getAnilistQueueStatus: options.anilist.getQueueStatus,
retryAnilistQueue: options.anilist.retryQueueNow,
runJellyfinCommand: options.jellyfin.runCommand,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
@@ -262,9 +270,18 @@ export function handleCliCommand(
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce ||
args.texthooker ||
args.help;
const ignoreStartOnly = source === "second-instance" && args.start && !hasNonStartAction;
const ignoreStartOnly =
source === "second-instance" && args.start && !hasNonStartAction;
if (ignoreStartOnly) {
deps.log("Ignoring --start because SubMiner is already running.");
return;
@@ -402,6 +419,9 @@ export function handleCliCommand(
} else if (args.anilistSetup) {
deps.openAnilistSetup();
deps.log("Opened AniList setup flow.");
} else if (args.jellyfin) {
deps.openJellyfinSetup();
deps.log("Opened Jellyfin setup flow.");
} else if (args.anilistRetryQueue) {
const queueStatus = deps.getAnilistQueueStatus();
deps.log(
@@ -417,6 +437,21 @@ export function handleCliCommand(
"retryAnilistQueue",
"AniList retry failed",
);
} else if (
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce
) {
runAsyncWithOsd(
() => deps.runJellyfinCommand(args),
deps,
"runJellyfinCommand",
"Jellyfin command failed",
);
} else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort);

View File

@@ -20,10 +20,11 @@ export {
triggerFieldGrouping,
updateLastCardFromClipboard,
} from "./mining";
export { createAppLifecycleDepsRuntime, startAppLifecycle } from "./app-lifecycle";
export {
cycleSecondarySubMode,
} from "./subtitle-position";
createAppLifecycleDepsRuntime,
startAppLifecycle,
} from "./app-lifecycle";
export { cycleSecondarySubMode } from "./subtitle-position";
export {
getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime,
@@ -92,9 +93,24 @@ export { handleMpvCommandFromIpc } from "./ipc-command";
export { createFieldGroupingOverlayRuntime } from "./field-grouping-overlay";
export { createNumericShortcutRuntime } from "./numeric-shortcut";
export { runStartupBootstrapRuntime } from "./startup";
export { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime } from "./subsync-runner";
export {
runSubsyncManualFromIpcRuntime,
triggerSubsyncFromConfigRuntime,
} from "./subsync-runner";
export { registerAnkiJimakuIpcRuntime } from "./anki-jimaku";
export { ImmersionTrackerService } from "./immersion-tracker-service";
export {
authenticateWithPassword as authenticateWithPasswordRuntime,
listItems as listJellyfinItemsRuntime,
listLibraries as listJellyfinLibrariesRuntime,
listSubtitleTracks as listJellyfinSubtitleTracksRuntime,
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime,
} from "./jellyfin";
export {
buildJellyfinTimelinePayload,
JellyfinRemoteSessionService,
} from "./jellyfin-remote";
export {
broadcastRuntimeOptionsChangedRuntime,
createOverlayManager,

View File

@@ -0,0 +1,334 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildJellyfinTimelinePayload,
JellyfinRemoteSessionService,
} from "./jellyfin-remote";
class FakeWebSocket {
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
on(event: string, listener: (...args: unknown[]) => void): this {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(listener);
return this;
}
close(): void {
this.emit("close");
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners[event] ?? []) {
listener(...args);
}
}
}
test("Jellyfin remote service has no traffic until started", async () => {
let socketCreateCount = 0;
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local:8096",
accessToken: "token-0",
deviceId: "device-0",
webSocketFactory: () => {
socketCreateCount += 1;
return 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,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(socketCreateCount, 0);
assert.equal(fetchCalls.length, 0);
assert.equal(service.isConnected(), false);
});
test("start posts capabilities on socket connect", async () => {
const sockets: FakeWebSocket[] = [];
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local:8096",
accessToken: "token-1",
deviceId: "device-1",
webSocketFactory: (url) => {
assert.equal(url, "ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1");
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
service.start();
sockets[0].emit("open");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(fetchCalls.length, 1);
assert.equal(
fetchCalls[0].input,
"http://jellyfin.local:8096/Sessions/Capabilities/Full",
);
assert.equal(service.isConnected(), true);
});
test("socket headers include jellyfin authorization metadata", () => {
const seenHeaders: Record<string, string>[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local:8096",
accessToken: "token-auth",
deviceId: "device-auth",
clientName: "SubMiner",
clientVersion: "0.1.0",
deviceName: "SubMiner",
socketHeadersFactory: (_url, headers) => {
seenHeaders.push(headers);
return new FakeWebSocket() as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
});
service.start();
assert.equal(seenHeaders.length, 1);
assert.ok(seenHeaders[0].Authorization.includes('Client="SubMiner"'));
assert.ok(seenHeaders[0].Authorization.includes('DeviceId="device-auth"'));
assert.ok(seenHeaders[0]["X-Emby-Authorization"]);
});
test("dispatches inbound Play, Playstate, and GeneralCommand messages", () => {
const sockets: FakeWebSocket[] = [];
const playPayloads: unknown[] = [];
const playstatePayloads: unknown[] = [];
const commandPayloads: unknown[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-2",
deviceId: "device-2",
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
onPlay: (payload) => playPayloads.push(payload),
onPlaystate: (payload) => playstatePayloads.push(payload),
onGeneralCommand: (payload) => commandPayloads.push(payload),
});
service.start();
const socket = sockets[0];
socket.emit(
"message",
JSON.stringify({ MessageType: "Play", Data: { ItemId: "movie-1" } }),
);
socket.emit(
"message",
JSON.stringify({ MessageType: "Playstate", Data: JSON.stringify({ Command: "Pause" }) }),
);
socket.emit(
"message",
Buffer.from(
JSON.stringify({
MessageType: "GeneralCommand",
Data: { Name: "DisplayMessage" },
}),
"utf8",
),
);
assert.deepEqual(playPayloads, [{ ItemId: "movie-1" }]);
assert.deepEqual(playstatePayloads, [{ Command: "Pause" }]);
assert.deepEqual(commandPayloads, [{ Name: "DisplayMessage" }]);
});
test("schedules reconnect with bounded exponential backoff", () => {
const sockets: FakeWebSocket[] = [];
const delays: number[] = [];
const pendingTimers: Array<() => void> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-3",
deviceId: "device-3",
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
reconnectBaseDelayMs: 100,
reconnectMaxDelayMs: 400,
setTimer: ((handler: () => void, delay?: number) => {
pendingTimers.push(handler);
delays.push(Number(delay));
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => {
return;
}) as typeof clearTimeout,
});
service.start();
sockets[0].emit("close");
pendingTimers.shift()?.();
sockets[1].emit("close");
pendingTimers.shift()?.();
sockets[2].emit("close");
pendingTimers.shift()?.();
sockets[3].emit("close");
assert.deepEqual(delays, [100, 200, 400, 400]);
assert.equal(sockets.length, 4);
});
test("Jellyfin remote stop prevents further reconnect/network activity", () => {
const sockets: FakeWebSocket[] = [];
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const pendingTimers: Array<() => void> = [];
const clearedTimers: unknown[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-stop",
deviceId: "device-stop",
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 ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
setTimer: ((handler: () => void) => {
pendingTimers.push(handler);
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: ((timer) => {
clearedTimers.push(timer);
}) as typeof clearTimeout,
});
service.start();
assert.equal(sockets.length, 1);
sockets[0].emit("close");
assert.equal(pendingTimers.length, 1);
service.stop();
for (const reconnect of pendingTimers) reconnect();
assert.ok(clearedTimers.length >= 1);
assert.equal(sockets.length, 1);
assert.equal(fetchCalls.length, 0);
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 = JSON.parse(JSON.stringify(expectedPayload));
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("advertiseNow validates server registration using Sessions endpoint", async () => {
const sockets: FakeWebSocket[] = [];
const calls: string[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-5",
deviceId: "device-5",
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/Sessions")) {
return new Response(
JSON.stringify([{ DeviceId: "device-5" }]),
{ status: 200 },
);
}
return new Response(null, { status: 200 });
}) as typeof fetch,
});
service.start();
sockets[0].emit("open");
const ok = await service.advertiseNow();
assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith("/Sessions")));
});

View File

@@ -0,0 +1,448 @@
import WebSocket from "ws";
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;
}
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;
EventName: string;
}
interface JellyfinRemoteSocket {
on(event: "open", listener: () => void): this;
on(event: "close", listener: () => void): this;
on(event: "error", listener: (error: Error) => void): this;
on(event: "message", listener: (data: unknown) => void): this;
close(): void;
}
type JellyfinRemoteSocketHeaders = Record<string, string>;
export interface JellyfinRemoteSessionServiceOptions {
serverUrl: string;
accessToken: string;
deviceId: string;
capabilities?: {
PlayableMediaTypes?: string;
SupportedCommands?: string;
SupportsMediaControl?: boolean;
};
onPlay?: (payload: unknown) => void;
onPlaystate?: (payload: unknown) => void;
onGeneralCommand?: (payload: unknown) => void;
fetchImpl?: typeof fetch;
webSocketFactory?: (url: string) => JellyfinRemoteSocket;
socketHeadersFactory?: (
url: string,
headers: JellyfinRemoteSocketHeaders,
) => JellyfinRemoteSocket;
setTimer?: typeof setTimeout;
clearTimer?: typeof clearTimeout;
reconnectBaseDelayMs?: number;
reconnectMaxDelayMs?: number;
clientName?: string;
clientVersion?: string;
deviceName?: string;
onConnected?: () => void;
onDisconnected?: () => void;
}
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();
if (!trimmed) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | null {
const serialized =
typeof rawData === "string"
? rawData
: Buffer.isBuffer(rawData)
? rawData.toString("utf8")
: null;
if (!serialized) return null;
try {
const parsed = JSON.parse(serialized) as JellyfinRemoteSessionMessage;
if (!parsed || typeof parsed !== "object") return null;
return parsed;
} catch {
return null;
}
}
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;
SupportsMediaControl: boolean;
} {
return {
PlayableMediaTypes: "Video,Audio",
SupportedCommands:
"Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent",
SupportsMediaControl: true,
};
}
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,
EventName: state.eventName || "timeupdate",
};
}
export class JellyfinRemoteSessionService {
private readonly serverUrl: string;
private readonly accessToken: string;
private readonly deviceId: string;
private readonly fetchImpl: typeof fetch;
private readonly webSocketFactory?: (url: string) => JellyfinRemoteSocket;
private readonly socketHeadersFactory?: (
url: string,
headers: JellyfinRemoteSocketHeaders,
) => JellyfinRemoteSocket;
private readonly setTimer: typeof setTimeout;
private readonly clearTimer: typeof clearTimeout;
private readonly onPlay?: (payload: unknown) => void;
private readonly onPlaystate?: (payload: unknown) => void;
private readonly onGeneralCommand?: (payload: unknown) => void;
private readonly capabilities: {
PlayableMediaTypes: string;
SupportedCommands: string;
SupportsMediaControl: boolean;
};
private readonly authHeader: string;
private readonly onConnected?: () => void;
private readonly onDisconnected?: () => void;
private readonly reconnectBaseDelayMs: number;
private readonly reconnectMaxDelayMs: number;
private socket: JellyfinRemoteSocket | null = null;
private running = false;
private connected = false;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
constructor(options: JellyfinRemoteSessionServiceOptions) {
this.serverUrl = normalizeServerUrl(options.serverUrl);
this.accessToken = options.accessToken;
this.deviceId = options.deviceId;
this.fetchImpl = options.fetchImpl ?? fetch;
this.webSocketFactory = options.webSocketFactory;
this.socketHeadersFactory = options.socketHeadersFactory;
this.setTimer = options.setTimer ?? setTimeout;
this.clearTimer = options.clearTimer ?? clearTimeout;
this.onPlay = options.onPlay;
this.onPlaystate = options.onPlaystate;
this.onGeneralCommand = options.onGeneralCommand;
this.capabilities = {
...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,
accessToken: this.accessToken,
});
this.onConnected = options.onConnected;
this.onDisconnected = options.onDisconnected;
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
this.reconnectMaxDelayMs = Math.max(
this.reconnectBaseDelayMs,
options.reconnectMaxDelayMs ?? 10_000,
);
}
public start(): void {
if (this.running) return;
this.running = true;
this.reconnectAttempt = 0;
this.connectSocket();
}
public stop(): void {
this.running = false;
this.connected = false;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
public isConnected(): boolean {
return this.connected;
}
public async advertiseNow(): Promise<boolean> {
await this.postCapabilities();
return this.isRegisteredOnServer();
}
public async reportPlaying(
state: JellyfinTimelinePlaybackState,
): Promise<boolean> {
return this.postTimeline("/Sessions/Playing", {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || "start",
});
}
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),
EventName: state.eventName || "stop",
});
}
private connectSocket(): void {
if (!this.running) return;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
const socket = this.createSocket(this.createSocketUrl());
this.socket = socket;
let disconnected = false;
socket.on("open", () => {
if (this.socket !== socket || !this.running) return;
this.connected = true;
this.reconnectAttempt = 0;
this.onConnected?.();
void this.postCapabilities();
});
socket.on("message", (rawData) => {
this.handleInboundMessage(rawData);
});
const handleDisconnect = () => {
if (disconnected) return;
disconnected = true;
if (this.socket === socket) {
this.socket = null;
}
this.connected = false;
this.onDisconnected?.();
if (this.running) {
this.scheduleReconnect();
}
};
socket.on("close", handleDisconnect);
socket.on("error", handleDisconnect);
}
private scheduleReconnect(): void {
const delay = Math.min(
this.reconnectMaxDelayMs,
this.reconnectBaseDelayMs * 2 ** this.reconnectAttempt,
);
this.reconnectAttempt += 1;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
}
this.reconnectTimer = this.setTimer(() => {
this.reconnectTimer = null;
this.connectSocket();
}, delay);
}
private createSocketUrl(): string {
const baseUrl = new URL(`${this.serverUrl}/`);
const socketUrl = new URL("/socket", baseUrl);
socketUrl.protocol = baseUrl.protocol === "https:" ? "wss:" : "ws:";
socketUrl.searchParams.set("api_key", this.accessToken);
socketUrl.searchParams.set("deviceId", this.deviceId);
return socketUrl.toString();
}
private createSocket(url: string): JellyfinRemoteSocket {
const headers: JellyfinRemoteSocketHeaders = {
Authorization: this.authHeader,
"X-Emby-Authorization": this.authHeader,
"X-Emby-Token": this.accessToken,
};
if (this.socketHeadersFactory) {
return this.socketHeadersFactory(url, headers);
}
if (this.webSocketFactory) {
return this.webSocketFactory(url);
}
return new WebSocket(url, { headers }) as unknown as JellyfinRemoteSocket;
}
private async postCapabilities(): Promise<void> {
const payload = this.capabilities;
const fullEndpointOk = await this.postJson(
"/Sessions/Capabilities/Full",
payload,
);
if (fullEndpointOk) return;
await this.postJson("/Sessions/Capabilities", payload);
}
private async isRegisteredOnServer(): Promise<boolean> {
try {
const response = await this.fetchImpl(`${this.serverUrl}/Sessions`, {
method: "GET",
headers: {
Authorization: this.authHeader,
"X-Emby-Authorization": this.authHeader,
"X-Emby-Token": this.accessToken,
},
});
if (!response.ok) return false;
const sessions = (await response.json()) as Array<Record<string, unknown>>;
return sessions.some(
(session) => String(session.DeviceId || "") === this.deviceId,
);
} catch {
return false;
}
}
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,
"X-Emby-Authorization": this.authHeader,
"X-Emby-Token": this.accessToken,
},
body: JSON.stringify(payload),
});
return response.ok;
} catch {
return false;
}
}
private handleInboundMessage(rawData: unknown): void {
const message = parseInboundMessage(rawData);
if (!message) return;
const messageType = message.MessageType;
const payload = parseMessageData(message.Data);
if (messageType === "Play") {
this.onPlay?.(payload);
return;
}
if (messageType === "Playstate") {
this.onPlaystate?.(payload);
return;
}
if (messageType === "GeneralCommand") {
this.onGeneralCommand?.(payload);
}
}
}

View File

@@ -0,0 +1,702 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
authenticateWithPassword,
listItems,
listLibraries,
listSubtitleTracks,
resolvePlaybackPlan,
ticksToSeconds,
} from "./jellyfin";
const clientInfo = {
deviceId: "subminer-test",
clientName: "SubMiner",
clientVersion: "0.1.0-test",
};
test("authenticateWithPassword returns token and user", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /Users\/AuthenticateByName$/);
return new Response(
JSON.stringify({
AccessToken: "abc123",
User: { Id: "user-1" },
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const session = await authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"pw",
clientInfo,
);
assert.equal(session.serverUrl, "http://jellyfin.local:8096");
assert.equal(session.accessToken, "abc123");
assert.equal(session.userId, "user-1");
} finally {
globalThis.fetch = originalFetch;
}
});
test("listLibraries maps server response", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Items: [
{
Id: "lib-1",
Name: "TV",
CollectionType: "tvshows",
Type: "CollectionFolder",
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const libraries = await listLibraries(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
);
assert.deepEqual(libraries, [
{
id: "lib-1",
name: "TV",
collectionType: "tvshows",
type: "CollectionFolder",
},
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test("listItems supports search and formats title", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /SearchTerm=planet/);
return new Response(
JSON.stringify({
Items: [
{
Id: "ep-1",
Name: "Pilot",
Type: "Episode",
SeriesName: "Space Show",
ParentIndexNumber: 1,
IndexNumber: 2,
},
],
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const items = await listItems(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
libraryId: "lib-1",
searchTerm: "planet",
limit: 25,
},
);
assert.equal(items[0].title, "Space Show S01E02 Pilot");
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan chooses direct play when allowed", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-1",
Name: "Movie A",
UserData: { PlaybackPositionTicks: 20_000_000 },
MediaSources: [
{
Id: "ms-1",
Container: "mkv",
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 1,
DefaultSubtitleStreamIndex: 3,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv"],
},
{ itemId: "movie-1" },
);
assert.equal(plan.mode, "direct");
assert.match(plan.url, /Videos\/movie-1\/stream\?/);
assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/);
assert.equal(plan.subtitleStreamIndex, null);
assert.equal(ticksToSeconds(plan.startTimeTicks), 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-2",
Name: "Movie B",
UserData: { PlaybackPositionTicks: 10_000_000 },
MediaSources: [
{
Id: "ms-2",
Container: "mkv",
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 4,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: false,
directPlayContainers: ["mkv"],
transcodeVideoCodec: "h264",
},
{ itemId: "movie-2" },
);
assert.equal(plan.mode, "transcode");
const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/);
assert.equal(url.searchParams.get("api_key"), "token");
assert.equal(url.searchParams.get("AudioStreamIndex"), "4");
assert.equal(url.searchParams.get("StartTimeTicks"), "10000000");
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan falls back to transcode when direct container not allowed", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-3",
Name: "Movie C",
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-3",
Container: "avi",
SupportsDirectStream: true,
SupportsTranscoding: true,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv", "mp4"],
transcodeVideoCodec: "h265",
},
{
itemId: "movie-3",
audioStreamIndex: 2,
subtitleStreamIndex: 5,
},
);
assert.equal(plan.mode, "transcode");
const url = new URL(plan.url);
assert.equal(url.searchParams.get("VideoCodec"), "h265");
assert.equal(url.searchParams.get("AudioStreamIndex"), "2");
assert.equal(url.searchParams.get("SubtitleStreamIndex"), "5");
} finally {
globalThis.fetch = originalFetch;
}
});
test("listSubtitleTracks returns all subtitle streams with delivery urls", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-1",
MediaSources: [
{
Id: "ms-1",
MediaStreams: [
{
Type: "Subtitle",
Index: 2,
Language: "eng",
DisplayTitle: "English Full",
IsDefault: true,
DeliveryMethod: "Embed",
},
{
Type: "Subtitle",
Index: 3,
Language: "jpn",
Title: "Japanese Signs",
IsForced: true,
IsExternal: true,
DeliveryMethod: "External",
DeliveryUrl: "/Videos/movie-1/ms-1/Subtitles/3/Stream.srt",
IsExternalUrl: false,
},
{
Type: "Subtitle",
Index: 4,
Language: "spa",
Title: "Spanish External",
DeliveryMethod: "External",
DeliveryUrl: "https://cdn.example.com/subs.srt",
IsExternalUrl: true,
},
],
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const tracks = await listSubtitleTracks(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
"movie-1",
);
assert.equal(tracks.length, 3);
assert.deepEqual(
tracks.map((track) => track.index),
[2, 3, 4],
);
assert.equal(
tracks[0].deliveryUrl,
"http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token",
);
assert.equal(
tracks[1].deliveryUrl,
"http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token",
);
assert.equal(tracks[2].deliveryUrl, "https://cdn.example.com/subs.srt");
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan falls back to transcode when direct play blocked", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-1",
Name: "Movie A",
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-1",
Container: "avi",
SupportsDirectStream: true,
SupportsTranscoding: true,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv", "mp4"],
transcodeVideoCodec: "h265",
},
{ itemId: "movie-1" },
);
assert.equal(plan.mode, "transcode");
assert.match(plan.url, /master\.m3u8\?/);
assert.match(plan.url, /VideoCodec=h265/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan reuses server transcoding url and appends missing params", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-4",
Name: "Movie D",
UserData: { PlaybackPositionTicks: 50_000_000 },
MediaSources: [
{
Id: "ms-4",
Container: "mkv",
SupportsDirectStream: false,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 3,
TranscodingUrl: "/Videos/movie-4/master.m3u8?VideoCodec=hevc",
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
},
{
itemId: "movie-4",
subtitleStreamIndex: 8,
},
);
assert.equal(plan.mode, "transcode");
const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
assert.equal(url.searchParams.get("VideoCodec"), "hevc");
assert.equal(url.searchParams.get("api_key"), "token");
assert.equal(url.searchParams.get("AudioStreamIndex"), "3");
assert.equal(url.searchParams.get("SubtitleStreamIndex"), "8");
assert.equal(url.searchParams.get("StartTimeTicks"), "50000000");
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan preserves episode metadata, stream selection, and resume ticks", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "ep-2",
Type: "Episode",
Name: "A New Hope",
SeriesName: "Galaxy Quest",
ParentIndexNumber: 2,
IndexNumber: 7,
UserData: { PlaybackPositionTicks: 35_000_000 },
MediaSources: [
{
Id: "ms-ep-2",
Container: "mkv",
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 6,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv"],
},
{
itemId: "ep-2",
subtitleStreamIndex: 9,
},
);
assert.equal(plan.mode, "direct");
assert.equal(plan.title, "Galaxy Quest S02E07 A New Hope");
assert.equal(plan.audioStreamIndex, 6);
assert.equal(plan.subtitleStreamIndex, 9);
assert.equal(plan.startTimeTicks, 35_000_000);
const url = new URL(plan.url);
assert.equal(url.searchParams.get("AudioStreamIndex"), "6");
assert.equal(url.searchParams.get("SubtitleStreamIndex"), "9");
assert.equal(url.searchParams.get("StartTimeTicks"), "35000000");
} finally {
globalThis.fetch = originalFetch;
}
});
test("listSubtitleTracks falls back from PlaybackInfo to item media sources", async () => {
const originalFetch = globalThis.fetch;
let requestCount = 0;
globalThis.fetch = (async (input) => {
requestCount += 1;
if (requestCount === 1) {
assert.match(String(input), /\/Items\/movie-fallback\/PlaybackInfo\?/);
return new Response("Playback info unavailable", { status: 500 });
}
return new Response(
JSON.stringify({
Id: "movie-fallback",
MediaSources: [
{
Id: "ms-fallback",
MediaStreams: [
{
Type: "Subtitle",
Index: 11,
Language: "eng",
Title: "English",
DeliveryMethod: "External",
DeliveryUrl: "/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt",
IsExternalUrl: false,
},
],
},
],
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const tracks = await listSubtitleTracks(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
"movie-fallback",
);
assert.equal(requestCount, 2);
assert.equal(tracks.length, 1);
assert.equal(tracks[0].index, 11);
assert.equal(
tracks[0].deliveryUrl,
"http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token",
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("authenticateWithPassword surfaces invalid credentials and server status failures", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })) as typeof fetch;
try {
await assert.rejects(
() =>
authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"badpw",
clientInfo,
),
/Invalid Jellyfin username or password\./,
);
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = (async () =>
new Response("Oops", { status: 500, statusText: "Internal Server Error" })) as typeof fetch;
try {
await assert.rejects(
() =>
authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"pw",
clientInfo,
),
/Jellyfin login failed \(500 Internal Server Error\)\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("listLibraries surfaces token-expiry auth errors", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("Forbidden", { status: 403, statusText: "Forbidden" })) as typeof fetch;
try {
await assert.rejects(
() =>
listLibraries(
{
serverUrl: "http://jellyfin.local",
accessToken: "expired",
userId: "u1",
username: "kyle",
},
clientInfo,
),
/Jellyfin authentication failed \(invalid or expired token\)\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan surfaces no-source and no-stream fallback errors", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-empty",
Name: "Movie Empty",
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [],
}),
{ status: 200 },
)) as typeof fetch;
try {
await assert.rejects(
() =>
resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{ enabled: true },
{ itemId: "movie-empty" },
),
/No playable media source found for Jellyfin item\./,
);
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-no-stream",
Name: "Movie No Stream",
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-none",
Container: "avi",
SupportsDirectStream: false,
SupportsTranscoding: false,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
await assert.rejects(
() =>
resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{ enabled: true },
{ itemId: "movie-no-stream" },
),
/Jellyfin item cannot be streamed by direct play or transcoding\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,571 @@
import { JellyfinConfig } from "../../types";
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
export interface JellyfinAuthSession {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
}
export interface JellyfinLibrary {
id: string;
name: string;
collectionType: string;
type: string;
}
export interface JellyfinPlaybackSelection {
itemId: string;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
}
export interface JellyfinPlaybackPlan {
mode: "direct" | "transcode";
url: string;
title: string;
startTimeTicks: number;
audioStreamIndex: number | null;
subtitleStreamIndex: number | null;
}
export interface JellyfinSubtitleTrack {
index: number;
language: string;
title: string;
codec: string;
isDefault: boolean;
isForced: boolean;
isExternal: boolean;
deliveryMethod: string;
deliveryUrl: string | null;
}
interface JellyfinAuthResponse {
AccessToken?: string;
User?: { Id?: string; Name?: string };
}
interface JellyfinMediaStream {
Index?: number;
Type?: string;
IsExternal?: boolean;
IsDefault?: boolean;
IsForced?: boolean;
Language?: string;
DisplayTitle?: string;
Title?: string;
Codec?: string;
DeliveryMethod?: string;
DeliveryUrl?: string;
IsExternalUrl?: boolean;
}
interface JellyfinMediaSource {
Id?: string;
Container?: string;
SupportsDirectStream?: boolean;
SupportsTranscoding?: boolean;
TranscodingUrl?: string;
DefaultAudioStreamIndex?: number;
DefaultSubtitleStreamIndex?: number;
MediaStreams?: JellyfinMediaStream[];
LiveStreamId?: string;
}
interface JellyfinItemUserData {
PlaybackPositionTicks?: number;
}
interface JellyfinItem {
Id?: string;
Name?: string;
Type?: string;
SeriesName?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
UserData?: JellyfinItemUserData;
MediaSources?: JellyfinMediaSource[];
}
interface JellyfinItemsResponse {
Items?: JellyfinItem[];
}
interface JellyfinPlaybackInfoResponse {
MediaSources?: JellyfinMediaSource[];
}
export interface JellyfinClientInfo {
deviceId: string;
clientName: string;
clientVersion: string;
}
function normalizeBaseUrl(value: string): string {
return value.trim().replace(/\/+$/, "");
}
function ensureString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asIntegerOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function resolveDeliveryUrl(
session: JellyfinAuthSession,
stream: JellyfinMediaStream,
itemId: string,
mediaSourceId: string,
): string | null {
const deliveryUrl = ensureString(stream.DeliveryUrl).trim();
if (deliveryUrl) {
if (stream.IsExternalUrl === true) return deliveryUrl;
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
if (!resolved.searchParams.has("api_key")) {
resolved.searchParams.set("api_key", session.accessToken);
}
return resolved.toString();
}
const streamIndex = asIntegerOrNull(stream.Index);
if (streamIndex === null || !itemId || !mediaSourceId) return null;
const codec = ensureString(stream.Codec).toLowerCase();
const ext =
codec === "subrip"
? "srt"
: codec === "webvtt"
? "vtt"
: codec === "vtt"
? "vtt"
: codec === "ass"
? "ass"
: codec === "ssa"
? "ssa"
: "srt";
const fallback = new URL(
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
`${session.serverUrl}/`,
);
if (!fallback.searchParams.has("api_key")) {
fallback.searchParams.set("api_key", session.accessToken);
}
return fallback.toString();
}
function createAuthorizationHeader(
client: JellyfinClientInfo,
token?: string,
): string {
const parts = [
`Client="${client.clientName}"`,
`Device="${client.clientName}"`,
`DeviceId="${client.deviceId}"`,
`Version="${client.clientVersion}"`,
];
if (token) parts.push(`Token="${token}"`);
return `MediaBrowser ${parts.join(", ")}`;
}
async function jellyfinRequestJson<T>(
path: string,
init: RequestInit,
session: JellyfinAuthSession,
client: JellyfinClientInfo,
): Promise<T> {
const headers = new Headers(init.headers ?? {});
headers.set("Content-Type", "application/json");
headers.set(
"Authorization",
createAuthorizationHeader(client, session.accessToken),
);
headers.set("X-Emby-Token", session.accessToken);
const response = await fetch(`${session.serverUrl}${path}`, {
...init,
headers,
});
if (response.status === 401 || response.status === 403) {
throw new Error(
"Jellyfin authentication failed (invalid or expired token).",
);
}
if (!response.ok) {
throw new Error(
`Jellyfin request failed (${response.status} ${response.statusText}).`,
);
}
return response.json() as Promise<T>;
}
function createDirectPlayUrl(
session: JellyfinAuthSession,
itemId: string,
mediaSource: JellyfinMediaSource,
plan: JellyfinPlaybackPlan,
): string {
const query = new URLSearchParams({
static: "true",
api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
});
if (mediaSource.LiveStreamId) {
query.set("LiveStreamId", mediaSource.LiveStreamId);
}
if (plan.audioStreamIndex !== null) {
query.set("AudioStreamIndex", String(plan.audioStreamIndex));
}
if (plan.subtitleStreamIndex !== null) {
query.set("SubtitleStreamIndex", String(plan.subtitleStreamIndex));
}
if (plan.startTimeTicks > 0) {
query.set("StartTimeTicks", String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
}
function createTranscodeUrl(
session: JellyfinAuthSession,
itemId: string,
mediaSource: JellyfinMediaSource,
plan: JellyfinPlaybackPlan,
config: JellyfinConfig,
): string {
if (mediaSource.TranscodingUrl) {
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
if (!url.searchParams.has("api_key")) {
url.searchParams.set("api_key", session.accessToken);
}
if (
!url.searchParams.has("AudioStreamIndex") &&
plan.audioStreamIndex !== null
) {
url.searchParams.set("AudioStreamIndex", String(plan.audioStreamIndex));
}
if (
!url.searchParams.has("SubtitleStreamIndex") &&
plan.subtitleStreamIndex !== null
) {
url.searchParams.set(
"SubtitleStreamIndex",
String(plan.subtitleStreamIndex),
);
}
if (!url.searchParams.has("StartTimeTicks") && plan.startTimeTicks > 0) {
url.searchParams.set("StartTimeTicks", String(plan.startTimeTicks));
}
return url.toString();
}
const query = new URLSearchParams({
api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
VideoCodec: ensureString(config.transcodeVideoCodec, "h264"),
TranscodingContainer: "ts",
});
if (plan.audioStreamIndex !== null) {
query.set("AudioStreamIndex", String(plan.audioStreamIndex));
}
if (plan.subtitleStreamIndex !== null) {
query.set("SubtitleStreamIndex", String(plan.subtitleStreamIndex));
}
if (plan.startTimeTicks > 0) {
query.set("StartTimeTicks", String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/master.m3u8?${query.toString()}`;
}
function getStreamDefaults(source: JellyfinMediaSource): {
audioStreamIndex: number | null;
} {
const audioDefault = asIntegerOrNull(source.DefaultAudioStreamIndex);
if (audioDefault !== null) return { audioStreamIndex: audioDefault };
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
const defaultAudio = streams.find(
(stream) => stream.Type === "Audio" && stream.IsDefault === true,
);
return {
audioStreamIndex: asIntegerOrNull(defaultAudio?.Index),
};
}
function getDisplayTitle(item: JellyfinItem): string {
if (item.Type === "Episode") {
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
const prefix = item.SeriesName ? `${item.SeriesName} ` : "";
return `${prefix}S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")} ${ensureString(item.Name).trim()}`.trim();
}
return ensureString(item.Name).trim() || "Jellyfin Item";
}
function shouldPreferDirectPlay(
source: JellyfinMediaSource,
config: JellyfinConfig,
): boolean {
if (source.SupportsDirectStream !== true) return false;
if (config.directPlayPreferred === false) return false;
const container = ensureString(source.Container).toLowerCase();
const allowlist = Array.isArray(config.directPlayContainers)
? config.directPlayContainers.map((entry) => entry.toLowerCase())
: [];
if (!container || allowlist.length === 0) return true;
return allowlist.includes(container);
}
export async function authenticateWithPassword(
serverUrl: string,
username: string,
password: string,
client: JellyfinClientInfo,
): Promise<JellyfinAuthSession> {
const normalizedUrl = normalizeBaseUrl(serverUrl);
if (!normalizedUrl) throw new Error("Missing Jellyfin server URL.");
if (!username.trim()) throw new Error("Missing Jellyfin username.");
if (!password) throw new Error("Missing Jellyfin password.");
const response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: createAuthorizationHeader(client),
},
body: JSON.stringify({
Username: username,
Pw: password,
}),
});
if (response.status === 401 || response.status === 403) {
throw new Error("Invalid Jellyfin username or password.");
}
if (!response.ok) {
throw new Error(
`Jellyfin login failed (${response.status} ${response.statusText}).`,
);
}
const payload = (await response.json()) as JellyfinAuthResponse;
const accessToken = ensureString(payload.AccessToken);
const userId = ensureString(payload.User?.Id);
if (!accessToken || !userId) {
throw new Error("Jellyfin login response missing token/user.");
}
return {
serverUrl: normalizedUrl,
accessToken,
userId,
username: username.trim(),
};
}
export async function listLibraries(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
): Promise<JellyfinLibrary[]> {
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
`/Users/${session.userId}/Views`,
{ method: "GET" },
session,
client,
);
const items = Array.isArray(payload.Items) ? payload.Items : [];
return items.map((item) => ({
id: ensureString(item.Id),
name: ensureString(item.Name, "Untitled"),
collectionType: ensureString(
(item as { CollectionType?: string }).CollectionType,
),
type: ensureString(item.Type),
}));
}
export async function listItems(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
options: {
libraryId: string;
searchTerm?: string;
limit?: number;
},
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
if (!options.libraryId) throw new Error("Missing Jellyfin library id.");
const query = new URLSearchParams({
ParentId: options.libraryId,
Recursive: "true",
IncludeItemTypes: "Movie,Episode,Audio",
Fields: "MediaSources,UserData",
SortBy: "SortName",
SortOrder: "Ascending",
Limit: String(options.limit ?? 100),
});
if (options.searchTerm?.trim()) {
query.set("SearchTerm", options.searchTerm.trim());
}
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
`/Users/${session.userId}/Items?${query.toString()}`,
{ method: "GET" },
session,
client,
);
const items = Array.isArray(payload.Items) ? payload.Items : [];
return items.map((item) => ({
id: ensureString(item.Id),
name: ensureString(item.Name),
type: ensureString(item.Type),
title: getDisplayTitle(item),
}));
}
export async function listSubtitleTracks(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
itemId: string,
): Promise<JellyfinSubtitleTrack[]> {
if (!itemId.trim()) throw new Error("Missing Jellyfin item id.");
let source: JellyfinMediaSource | undefined;
try {
const playbackInfo =
await jellyfinRequestJson<JellyfinPlaybackInfoResponse>(
`/Items/${itemId}/PlaybackInfo?UserId=${encodeURIComponent(session.userId)}`,
{
method: "POST",
body: JSON.stringify({ UserId: session.userId }),
},
session,
client,
);
source = Array.isArray(playbackInfo.MediaSources)
? playbackInfo.MediaSources[0]
: undefined;
} catch {}
if (!source) {
const item = await jellyfinRequestJson<JellyfinItem>(
`/Users/${session.userId}/Items/${itemId}?Fields=MediaSources`,
{ method: "GET" },
session,
client,
);
source = Array.isArray(item.MediaSources)
? item.MediaSources[0]
: undefined;
}
if (!source) {
throw new Error("No playable media source found for Jellyfin item.");
}
const mediaSourceId = ensureString(source.Id);
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
const tracks: JellyfinSubtitleTrack[] = [];
for (const stream of streams) {
if (stream.Type !== "Subtitle") continue;
const index = asIntegerOrNull(stream.Index);
if (index === null) continue;
tracks.push({
index,
language: ensureString(stream.Language),
title: ensureString(stream.DisplayTitle || stream.Title),
codec: ensureString(stream.Codec),
isDefault: stream.IsDefault === true,
isForced: stream.IsForced === true,
isExternal: stream.IsExternal === true,
deliveryMethod: ensureString(stream.DeliveryMethod),
deliveryUrl: resolveDeliveryUrl(session, stream, itemId, mediaSourceId),
});
}
return tracks;
}
export async function resolvePlaybackPlan(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
config: JellyfinConfig,
selection: JellyfinPlaybackSelection,
): Promise<JellyfinPlaybackPlan> {
if (!selection.itemId) {
throw new Error("Missing Jellyfin item id.");
}
const item = await jellyfinRequestJson<JellyfinItem>(
`/Users/${session.userId}/Items/${selection.itemId}?Fields=MediaSources,UserData`,
{ method: "GET" },
session,
client,
);
const source = Array.isArray(item.MediaSources)
? item.MediaSources[0]
: undefined;
if (!source) {
throw new Error("No playable media source found for Jellyfin item.");
}
const defaults = getStreamDefaults(source);
const audioStreamIndex =
selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
const startTimeTicks = Math.max(
0,
asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0,
);
const basePlan: JellyfinPlaybackPlan = {
mode: "transcode",
url: "",
title: getDisplayTitle(item),
startTimeTicks,
audioStreamIndex,
subtitleStreamIndex,
};
if (shouldPreferDirectPlay(source, config)) {
return {
...basePlan,
mode: "direct",
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
};
}
if (
source.SupportsTranscoding !== true &&
source.SupportsDirectStream === true
) {
return {
...basePlan,
mode: "direct",
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
};
}
if (source.SupportsTranscoding !== true) {
throw new Error(
"Jellyfin item cannot be streamed by direct play or transcoding.",
);
}
return {
...basePlan,
mode: "transcode",
url: createTranscodeUrl(
session,
selection.itemId,
source,
basePlan,
config,
),
};
}
export function ticksToSeconds(ticks: number): number {
return Math.max(0, Math.floor(ticks / JELLYFIN_TICKS_PER_SECOND));
}

View File

@@ -51,7 +51,9 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
return {
state,
deps: {
getResolvedConfig: () => ({ secondarySub: { secondarySubLanguages: ["ja"] } }),
getResolvedConfig: () => ({
secondarySub: { secondarySubLanguages: ["ja"] },
}),
getSubtitleMetrics: () => metrics,
isVisibleOverlayVisible: () => false,
emitSubtitleChange: (payload) => state.events.push(payload),
@@ -94,16 +96,16 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
state.commands.push(payload);
return true;
},
restorePreviousSecondarySubVisibility: () => {
state.restored += 1;
},
setPreviousSecondarySubVisibility: () => {
// intentionally not tracked in this unit test
},
...overrides,
restorePreviousSecondarySubVisibility: () => {
state.restored += 1;
},
};
}
setPreviousSecondarySubVisibility: () => {
// intentionally not tracked in this unit test
},
...overrides,
},
};
}
test("dispatchMpvProtocolMessage emits subtitle text on property change", async () => {
const { deps, state } = createDeps();
@@ -131,7 +133,9 @@ test("dispatchMpvProtocolMessage sets secondary subtitle track based on track li
deps,
);
assert.deepEqual(state.commands, [{ command: ["set_property", "secondary-sid", 2] }]);
assert.deepEqual(state.commands, [
{ command: ["set_property", "secondary-sid", 2] },
]);
});
test("dispatchMpvProtocolMessage restores secondary visibility on shutdown", async () => {
@@ -166,10 +170,9 @@ test("dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
assert.equal(pendingPauseAtSubEnd, false);
assert.equal(pauseAtTime, 42);
assert.deepEqual(state.events, [{ text: "字幕", start: 0, end: 0 }]);
assert.deepEqual(
state.commands[state.commands.length - 1],
{ command: ["set_property", "pause", false] },
);
assert.deepEqual(state.commands[state.commands.length - 1], {
command: ["set_property", "pause", false],
});
});
test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer", () => {
@@ -178,7 +181,7 @@ test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buf
);
assert.equal(parsed.messages.length, 2);
assert.equal(parsed.nextBuffer, "{\"partial\"");
assert.equal(parsed.nextBuffer, '{"partial"');
assert.equal(parsed.messages[0].event, "shutdown");
assert.equal(parsed.messages[1].name, "media-title");
});
@@ -186,9 +189,13 @@ test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buf
test("splitMpvMessagesFromBuffer reports invalid JSON lines", () => {
const errors: Array<{ line: string; error?: string }> = [];
splitMpvMessagesFromBuffer('{"event":"x"}\n{invalid}\n', undefined, (line, error) => {
errors.push({ line, error: String(error) });
});
splitMpvMessagesFromBuffer(
'{"event":"x"}\n{invalid}\n',
undefined,
(line, error) => {
errors.push({ line, error: String(error) });
},
);
assert.equal(errors.length, 1);
assert.equal(errors[0].line, "{invalid}");

View File

@@ -35,10 +35,7 @@ export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
export type MpvMessageParser = (message: MpvMessage) => void;
export type MpvParseErrorHandler = (
line: string,
error: unknown,
) => void;
export type MpvParseErrorHandler = (line: string, error: unknown) => void;
export interface MpvProtocolParseResult {
messages: MpvMessage[];
@@ -46,12 +43,21 @@ export interface MpvProtocolParseResult {
}
export interface MpvProtocolHandleMessageDeps {
getResolvedConfig: () => { secondarySub?: { secondarySubLanguages?: Array<string> } };
getResolvedConfig: () => {
secondarySub?: { secondarySubLanguages?: Array<string> };
};
getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
isVisibleOverlayVisible: () => boolean;
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void;
emitSubtitleChange: (payload: {
text: string;
isOverlayVisible: boolean;
}) => void;
emitSubtitleAssChange: (payload: { text: string }) => void;
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
emitSubtitleTiming: (payload: {
text: string;
start: number;
end: number;
}) => void;
emitSecondarySubtitleChange: (payload: { text: string }) => void;
getCurrentSubText: () => string;
setCurrentSubText: (text: string) => void;
@@ -63,7 +69,9 @@ export interface MpvProtocolHandleMessageDeps {
emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void;
emitPauseChange: (payload: { paused: boolean }) => void;
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
emitSubtitleMetricsChange: (
payload: Partial<MpvSubtitleRenderMetrics>,
) => void;
setCurrentSecondarySubText: (text: string) => void;
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
setSecondarySubVisibility: (visible: boolean) => void;
@@ -87,7 +95,10 @@ export interface MpvProtocolHandleMessageDeps {
"ff-index"?: number;
}>,
) => void;
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
sendCommand: (payload: {
command: unknown[];
request_id?: number;
}) => boolean;
restorePreviousSecondarySubVisibility: () => void;
}
@@ -129,7 +140,10 @@ export async function dispatchMpvProtocolMessage(
if (msg.name === "sub-text") {
const nextSubText = (msg.data as string) || "";
const overlayVisible = deps.isVisibleOverlayVisible();
deps.emitSubtitleChange({ text: nextSubText, isOverlayVisible: overlayVisible });
deps.emitSubtitleChange({
text: nextSubText,
isOverlayVisible: overlayVisible,
});
deps.setCurrentSubText(nextSubText);
} else if (msg.name === "sub-text-ass") {
deps.emitSubtitleAssChange({ text: (msg.data as string) || "" });
@@ -378,10 +392,7 @@ export async function dispatchMpvProtocolMessage(
}
}
export function asBoolean(
value: unknown,
fallback: boolean,
): boolean {
export function asBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
@@ -392,10 +403,7 @@ export function asBoolean(
return fallback;
}
export function asFiniteNumber(
value: unknown,
fallback: number,
): number {
export function asFiniteNumber(value: unknown, fallback: number): number {
const nextValue = Number(value);
return Number.isFinite(nextValue) ? nextValue : fallback;
}

View File

@@ -29,8 +29,5 @@ test("resolveCurrentAudioStreamIndex prefers matching current audio track id", (
});
test("resolveCurrentAudioStreamIndex returns null when tracks are not an array", () => {
assert.equal(
resolveCurrentAudioStreamIndex(null, null),
null,
);
assert.equal(resolveCurrentAudioStreamIndex(null, null), null);
});

View File

@@ -60,7 +60,9 @@ test("scheduleMpvReconnect clears existing timer and increments attempt", () =>
handler();
return 1 as unknown as ReturnType<typeof setTimeout>;
};
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
(globalThis as any).clearTimeout = (
timer: ReturnType<typeof setTimeout> | null,
) => {
cleared.push(timer);
};
@@ -205,14 +207,10 @@ test("MpvSocketTransport ignores connect requests while already connecting or co
test("MpvSocketTransport.shutdown clears socket and lifecycle flags", async () => {
const transport = new MpvSocketTransport({
socketPath: "/tmp/mpv.sock",
onConnect: () => {
},
onData: () => {
},
onError: () => {
},
onClose: () => {
},
onConnect: () => {},
onData: () => {},
onError: () => {},
onClose: () => {},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});

View File

@@ -38,9 +38,7 @@ export interface MpvReconnectSchedulerDeps {
connect: () => void;
}
export function scheduleMpvReconnect(
deps: MpvReconnectSchedulerDeps,
): number {
export function scheduleMpvReconnect(deps: MpvReconnectSchedulerDeps): number {
const reconnectTimer = deps.getReconnectTimer();
if (reconnectTimer) {
clearTimeout(reconnectTimer);

View File

@@ -12,7 +12,7 @@ function makeDeps(
overrides: Partial<MpvIpcClientProtocolDeps> = {},
): MpvIpcClientDeps {
return {
getResolvedConfig: () => ({} as any),
getResolvedConfig: () => ({}) as any,
autoStartOverlay: false,
setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
@@ -23,10 +23,13 @@ function makeDeps(
};
}
function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise<void> {
return (client as unknown as { handleMessage: (msg: unknown) => Promise<void> }).handleMessage(
msg,
);
function invokeHandleMessage(
client: MpvIpcClient,
msg: unknown,
): Promise<void> {
return (
client as unknown as { handleMessage: (msg: unknown) => Promise<void> }
).handleMessage(msg);
}
test("MpvIpcClient resolves pending request by request_id", async () => {
@@ -67,14 +70,14 @@ test("MpvIpcClient parses JSON line protocol in processBuffer", () => {
seen.push(msg);
};
(client as any).buffer =
"{\"event\":\"property-change\",\"name\":\"path\",\"data\":\"a\"}\n{\"request_id\":1,\"data\":\"ok\"}\n{\"partial\":";
'{"event":"property-change","name":"path","data":"a"}\n{"request_id":1,"data":"ok"}\n{"partial":';
(client as any).processBuffer();
assert.equal(seen.length, 2);
assert.equal(seen[0].name, "path");
assert.equal(seen[1].request_id, 1);
assert.equal((client as any).buffer, "{\"partial\":");
assert.equal((client as any).buffer, '{"partial":');
});
test("MpvIpcClient request rejects when disconnected", async () => {
@@ -170,7 +173,9 @@ test("MpvIpcClient scheduleReconnect clears existing reconnect timer", () => {
handler();
return 1 as unknown as ReturnType<typeof setTimeout>;
};
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
(globalThis as any).clearTimeout = (
timer: ReturnType<typeof setTimeout> | null,
) => {
cleared.push(timer);
};
@@ -245,7 +250,8 @@ test("MpvIpcClient reconnect replays property subscriptions and initial state re
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command[0] === "set_property" &&
(command as { command: unknown[] }).command[1] === "secondary-sub-visibility" &&
(command as { command: unknown[] }).command[1] ===
"secondary-sub-visibility" &&
(command as { command: unknown[] }).command[2] === "no",
);
const hasTrackSubscription = commands.some(

View File

@@ -1,9 +1,5 @@
import { EventEmitter } from "events";
import {
Config,
MpvClient,
MpvSubtitleRenderMetrics,
} from "../../types";
import { Config, MpvClient, MpvSubtitleRenderMetrics } from "../../types";
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
@@ -12,11 +8,11 @@ import {
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
} from "./mpv-protocol";
import { requestMpvInitialState, subscribeToMpvProperties } from "./mpv-properties";
import {
scheduleMpvReconnect,
MpvSocketTransport,
} from "./mpv-transport";
requestMpvInitialState,
subscribeToMpvProperties,
} from "./mpv-properties";
import { scheduleMpvReconnect, MpvSocketTransport } from "./mpv-transport";
import { createLogger } from "../../logger";
const logger = createLogger("main:mpv");
@@ -42,7 +38,9 @@ export function resolveCurrentAudioStreamIndex(
audioTracks.find((track) => track.selected === true);
const ffIndex = activeTrack?.["ff-index"];
return typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0
return typeof ffIndex === "number" &&
Number.isInteger(ffIndex) &&
ffIndex >= 0
? ffIndex
: null;
}
@@ -97,9 +95,7 @@ export function setMpvSubVisibilityRuntime(
mpvClient.setSubVisibility(visible);
}
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
} from "./mpv-protocol";
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-protocol";
export interface MpvIpcClientProtocolDeps {
getResolvedConfig: () => Config;
@@ -114,6 +110,7 @@ export interface MpvIpcClientProtocolDeps {
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
export interface MpvIpcClientEventMap {
"connection-change": { connected: boolean };
"subtitle-change": { text: string; isOverlayVisible: boolean };
"subtitle-ass-change": { text: string };
"subtitle-timing": { text: string; start: number; end: number };
@@ -171,10 +168,7 @@ export class MpvIpcClient implements MpvClient {
private nextDynamicRequestId = 1000;
private pendingRequests = new Map<number, (message: MpvMessage) => void>();
constructor(
socketPath: string,
deps: MpvIpcClientDeps,
) {
constructor(socketPath: string, deps: MpvIpcClientDeps) {
this.deps = deps;
this.transport = new MpvSocketTransport({
@@ -184,6 +178,7 @@ export class MpvIpcClient implements MpvClient {
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
this.emit("connection-change", { connected: true });
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false);
@@ -217,6 +212,7 @@ export class MpvIpcClient implements MpvClient {
this.connected = false;
this.connecting = false;
this.socket = null;
this.emit("connection-change", { connected: false });
this.failPendingRequests();
this.scheduleReconnect();
},
@@ -512,7 +508,11 @@ export class MpvIpcClient implements MpvClient {
const previous = this.previousSecondarySubVisibility;
if (previous === null) return;
this.send({
command: ["set_property", "secondary-sub-visibility", previous ? "yes" : "no"],
command: [
"set_property",
"secondary-sub-visibility",
previous ? "yes" : "no",
],
});
this.previousSecondarySubVisibility = null;
}

View File

@@ -1,8 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
runStartupBootstrapRuntime,
} from "./startup";
import { runStartupBootstrapRuntime } from "./startup";
import { CliArgs } from "../../cli/args";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
@@ -34,6 +32,15 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
texthooker: false,
help: false,
autoStartOverlay: false,
@@ -106,6 +113,35 @@ test("runStartupBootstrapRuntime keeps log-level precedence for repeated calls",
]);
});
test("runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags", () => {
const calls: string[] = [];
const args = makeArgs({
jellyfin: true,
jellyfinLibraries: true,
socketPath: "/tmp/stable.sock",
texthookerPort: 8888,
});
const result = runStartupBootstrapRuntime({
argv: ["node", "main.ts", "--jellyfin", "--jellyfin-libraries"],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push("forceX11"),
enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"),
getDefaultSocketPath: () => "/tmp/default.sock",
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push("startLifecycle"),
});
assert.equal(result.mpvSocketPath, "/tmp/stable.sock");
assert.equal(result.texthookerPort, 8888);
assert.equal(result.backendOverride, null);
assert.equal(result.autoStartOverlay, false);
assert.equal(result.texthookerOnlyMode, false);
assert.deepEqual(calls, ["forceX11", "enforceWayland", "startLifecycle"]);
});
test("runStartupBootstrapRuntime keeps --debug separate from log verbosity", () => {
const calls: string[] = [];
const args = makeArgs({
@@ -146,9 +182,5 @@ test("runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
assert.equal(result.mpvSocketPath, "/tmp/default.sock");
assert.equal(result.texthookerPort, 5174);
assert.equal(result.backendOverride, null);
assert.deepEqual(calls, [
"setLog:warn:cli",
"forceX11",
"enforceWayland",
]);
assert.deepEqual(calls, ["setLog:warn:cli", "forceX11", "enforceWayland"]);
});