mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(jellyfin): add remote playback and config plumbing
This commit is contained in:
@@ -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"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
334
src/core/services/jellyfin-remote.test.ts
Normal file
334
src/core/services/jellyfin-remote.test.ts
Normal 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")));
|
||||
});
|
||||
448
src/core/services/jellyfin-remote.ts
Normal file
448
src/core/services/jellyfin-remote.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
702
src/core/services/jellyfin.test.ts
Normal file
702
src/core/services/jellyfin.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
571
src/core/services/jellyfin.ts
Normal file
571
src/core/services/jellyfin.ts
Normal 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));
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user