feat(overlay): add subtitle selection modal and Jellyfin 12 fixes

- Add an optional subtitle selection modal for primary/secondary mpv tracks (subtitleSelection.enabled, g-s sequence shortcut) with key-sequence conflict handling
- Authenticate Jellyfin URLs with the ApiKey query, answer remote keep-alives, clear now-playing on stop, and restore episode titles in Anki misc info
- Honor the configured mpv executable when Jellyfin starts playback via a shared mpv-process launcher
- Bump electron-builder to 26.16.1
- Condense and reconcile changelog fragments; update config example and docs
This commit is contained in:
2026-09-22 18:51:59 -07:00
114 changed files with 2404 additions and 381 deletions
+22
View File
@@ -1545,3 +1545,25 @@ test('Anki metadata rejects a credential-bearing media title before metadata arr
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
});
test('AnkiIntegration.formatMiscInfoPattern treats ApiKey stream paths like legacy api_key ones', () => {
const integration = new AnkiIntegration(
{ metadata: { pattern: '[SubMiner] %f (%t)' } } as never,
{} as never,
{
currentSubText: '',
currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1',
currentTimePos: 426,
currentSubStart: 426,
currentSubEnd: 428,
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
send: () => true,
} as unknown as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('ApiKey='), false);
});
+1 -1
View File
@@ -185,7 +185,7 @@ function extractFilenameFromMediaPath(rawPath: string): string {
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
const loweredPath = rawPath.toLowerCase();
const loweredFilename = filename.toLowerCase();
if (loweredPath.includes('api_key=')) {
if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) {
return true;
}
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
+1
View File
@@ -58,6 +58,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSelection: { enabled: false },
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
dictionaryBackend,
hachidori,
+1
View File
@@ -103,6 +103,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openTsukihime: 'Ctrl+Shift+T',
openSubtitleSelection: 'g-s',
openSubtitleGeneration: 'Ctrl+Shift+G',
openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C',
+7
View File
@@ -642,6 +642,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openSessionHelp,
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
},
{
path: 'shortcuts.openSubtitleSelection',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openSubtitleSelection,
description:
'Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.',
},
{
path: 'shortcuts.openSubtitleGeneration',
kind: 'string',
@@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'subtitleSelection.enabled',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSelection.enabled,
description:
'Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection.',
},
...(
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
).map((key) => ({
@@ -17,6 +17,12 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Used only while Hachidori is linked to an external host.'],
key: 'hachidori',
},
{
title: 'Subtitle Selection',
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
notes: ['Hot-reload: enabling or disabling updates the session shortcut immediately.'],
key: 'subtitleSelection',
},
{
title: 'Japanese Subtitle Generation',
description: [
+7 -1
View File
@@ -2,7 +2,13 @@ function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
const HOT_RELOAD_ROOTS = [
'subtitleStyle',
'keybindings',
'shortcuts',
'subtitleSidebar',
'subtitleSelection',
] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
+20
View File
@@ -33,6 +33,25 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
);
}
if (isObject(src.subtitleSelection)) {
const enabled = asBoolean(src.subtitleSelection.enabled);
if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled;
else if (src.subtitleSelection.enabled !== undefined)
warn(
'subtitleSelection.enabled',
src.subtitleSelection.enabled,
resolved.subtitleSelection.enabled,
'Expected boolean.',
);
} else if (src.subtitleSelection !== undefined) {
warn(
'subtitleSelection',
src.subtitleSelection,
resolved.subtitleSelection,
'Expected object.',
);
}
if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
if (launchAtStartup !== undefined) {
@@ -264,6 +283,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openRuntimeOptions',
'openJimaku',
'openTsukihime',
'openSubtitleSelection',
'openSubtitleGeneration',
'openSessionHelp',
'openControllerSelect',
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
import { DEFAULT_CONFIG } from '../definitions';
import { buildConfigSettingsRegistry } from '../settings/registry';
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
import {
compileSessionBindings,
buildPluginSessionBindingsArtifact,
} from '../../core/services/session-bindings';
function bindings(config: ReturnType<typeof resolveConfig>['resolved']) {
return compileSessionBindings({
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
keybindings: config.keybindings,
platform: 'linux',
});
}
test('subtitle selection is opt-in and enabling it compiles g-s for mpv and the overlay', () => {
const defaults = resolveConfig({}).resolved;
assert.equal(defaults.subtitleSelection.enabled, false);
assert.equal(defaults.shortcuts.openSubtitleSelection, 'g-s');
const find = (config: typeof defaults) =>
bindings(config).bindings.find(
(binding) =>
binding.actionType === 'session-action' && binding.actionId === 'openSubtitleSelection',
);
assert.equal(find(defaults), undefined);
const enabled = resolveConfig({ subtitleSelection: { enabled: true } }).resolved;
const binding = find(enabled);
assert.ok(binding);
assert.deepEqual(binding.key, { code: 'KeyG-KeyS', modifiers: [] });
assert.equal(bindings(enabled).warnings.length, 0);
const artifact = buildPluginSessionBindingsArtifact({
bindings: [binding],
warnings: [],
numericSelectionTimeoutMs: 1000,
});
assert.deepEqual(artifact.bindings[0], {
...binding,
cliArgs: ['--session-action', '{"actionId":"openSubtitleSelection"}'],
});
enabled.subtitleSelection.enabled = false;
assert.equal(find(enabled), undefined);
});
test('subtitle selection settings are validated, hot reloadable, and the shortcut can be cleared', () => {
// @ts-expect-error Config files can contain invalid values at runtime.
const { resolved, warnings } = resolveConfig({ subtitleSelection: { enabled: 'yes' } });
assert.equal(resolved.subtitleSelection.enabled, false);
assert.equal(warnings.length, 1);
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === 'subtitleSelection.enabled',
);
assert.equal(field?.category, 'behavior');
assert.equal(field?.restartBehavior, 'hot-reload');
const cleared = resolveConfig({
subtitleSelection: { enabled: true },
shortcuts: { openSubtitleSelection: null },
}).resolved;
assert.equal(resolveConfiguredShortcuts(cleared, DEFAULT_CONFIG).openSubtitleSelection, null);
});
+4
View File
@@ -459,6 +459,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subtitleSelection.')) {
return { category: 'behavior', section: 'Subtitle Selection' };
}
if (path.startsWith('subtitleGeneration.')) {
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
}
@@ -635,6 +638,7 @@ function subsectionForPath(path: string): string | undefined {
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openTsukihime' ||
leaf === 'openSubtitleSelection' ||
leaf === 'openSubtitleGeneration' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
@@ -3174,6 +3174,7 @@ test('Jellyfin metadata cleanup requires both an API key and a stream marker', a
{ filename: 'stream?api_key=secret', leaked: true },
{ filename: '/STREAM?API_KEY=secret', leaked: true },
{ filename: '/Videos/item?api_key=secret', leaked: true },
{ filename: '/Videos/item?ApiKey=secret', leaked: true },
{ filename: 'MediaSourceId=item api key secret', leaked: true },
{ filename: 'An API Key Story', leaked: false },
{ filename: 'api_key=ordinary-metadata', leaked: false },
@@ -376,6 +376,7 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
'api_key',
'ApiKey',
'StartTimeTicks',
'AudioStreamIndex',
'SubtitleStreamIndex',
@@ -82,7 +82,7 @@ function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
) {
return null;
}
if (!url.searchParams.has('api_key')) {
if (!url.searchParams.has('api_key') && !url.searchParams.has('ApiKey')) {
return null;
}
return url;
@@ -130,13 +130,13 @@ function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: str
SELECT v.canonical_title
FROM imm_videos v
WHERE v.anime_id = a.anime_id
AND v.canonical_title NOT LIKE '%api_key=%'
AND v.canonical_title NOT LIKE '%api_key=%' AND v.canonical_title NOT LIKE '%ApiKey=%'
AND lower(v.canonical_title) NOT LIKE '%api key%'
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
LIMIT 1
) AS linked_video_title
FROM imm_anime a
WHERE a.canonical_title LIKE '%api_key=%'
WHERE a.canonical_title LIKE '%api_key=%' OR a.canonical_title LIKE '%ApiKey=%'
OR lower(a.canonical_title) LIKE '%api key%'
OR lower(a.normalized_title_key) LIKE '%api key%'
`,
@@ -244,11 +244,11 @@ function repairLeakedJellyfinVideoParseMetadata(
LAST_UPDATE_DATE = ?
WHERE source_type = 2
AND (
parsed_basename LIKE '%api_key=%'
parsed_basename LIKE '%api_key=%' OR parsed_basename LIKE '%ApiKey=%'
OR lower(parsed_basename) LIKE '%api key%'
OR parsed_title LIKE '%api_key=%'
OR parsed_title LIKE '%api_key=%' OR parsed_title LIKE '%ApiKey=%'
OR lower(parsed_title) LIKE '%api key%'
OR parse_metadata_json LIKE '%api_key=%'
OR parse_metadata_json LIKE '%api_key=%' OR parse_metadata_json LIKE '%ApiKey=%'
OR lower(parse_metadata_json) LIKE '%api key%'
)
`,
@@ -267,7 +267,7 @@ function repairLeakedJellyfinAnimeParseMetadata(
UPDATE imm_anime
SET metadata_json = NULL, LAST_UPDATE_DATE = ?
WHERE (
metadata_json LIKE '%api_key=%'
metadata_json LIKE '%api_key=%' OR metadata_json LIKE '%ApiKey=%'
OR lower(metadata_json) LIKE '%api key%'
) AND (
lower(metadata_json) LIKE '%stream?%'
@@ -295,11 +295,11 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
FROM imm_videos
WHERE source_type = 2
AND (
video_key LIKE '%api_key=%'
video_key LIKE '%api_key=%' OR video_key LIKE '%ApiKey=%'
OR lower(video_key) LIKE '%api key%'
OR source_url LIKE '%api_key=%'
OR source_url LIKE '%api_key=%' OR source_url LIKE '%ApiKey=%'
OR lower(source_url) LIKE '%api key%'
OR canonical_title LIKE '%api_key=%'
OR canonical_title LIKE '%api_key=%' OR canonical_title LIKE '%ApiKey=%'
OR lower(canonical_title) LIKE '%api key%'
)
`,
+2 -2
View File
@@ -93,7 +93,7 @@ export interface IpcServiceDeps {
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
@@ -378,7 +378,7 @@ export interface IpcDepsRuntimeOptions {
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
+160 -2
View File
@@ -4,6 +4,17 @@ import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './je
class FakeWebSocket {
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
sent: string[] = [];
terminated = false;
send(data: string): void {
this.sent.push(data);
}
terminate(): void {
this.terminated = true;
this.emit('close');
}
on(event: string, listener: (...args: unknown[]) => void): this {
if (!this.listeners[event]) {
@@ -58,7 +69,7 @@ test('start posts capabilities on socket connect', async () => {
accessToken: 'token-1',
deviceId: 'device-1',
webSocketFactory: (url) => {
assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1');
assert.equal(url, 'ws://jellyfin.local:8096/socket?ApiKey=token-1&deviceId=device-1');
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
@@ -99,7 +110,8 @@ test('socket headers include jellyfin authorization metadata', () => {
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']);
assert.equal('X-Emby-Authorization' in seenHeaders[0]!, false);
assert.equal('X-Emby-Token' in seenHeaders[0]!, false);
});
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
@@ -355,3 +367,149 @@ test('advertiseNow validates server registration using Sessions endpoint', async
assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
});
test('answers ForceKeepAlive with KeepAlive messages on the advertised cadence', () => {
const sockets: FakeWebSocket[] = [];
const timers: Array<{ handler: () => void; delay: number }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-ka',
deviceId: 'device-ka',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
setTimer: ((handler: () => void, delay?: number) => {
timers.push({ handler, delay: Number(delay) });
return timers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => undefined) as typeof clearTimeout,
});
service.start();
sockets[0]!.emit('open');
assert.deepEqual(sockets[0]!.sent, ['{"MessageType":"KeepAlive"}']);
assert.equal(timers[0]!.delay, 30_000);
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 20 }));
assert.equal(sockets[0]!.sent.length, 2);
assert.equal(timers.at(-1)!.delay, 10_000);
timers.at(-1)!.handler();
assert.equal(sockets[0]!.sent.length, 3);
});
test('reconnects when the server stops answering keep-alives', () => {
let now = 1_000_000;
const sockets: FakeWebSocket[] = [];
const timers: Array<() => void> = [];
const warnings: string[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-lost',
deviceId: 'device-lost',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
getNow: () => now,
logWarn: (message) => {
warnings.push(message);
},
reconnectBaseDelayMs: 100,
setTimer: ((handler: () => void) => {
timers.push(handler);
return timers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => undefined) as typeof clearTimeout,
});
service.start();
sockets[0]!.emit('open');
// Two silent ticks are still within the 90s tolerance; the third marks the socket lost.
now += 30_000;
timers.shift()!();
now += 30_000;
timers.shift()!();
assert.equal(sockets[0]!.sent.length, 3);
assert.equal(sockets[0]!.terminated, false);
now += 30_000;
timers.shift()!();
assert.equal(sockets[0]!.terminated, true);
assert.equal(service.isConnected(), false);
assert.equal(warnings.length, 1);
timers.shift()!();
assert.equal(sockets.length, 2);
});
test('warns once per failing timeline endpoint until it recovers', async () => {
const warnings: string[] = [];
let status = 400;
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-warn',
deviceId: 'device-warn',
webSocketFactory: () => new FakeWebSocket() as unknown as any,
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
logWarn: (message) => {
warnings.push(message);
},
});
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
assert.equal(await service.reportStopped(state), false);
assert.equal(await service.reportStopped(state), false);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
status = 200;
assert.equal(await service.reportStopped(state), true);
status = 500;
assert.equal(await service.reportStopped(state), false);
assert.equal(warnings.length, 2);
});
test('ignores messages from a superseded socket', () => {
const sockets: FakeWebSocket[] = [];
const playPayloads: unknown[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stale',
deviceId: 'device-stale',
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);
},
setTimer: (() => 1 as unknown as ReturnType<typeof setTimeout>) as unknown as typeof setTimeout,
clearTimer: (() => undefined) as typeof clearTimeout,
});
service.start();
service.stop();
service.start();
sockets[1]!.emit('open');
assert.equal(sockets.length, 2);
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 10 }));
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemIds: ['x'] } }));
assert.deepEqual(sockets[0]!.sent, []);
assert.deepEqual(playPayloads, []);
assert.deepEqual(sockets[1]!.sent, ['{"MessageType":"KeepAlive"}']);
});
+105 -10
View File
@@ -45,9 +45,22 @@ interface JellyfinRemoteSocket {
on(event: 'close', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'message', listener: (data: unknown) => void): this;
send(data: string): void;
terminate?(): void;
close(): void;
}
// Jellyfin advertises its keep-alive timeout in the ForceKeepAlive message (60s by default),
// drops sockets that stay silent past it, and since 12.0 also detaches the session's remote
// controller when that happens. The drop never reaches the client as a close frame, so the
// client has to keep sending KeepAlive and treat missing replies as a dead connection.
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 60_000;
const KEEP_ALIVE_LOST_FACTOR = 1.5;
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
(timer as unknown as { unref?: () => void }).unref?.();
}
type JellyfinRemoteSocketHeaders = Record<string, string>;
export interface JellyfinRemoteSessionServiceOptions {
@@ -77,6 +90,9 @@ export interface JellyfinRemoteSessionServiceOptions {
deviceName?: string;
onConnected?: () => void;
onDisconnected?: () => void;
logWarn?: (message: string, details?: unknown) => void;
keepAliveTimeoutMs?: number;
getNow?: () => number;
}
function normalizeServerUrl(serverUrl: string): string {
@@ -196,6 +212,12 @@ export class JellyfinRemoteSessionService {
private readonly authHeader: string;
private readonly onConnected?: () => void;
private readonly onDisconnected?: () => void;
private readonly logWarn?: (message: string, details?: unknown) => void;
private readonly now: () => number;
private keepAliveTimeoutMs: number;
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
private lastInboundAtMs = 0;
private readonly failedRequestPaths = new Set<string>();
private readonly reconnectBaseDelayMs: number;
private readonly reconnectMaxDelayMs: number;
@@ -233,6 +255,12 @@ export class JellyfinRemoteSessionService {
});
this.onConnected = options.onConnected;
this.onDisconnected = options.onDisconnected;
this.logWarn = options.logWarn;
this.now = options.getNow ?? Date.now;
this.keepAliveTimeoutMs = Math.max(
1000,
options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS,
);
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
this.reconnectMaxDelayMs = Math.max(
this.reconnectBaseDelayMs,
@@ -250,6 +278,7 @@ export class JellyfinRemoteSessionService {
public stop(): void {
this.running = false;
this.connected = false;
this.stopKeepAlive();
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
@@ -298,12 +327,16 @@ export class JellyfinRemoteSessionService {
if (this.socket !== socket || !this.running) return;
this.connected = true;
this.reconnectAttempt = 0;
this.lastInboundAtMs = this.now();
this.startKeepAlive(socket, this.keepAliveTimeoutMs);
this.onConnected?.();
void this.postCapabilities();
});
socket.on('message', (rawData) => {
this.handleInboundMessage(rawData);
if (this.socket !== socket || !this.running) return;
this.lastInboundAtMs = this.now();
this.handleInboundMessage(socket, rawData);
});
const handleDisconnect = () => {
@@ -311,6 +344,7 @@ export class JellyfinRemoteSessionService {
disconnected = true;
if (this.socket === socket) {
this.socket = null;
this.stopKeepAlive();
}
this.connected = false;
this.onDisconnected?.();
@@ -323,6 +357,51 @@ export class JellyfinRemoteSessionService {
socket.on('error', handleDisconnect);
}
private startKeepAlive(socket: JellyfinRemoteSocket, timeoutMs: number): void {
this.stopKeepAlive();
this.keepAliveTimeoutMs = timeoutMs;
this.sendKeepAlive(socket);
this.scheduleKeepAliveTick(socket);
}
private scheduleKeepAliveTick(socket: JellyfinRemoteSocket): void {
const intervalMs = Math.max(1000, Math.floor(this.keepAliveTimeoutMs / 2));
const timer = this.setTimer(() => {
this.keepAliveTimer = null;
if (this.socket !== socket || !this.running) return;
const silentForMs = this.now() - this.lastInboundAtMs;
if (silentForMs >= this.keepAliveTimeoutMs * KEEP_ALIVE_LOST_FACTOR) {
this.logWarn?.('Jellyfin remote websocket stopped answering keep-alives; reconnecting.');
// Dropping the socket raises 'close', which schedules the reconnect.
if (socket.terminate) {
socket.terminate();
} else {
socket.close();
}
return;
}
this.sendKeepAlive(socket);
this.scheduleKeepAliveTick(socket);
}, intervalMs);
unrefTimer(timer);
this.keepAliveTimer = timer;
}
private stopKeepAlive(): void {
if (this.keepAliveTimer) {
this.clearTimer(this.keepAliveTimer);
this.keepAliveTimer = null;
}
}
private sendKeepAlive(socket: JellyfinRemoteSocket): void {
try {
socket.send(JSON.stringify({ MessageType: 'KeepAlive' }));
} catch (error) {
this.logWarn?.('Failed to send Jellyfin remote keep-alive.', error);
}
}
private scheduleReconnect(): void {
const delay = Math.min(
this.reconnectMaxDelayMs,
@@ -342,7 +421,7 @@ export class JellyfinRemoteSessionService {
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('ApiKey', this.accessToken);
socketUrl.searchParams.set('deviceId', this.deviceId);
return socketUrl.toString();
}
@@ -350,8 +429,6 @@ export class JellyfinRemoteSessionService {
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);
@@ -375,8 +452,6 @@ export class JellyfinRemoteSessionService {
method: 'GET',
headers: {
Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
},
});
if (!response.ok) return false;
@@ -398,21 +473,41 @@ export class JellyfinRemoteSessionService {
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
},
body: JSON.stringify(payload),
});
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
return response.ok;
} catch {
} catch (error) {
this.noteRequestOutcome(path, error);
return false;
}
}
private handleInboundMessage(rawData: unknown): void {
// Warn once per path while it keeps failing so a rejected stop report is visible in the
// log without a warning per progress tick.
private noteRequestOutcome(path: string, failure: unknown): void {
if (failure === null) {
this.failedRequestPaths.delete(path);
return;
}
if (this.failedRequestPaths.has(path)) return;
this.failedRequestPaths.add(path);
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
}
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
const message = parseInboundMessage(rawData);
if (!message) return;
const messageType = message.MessageType;
if (messageType === 'ForceKeepAlive') {
const seconds = Number(message.Data);
const timeoutMs =
Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : this.keepAliveTimeoutMs;
this.startKeepAlive(socket, timeoutMs);
return;
}
if (messageType === 'KeepAlive') return;
const payload = parseMessageData(message.Data);
if (messageType === 'Play') {
this.onPlay?.(payload);
+70 -6
View File
@@ -279,7 +279,7 @@ test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled
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('ApiKey'), 'token');
assert.equal(url.searchParams.get('AudioStreamIndex'), '4');
assert.equal(url.searchParams.get('StartTimeTicks'), '10000000');
} finally {
@@ -365,7 +365,7 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
IsForced: true,
IsExternal: true,
DeliveryMethod: 'External',
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt',
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=server-token',
IsExternalUrl: false,
},
{
@@ -402,11 +402,11 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
);
assert.equal(
tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token',
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?ApiKey=token',
);
assert.equal(
tracks[1]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token',
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token',
);
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
} finally {
@@ -505,7 +505,7 @@ test('resolvePlaybackPlan reuses server transcoding url and appends missing para
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('ApiKey'), 'token');
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8');
assert.equal(url.searchParams.get('StartTimeTicks'), '50000000');
@@ -626,7 +626,7 @@ test('listSubtitleTracks falls back from PlaybackInfo to item media sources', as
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',
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?ApiKey=token',
);
} finally {
globalThis.fetch = originalFetch;
@@ -789,3 +789,67 @@ test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', asy
globalThis.fetch = originalFetch;
}
});
test('API requests authenticate with the MediaBrowser header only (no legacy X-Emby-Token)', async () => {
const originalFetch = globalThis.fetch;
const seenHeaders: Headers[] = [];
globalThis.fetch = (async (_input, init) => {
seenHeaders.push(new Headers(init?.headers));
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
}) as typeof fetch;
try {
await listLibraries(
{ serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' },
clientInfo,
);
assert.equal(seenHeaders.length, 1);
const headers = seenHeaders[0]!;
const authorization = headers.get('authorization') ?? '';
assert.match(authorization, /^MediaBrowser /);
assert.match(authorization, /Token="token"/);
assert.match(authorization, /DeviceId="subminer-test"/);
assert.equal(headers.has('x-emby-token'), false);
assert.equal(headers.has('x-emby-authorization'), false);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan replaces a legacy api_key on the server transcoding url with ApiKey', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-legacy',
Name: 'Movie Legacy',
MediaSources: [
{
Id: 'ms-legacy',
Container: 'mkv',
SupportsDirectStream: false,
SupportsTranscoding: true,
TranscodingUrl: '/Videos/movie-legacy/master.m3u8?VideoCodec=hevc&api_key=server-token',
},
],
}),
{ 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-legacy' },
);
assert.equal(plan.mode, 'transcode');
const url = new URL(plan.url);
assert.equal(url.searchParams.get('ApiKey'), 'token');
assert.equal(url.searchParams.has('api_key'), false);
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
} finally {
globalThis.fetch = originalFetch;
}
});
+15 -12
View File
@@ -136,6 +136,16 @@ function getErrorMessage(error: unknown): string {
return String(error || 'unknown error');
}
// Jellyfin reads query keys case-insensitively and older servers embed the token as
// `api_key` in the URLs they hand back, so drop every spelling before setting the one
// form Jellyfin 12 still accepts with legacy authorization disabled.
function setApiKeyParam(url: URL, accessToken: string): void {
for (const key of [...url.searchParams.keys()]) {
if (/^api_?key$/i.test(key)) url.searchParams.delete(key);
}
url.searchParams.set('ApiKey', accessToken);
}
function resolveDeliveryUrl(
session: JellyfinAuthSession,
stream: JellyfinMediaStream,
@@ -146,9 +156,7 @@ function resolveDeliveryUrl(
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);
}
setApiKeyParam(resolved, session.accessToken);
return resolved.toString();
}
@@ -171,9 +179,7 @@ function resolveDeliveryUrl(
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
`${session.serverUrl}/`,
);
if (!fallback.searchParams.has('api_key')) {
fallback.searchParams.set('api_key', session.accessToken);
}
setApiKeyParam(fallback, session.accessToken);
return fallback.toString();
}
@@ -197,7 +203,6 @@ async function jellyfinRequestJson<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,
@@ -221,7 +226,7 @@ function createDirectPlayUrl(
): string {
const query = new URLSearchParams({
static: 'true',
api_key: session.accessToken,
ApiKey: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
});
if (mediaSource.LiveStreamId) {
@@ -245,9 +250,7 @@ function createTranscodeUrl(
): 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);
}
setApiKeyParam(url, session.accessToken);
if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) {
url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex));
}
@@ -261,7 +264,7 @@ function createTranscodeUrl(
}
const query = new URLSearchParams({
api_key: session.accessToken,
ApiKey: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
TranscodingContainer: 'ts',
+2
View File
@@ -1,5 +1,6 @@
import {
MPV_REQUEST_ID_AID,
MPV_REQUEST_ID_MEDIA_TITLE,
MPV_REQUEST_ID_OSD_DIMENSIONS,
MPV_REQUEST_ID_OSD_HEIGHT,
MPV_REQUEST_ID_PATH,
@@ -85,6 +86,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
},
{
command: ['get_property', 'media-title'],
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
},
{
command: ['get_property', 'pause'],
+23 -5
View File
@@ -35,6 +35,7 @@ export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122;
export const MPV_REQUEST_ID_PAUSE = 123;
export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
export const MPV_REQUEST_ID_MEDIA_TITLE = 202;
export type MpvMessageParser = (message: MpvMessage) => void;
export type MpvParseErrorHandler = (line: string, error: unknown) => void;
@@ -335,14 +336,18 @@ export async function dispatchMpvProtocolMessage(
} else if (msg.name === 'fullscreen') {
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
} else if (msg.name === 'media-title') {
const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null;
if (typeof msg.data === 'string' && msg.data.trim() && !title) return;
deps.emitMediaTitleChange({
title,
});
applyMediaTitle(deps, msg.data);
} else if (msg.name === 'path') {
const path = (msg.data as string) || '';
deps.setCurrentVideoPath(path);
// A forced title set before loadfile arrives ahead of the path change that clears the
// cached title and never fires again, so read it back once the new path is known.
if (path) {
deps.sendCommand({
command: ['get_property', 'media-title'],
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
});
}
deps.emitMediaPathChange({ path });
deps.autoLoadSecondarySubTrack(path);
deps.syncCurrentAudioStreamIndex();
@@ -467,6 +472,8 @@ export async function dispatchMpvProtocolMessage(
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.request_id === MPV_REQUEST_ID_PATH) {
deps.emitMediaPathChange({ path: (msg.data as string) || '' });
} else if (msg.request_id === MPV_REQUEST_ID_MEDIA_TITLE) {
applyMediaTitle(deps, msg.data);
} else if (msg.request_id === MPV_REQUEST_ID_AID) {
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
deps.syncCurrentAudioStreamIndex();
@@ -557,6 +564,17 @@ export function asFiniteNumber(value: unknown, fallback: number): number {
return Number.isFinite(nextValue) ? nextValue : fallback;
}
// URL-derived titles (mpv falls back to the basename of a query-bearing stream URL) must not
// replace known metadata, so they are dropped instead of cached.
function applyMediaTitle(
deps: Pick<MpvProtocolHandleMessageDeps, 'emitMediaTitleChange'>,
data: unknown,
): void {
const title = typeof data === 'string' ? sanitizeMediaTitle(data) : null;
if (typeof data === 'string' && data.trim() && !title) return;
deps.emitMediaTitleChange({ title });
}
export function parseVisibilityProperty(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return null;
+32 -3
View File
@@ -9,6 +9,7 @@ import {
} from './mpv';
import {
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_MEDIA_TITLE,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
} from './mpv-protocol';
@@ -135,9 +136,15 @@ test('MpvIpcClient ignores URL-derived titles without replacing known metadata',
assert.deepEqual(titles, ['My Anime S01E02']);
});
test('MpvIpcClient clears cached media title when media path changes', async () => {
test('MpvIpcClient clears cached media title when media path changes and reads it back', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const commands: Array<{ command?: unknown[]; request_id?: number }> = [];
(client as any).send = (command: { command?: unknown[]; request_id?: number }) => {
commands.push(command);
return true;
};
// A forced title (Jellyfin sets force-media-title before loadfile) arrives before the path.
await invokeHandleMessage(client, {
event: 'property-change',
name: 'media-title',
@@ -148,11 +155,33 @@ test('MpvIpcClient clears cached media title when media path changes', async ()
await invokeHandleMessage(client, {
event: 'property-change',
name: 'path',
data: '/tmp/new-episode.mkv',
data: 'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
});
assert.equal(client.currentVideoPath, '/tmp/new-episode.mkv');
assert.equal(
client.currentVideoPath,
'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
);
assert.equal(client.currentMediaTitle, null);
const titleRequest = commands.find(
(command) => command.command?.[0] === 'get_property' && command.command?.[1] === 'media-title',
);
assert.equal(titleRequest?.request_id, MPV_REQUEST_ID_MEDIA_TITLE);
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
error: 'success',
data: '[Jellyfin/direct] Episode 1',
});
assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1');
// A URL-derived read-back must not poison the cache.
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
error: 'success',
data: 'stream?static=true&ApiKey=secret',
});
assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1');
});
test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => {
@@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -43,6 +43,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'),
openTsukihime: () => calls.push('tsukihime'),
openSubtitleSelection: () => calls.push('subtitle-selection'),
openSubtitleGeneration: () => calls.push('subtitle-generation'),
openYoutubeTrackPicker: () => {
calls.push('youtube');
+4
View File
@@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps {
openControllerDebug: () => void;
openJimaku: () => void;
openTsukihime: () => void;
openSubtitleSelection: () => void;
openSubtitleGeneration: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
@@ -120,6 +121,9 @@ export async function dispatchSessionAction(
case 'openTsukihime':
deps.openTsukihime();
return;
case 'openSubtitleSelection':
deps.openSubtitleSelection();
return;
case 'openSubtitleGeneration':
deps.openSubtitleGeneration();
return;
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -707,3 +708,58 @@ test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-co
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
});
test('single keys reserve sequence prefixes without reserving the second stroke', () => {
for (const key of ['g', 'Ctrl+g', 's']) {
const result = compileSessionBindings({
shortcuts: createShortcuts({ openSubtitleSelection: 'g-s' }),
keybindings: [createKeybinding(key, ['show-text', 'single'])],
platform: 'linux',
});
assert.ok(result.bindings.some((binding) => binding.originalKey === key));
assert.equal(
result.bindings.some((binding) => binding.originalKey === 'g-s'),
key !== 'g',
);
assert.equal(result.warnings.length, key === 'g' ? 1 : 0);
if (key === 'g') assert.match(result.warnings[0]!.message, /Single-key bindings take priority/);
}
});
test('configured shortcuts and built-in overlay keys also reserve sequence prefixes', () => {
for (const prefix of ['g', 'y', 'v']) {
const result = compileSessionBindings({
shortcuts: createShortcuts({ openSubtitleSelection: `${prefix}-s`, copySubtitle: 'g' }),
keybindings: [],
platform: 'linux',
});
assert.equal(
result.bindings.some((binding) => binding.originalKey === `${prefix}-s`),
false,
);
assert.ok(result.bindings.some((binding) => binding.originalKey === 'g'));
assert.equal(result.warnings.length, 1);
}
});
test('sequence reservations follow the sidebar code and literal Shift semantics', () => {
for (const [toggleKey, disabled] of [
['KeyG', true],
['g', false],
['G', true],
] as const) {
const result = compileSessionBindings({
shortcuts: createShortcuts({ openSubtitleSelection: 'Shift+g-s' }),
keybindings: [],
platform: 'linux',
rawConfig: {
...DEFAULT_CONFIG,
subtitleSidebar: { ...DEFAULT_CONFIG.subtitleSidebar, toggleKey },
},
});
assert.equal(
result.bindings.some((binding) => binding.originalKey === 'Shift+g-s'),
!disabled,
);
}
});
+43 -1
View File
@@ -12,6 +12,10 @@ import type {
SessionKeySpec,
} from '../../types/session-bindings';
import { SPECIAL_COMMANDS } from '../../config';
import {
resolveSessionSequenceConflicts,
type SessionKeyReservation,
} from '../../shared/session-key-sequences';
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
@@ -56,6 +60,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openTsukihime', actionId: 'openTsukihime' },
{ key: 'openSubtitleSelection', actionId: 'openSubtitleSelection' },
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
@@ -81,6 +86,13 @@ function normalizeCodeToken(
): string | null {
const normalized = token.trim();
if (!normalized) return null;
// Two lowercase letters use mpv's sequential-key syntax, for example g-s.
if (/^[a-z]-[a-z]$/.test(normalized)) {
return normalized
.split('-')
.map((letter) => `Key${letter.toUpperCase()}`)
.join('-');
}
if (options.allowMouseButtons === true) {
const normalizedMouse = normalized.toUpperCase();
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
@@ -543,7 +555,37 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
}
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
return { bindings, warnings };
const reservations: SessionKeyReservation[] = [
{ key: { code: 'KeyY', modifiers: [] }, path: 'built-in y sequences' },
{ key: { code: 'KeyV', modifiers: [] }, path: 'primary subtitle visibility key' },
{ key: { code: 'KeyY', modifiers: ['ctrl'] }, path: 'lookup window toggle' },
{ key: { code: 'KeyY', modifiers: ['meta'] }, path: 'lookup window toggle' },
{ key: { code: 'KeyY', modifiers: ['ctrl', 'shift'] }, path: 'keyboard-driven mode toggle' },
{ key: { code: 'KeyY', modifiers: ['shift', 'meta'] }, path: 'keyboard-driven mode toggle' },
...[...candidates.values()].flatMap((drafts) =>
drafts.map(({ binding }) => ({
key: binding.key,
path: binding.sourcePath,
})),
),
];
const sidebarKey = input.rawConfig?.subtitleSidebar?.toggleKey;
if (sidebarKey) {
const { key } = parseSessionBindingKey(sidebarKey, input.platform);
if (key) {
// The sidebar accepts DOM codes with either Shift state, or literal characters.
if (/^[A-Z]$/.test(sidebarKey)) key.modifiers = ['shift'];
reservations.push({ key, path: 'subtitleSidebar.toggleKey' });
if (/^Key[A-Z]$/.test(sidebarKey)) {
reservations.push({
key: { ...key, modifiers: ['shift'] },
path: 'subtitleSidebar.toggleKey',
});
}
}
}
const result = resolveSessionSequenceConflicts(bindings, reservations);
return { bindings: result.bindings, warnings: [...warnings, ...result.warnings] };
}
export function buildPluginSessionBindingsArtifact(input: {
+5
View File
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
openTsukihime: string | null | undefined;
openSubtitleSelection: string | null | undefined;
openSubtitleGeneration: string | null | undefined;
openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined;
@@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts(
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
openSubtitleSelection:
config.subtitleSelection?.enabled === true
? normalizeShortcut(shortcutValue('openSubtitleSelection'))
: null,
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
+66 -43
View File
@@ -84,7 +84,6 @@ protocol.registerSchemesAsPrivileged([
]);
import * as fs from 'fs';
import { spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
@@ -131,11 +130,6 @@ import {
import { printHelp } from './cli/help';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
shouldForceX11WaylandSession,
} from './shared/mpv-x11-backend';
import { AnkiConnectClient } from './anki-connect';
import {
getStartupModeFlags,
@@ -402,6 +396,7 @@ import {
getConfiguredWindowsMpvPathStatus,
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
@@ -475,6 +470,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
import {
createSubtitleSelectionRuntime,
openSubtitleSelectionModal,
registerSubtitleSelectionIpc,
} from './main/runtime/subtitle-selection';
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
@@ -686,22 +686,6 @@ const MPV_JELLYFIN_DEFAULT_ARGS = [
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const;
/**
* Spawn a SubMiner-managed mpv (Jellyfin/YouTube) detached. On unsupported Wayland
* sessions it is pinned to XWayland Wayland-hint env stripped and an X11 GPU context
* appended so the XWayland overlay can stay above it, matching the `subminer` launcher.
*/
function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
if (!shouldForceX11WaylandSession(process.env)) {
return spawn('mpv', args, { detached: true, stdio: 'ignore' });
}
return spawn('mpv', [...args, ...MPV_X11_BACKEND_ARGS], {
detached: true,
stdio: 'ignore',
env: applyX11EnvOverrides({ ...process.env }),
});
}
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
@@ -3231,18 +3215,20 @@ const {
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () =>
resolveMpvExecutablePath(configService.getConfig().mpv.executablePath),
getSocketPath: () => appState.mpvSocketPath,
getLaunchMode: () => configService.getConfig().mpv.launchMode,
platform: process.platform,
execPath: process.execPath,
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
getInstalledPluginDetection: () =>
getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: configService.getConfig().mpv.executablePath,
mpvExecutablePath,
}),
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
@@ -3250,7 +3236,7 @@ const {
removeSocketPath: (socketPath) => {
fs.rmSync(socketPath, { force: true });
},
spawnMpv: (args) => spawnManagedMpvProcess(args),
spawnMpv: spawnMpvProcess,
logWarn: (message, error) => logger.warn(message, error),
logInfo: (message) => logger.info(message),
},
@@ -4664,6 +4650,7 @@ const {
maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
void refreshMpvSessionBindings();
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
@@ -5384,20 +5371,32 @@ const {
},
});
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
configDir: CONFIG_DIR,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getResolvedConfig: () => configService.getConfig(),
getMpvClient: () => appState.mpvClient,
setSessionBindings: (bindings) => {
appState.sessionBindings = bindings;
},
setSessionBindingsInitialized: (initialized) => {
appState.sessionBindingsInitialized = initialized;
},
logWarn: (message) => logger.warn(message),
});
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
createSessionBindingsRuntime({
configDir: CONFIG_DIR,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getResolvedConfig: () => configService.getConfig(),
getMpvClient: () => appState.mpvClient,
setSessionBindings: (bindings) => {
appState.sessionBindings = bindings;
},
setSessionBindingsInitialized: (initialized) => {
appState.sessionBindingsInitialized = initialized;
},
logWarn: (message) => logger.warn(message),
onBindingsChanged: (bindings) =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
onWarning: (warning) => {
if (warning.kind !== 'conflict') return;
overlayNotificationsRuntime.showOverlayNotification({
id: `session-binding-conflict:${warning.path}`,
title: 'Shortcut conflict',
body: warning.message,
variant: 'warning',
});
},
});
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
@@ -5652,6 +5651,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openJimaku: () => openJimakuOverlay(),
openTsukihime: () => openTsukihimeOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openSubtitleSelection: () => {
if (!configService.getConfig().subtitleSelection.enabled) return;
openOverlayHostedModalWithOsd(
openSubtitleSelectionModal,
'Subtitle selection overlay unavailable.',
'Failed to open subtitle selection overlay.',
);
},
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
@@ -6004,8 +6011,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings,
getMpvInputBindings: () =>
readMpvInputBindings({
getMpvInputBindings: async () => {
await refreshMpvSessionBindings();
return readMpvInputBindings({
getMpvClient: () => appState.mpvClient,
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
platform:
@@ -6014,8 +6022,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
: process.platform === 'win32'
? 'win32'
: 'linux',
}),
getSessionBindings: () => appState.sessionBindings,
});
},
getSessionBindings: async () => {
await refreshMpvSessionBindings();
return appState.sessionBindings;
},
getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
@@ -6811,6 +6823,17 @@ function setOverlayVisible(visible: boolean): void {
}
registerIpcRuntimeHandlers();
registerSubtitleSelectionIpc({
ipc: ipcMain,
isAllowedSender: (sender) =>
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
(window) => window && !window.isDestroyed() && window.webContents === sender,
),
runtime: createSubtitleSelectionRuntime({
isEnabled: () => configService.getConfig().subtitleSelection.enabled,
getMpvClient: () => appState.mpvClient,
}),
});
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: () =>
@@ -7,6 +7,7 @@ import {
createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
} from '../domains/jellyfin';
@@ -91,13 +92,17 @@ export function composeJellyfinRemoteHandlers(
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
logWarn: options.logWarn,
});
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
buildReportJellyfinRemoteProgressMainDepsHandler(),
);
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler(
buildReportJellyfinRemoteStoppedMainDepsHandler(),
);
const reportTracker = createJellyfinRemoteReportTracker();
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({
...buildReportJellyfinRemoteProgressMainDepsHandler(),
reportTracker,
});
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({
...buildReportJellyfinRemoteStoppedMainDepsHandler(),
reportTracker,
});
const buildHandleJellyfinRemotePlayMainDepsHandler =
createBuildHandleJellyfinRemotePlayMainDepsHandler({
@@ -54,6 +54,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
sleep: async () => {},
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/test-mpv.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
deps.setKeybindings(payload.keybindings);
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
if (diff.hotReloadFields.includes('shortcuts')) {
if (
diff.hotReloadFields.includes('shortcuts') ||
diff.hotReloadFields.includes('subtitleSelection')
) {
deps.refreshGlobalAndOverlayShortcuts();
}
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
+1 -1
View File
@@ -165,7 +165,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
const mpvClient = deps.getMpvClient();
if (!connected || !mpvClient) {
throw new Error(
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
'MPV not connected and auto-launch failed. Check mpv.executablePath or ensure mpv is available in PATH.',
);
}
@@ -32,6 +32,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
},
};
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
getMpvExecutablePath: () => '/usr/local/bin/mpv',
getSocketPath: () => '/tmp/mpv.sock',
getLaunchMode: () => 'fullscreen',
platform: 'darwin',
@@ -47,8 +48,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
getDefaultMpvLogPath: () => '/tmp/mpv.log',
defaultMpvArgs: ['--no-config'],
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
spawnMpv: (args) => {
calls.push(`spawn:${args.join(' ')}`);
spawnMpv: (executablePath, args) => {
calls.push(`spawn:${executablePath} ${args.join(' ')}`);
return proc;
},
logWarn: (message) => calls.push(`warn:${message}`),
@@ -60,14 +61,20 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
assert.equal(deps.platform, 'darwin');
assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
assert.equal(deps.getMpvExecutablePath(), '/usr/local/bin/mpv');
assert.equal(deps.getInstalledPluginDetection?.('/usr/local/bin/mpv').installed, false);
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
deps.removeSocketPath('/tmp/mpv.sock');
deps.spawnMpv(['--idle=yes']);
deps.spawnMpv('/usr/local/bin/mpv', ['--idle=yes']);
deps.logInfo('launched');
deps.logWarn('bad', null);
assert.deepEqual(calls, ['rm:/tmp/mpv.sock', 'spawn:--idle=yes', 'info:launched', 'warn:bad']);
assert.deepEqual(calls, [
'rm:/tmp/mpv.sock',
'spawn:/usr/local/bin/mpv --idle=yes',
'info:launched',
'warn:bad',
]);
});
test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
@@ -16,6 +16,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
deps: LaunchMpvForJellyfinDeps,
) {
return (): LaunchMpvForJellyfinDeps => ({
getMpvExecutablePath: () => deps.getMpvExecutablePath(),
getSocketPath: () => deps.getSocketPath(),
getLaunchMode: () => deps.getLaunchMode(),
platform: deps.platform,
@@ -26,7 +27,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
defaultMpvArgs: deps.defaultMpvArgs,
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
spawnMpv: (args: string[]) => deps.spawnMpv(args),
spawnMpv: (executablePath, args) => deps.spawnMpv(executablePath, args),
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
logInfo: (message: string) => deps.logInfo(message),
});
@@ -1,5 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectInstalledMpvPlugin } from './first-run-setup-plugin';
import { resolveWindowsMpvPath } from './mpv-process';
import {
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createLaunchMpvIdleForJellyfinPlaybackHandler,
@@ -30,6 +32,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
const spawnedArgs: string[][] = [];
const logs: string[] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'maximized',
platform: 'darwin',
@@ -39,7 +42,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnMpv: (_executable, args) => {
spawnedArgs.push(args);
return {
on: () => {},
@@ -67,6 +70,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
@@ -84,7 +88,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnMpv: (_executable, args) => {
spawnedArgs.push(args);
return {
on: () => {},
@@ -108,41 +112,53 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
});
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
execPath: '/opt/SubMiner/SubMiner.AppImage',
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
getInstalledPluginDetection: () => ({
installed: true,
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
version: '0.1.0',
source: 'default-config',
message: null,
}),
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnedArgs.push(args);
return {
on: () => {},
unref: () => {},
};
},
logWarn: () => {},
logInfo: () => {},
});
test('Jellyfin detects portable plugins beside the executable selected for launch', () => {
const mpvPath = 'C:\\portable player\\mpv.exe';
const pluginPath = 'C:\\portable player\\portable_config\\scripts\\subminer\\main.lua';
for (const source of ['environment', 'PATH']) {
let resolutions = 0;
const spawned: Array<{ executable: string; args: string[] }> = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => {
resolutions += 1;
return resolveWindowsMpvPath({
getEnv: () => (source === 'environment' ? mpvPath : undefined),
runWhere: () => ({ status: 0, stdout: mpvPath }),
fileExists: (candidate) => candidate === mpvPath,
});
},
getSocketPath: () => '\\\\.\\pipe\\subminer-test',
getLaunchMode: () => 'normal',
platform: 'win32',
execPath: 'C:\\SubMiner\\SubMiner.exe',
getRuntimePluginEntrypoint: () => 'C:\\SubMiner\\plugin\\subminer\\main.lua',
getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({
platform: 'win32',
homeDir: 'C:\\Users\\test',
mpvExecutablePath,
existsSync: (candidate) => candidate === pluginPath,
}),
getDefaultMpvLogPath: () => '',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: (executable, args) => {
spawned.push({ executable, args });
return { on: () => {}, unref: () => {} };
},
logWarn: () => {},
logInfo: () => {},
});
launch();
assert.equal(
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
false,
);
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
launch();
assert.equal(resolutions, 1, source);
assert.equal(spawned.length, 1);
assert.equal(spawned[0]!.executable, mpvPath);
assert.equal(
spawned[0]!.args.some((arg) => arg.startsWith('--script=')),
false,
);
}
});
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
@@ -41,23 +41,25 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps)
}
export type LaunchMpvForJellyfinDeps = {
getMpvExecutablePath: () => string;
getSocketPath: () => string;
getLaunchMode: () => MpvLaunchMode;
platform: NodeJS.Platform;
execPath: string;
getRuntimePluginEntrypoint?: () => string | null | undefined;
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
getInstalledPluginDetection?: (mpvExecutablePath: string) => InstalledMpvPluginDetection;
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
getDefaultMpvLogPath: () => string;
defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => SpawnedProcessLike;
spawnMpv: (executablePath: string, args: string[]) => SpawnedProcessLike;
logWarn: (message: string, error: unknown) => void;
logInfo: (message: string) => void;
};
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
return (): void => {
const executablePath = deps.getMpvExecutablePath();
const socketPath = deps.getSocketPath();
if (deps.platform !== 'win32') {
try {
@@ -78,7 +80,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
)
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
const installedPlugin = deps.getInstalledPluginDetection?.();
const installedPlugin = deps.getInstalledPluginDetection?.(executablePath);
const runtimePluginEntrypoint = installedPlugin?.installed
? ''
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
@@ -95,7 +97,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
`--input-ipc-server=${socketPath}`,
];
const proc = deps.spawnMpv(mpvArgs);
const proc = deps.spawnMpv(executablePath, mpvArgs);
proc.on('error', (error) => {
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
});
@@ -75,5 +75,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
ticksPerSecond: deps.ticksPerSecond,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}),
});
}
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
markJellyfinRemotePlaybackLoaded,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
secondsToJellyfinTicks,
@@ -528,3 +529,70 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
assert.equal(stopped, false);
assert.equal(cleared, false);
});
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
const tracker = createJellyfinRemoteReportTracker();
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
itemId: 'item-1',
playMethod: 'DirectPlay',
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
};
const calls: string[] = [];
let releaseProgress: () => void = () => undefined;
const progressGate = new Promise<void>((resolve) => {
releaseProgress = resolve;
});
const session = {
isConnected: () => true,
reportProgress: async ({ eventName }: { eventName: string }) => {
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
if (calls.length === 1) await progressGate;
return true;
},
reportStopped: async () => {
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
return true;
},
};
const shared = {
getActivePlayback: () => playback,
clearActivePlayback: () => {
playback = null;
},
getSession: () => session,
getMpvClient: () => ({ currentTimePos: 42 }),
ticksPerSecond: 10_000_000,
logDebug: () => undefined,
reportTracker: tracker,
};
const reportProgress = createReportJellyfinRemoteProgressHandler({
...shared,
getNow: () => 10_000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => undefined,
progressIntervalMs: 3000,
});
const reportStopped = createReportJellyfinRemoteStoppedHandler(shared);
// A periodic tick is mid-request when the stop starts.
const tick = reportProgress(true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
const stop = reportStopped();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(playback, null);
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
// A tick fired after the stop began must not report anything.
await reportProgress(true);
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
releaseProgress();
await tick;
await stop;
assert.deepEqual(calls, [
'progress:TimeUpdate:active',
'progress:TimeUpdate:cleared',
'stopped:cleared',
]);
});
+43 -4
View File
@@ -134,6 +134,29 @@ function isSeekLikePositionJump(
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
}
// Jellyfin re-creates a session's NowPlayingItem from any progress report, so a progress
// tick that lands after the stop report leaves the server showing playback forever. The
// tracker lets the stop handler wait for reports that are already in flight.
export type JellyfinRemoteReportTracker = {
track: (report: Promise<void>) => void;
settled: () => Promise<void>;
};
export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker {
const active = new Set<Promise<void>>();
return {
track: (report) => {
active.add(report);
void report.finally(() => active.delete(report));
},
settled: async () => {
while (active.size > 0) {
await Promise.allSettled([...active]);
}
},
};
}
export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
@@ -145,6 +168,7 @@ export type JellyfinRemoteProgressReporterDeps = {
progressIntervalMs: number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void;
reportTracker?: JellyfinRemoteReportTracker;
};
export function createReportJellyfinRemoteProgressHandler(
@@ -152,7 +176,7 @@ export function createReportJellyfinRemoteProgressHandler(
) {
let lastReportedPositionSeconds: number | null = null;
return async (force = false): Promise<void> => {
const report = async (force: boolean): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
const session = deps.getSession();
@@ -193,6 +217,12 @@ export function createReportJellyfinRemoteProgressHandler(
deps.logDebug('Failed to report Jellyfin remote progress', error);
}
};
return async (force = false): Promise<void> => {
const pending = report(force);
deps.reportTracker?.track(pending);
await pending;
};
}
export type JellyfinRemoteStoppedReporterDeps = {
@@ -203,6 +233,8 @@ export type JellyfinRemoteStoppedReporterDeps = {
getNow?: () => number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void;
logWarn?: (message: string) => void;
reportTracker?: JellyfinRemoteReportTracker;
};
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
@@ -226,6 +258,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
deps.clearActivePlayback();
return;
}
// Clear before any network call so progress ticks fired during the stop find nothing to
// report, then let reports already in flight finish so none can arrive after the stop.
deps.clearActivePlayback();
await deps.reportTracker?.settled();
try {
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
@@ -244,7 +280,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote final progress', error);
}
await session.reportStopped({
const reported = await session.reportStopped({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks,
@@ -254,10 +290,13 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'stop',
});
if (reported === false) {
deps.logWarn?.(
`Jellyfin did not accept the playback stop report for item ${playback.itemId}; the server may keep showing it as playing.`,
);
}
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote stop', error);
} finally {
deps.clearActivePlayback();
}
};
}
@@ -38,6 +38,7 @@ type JellyfinRemoteServiceOptions = {
};
onConnected: () => void;
onDisconnected: () => void;
logWarn?: (message: string, details?: unknown) => void;
onPlay: (payload: JellyfinRemoteEventPayload) => void;
onPlaystate: (payload: JellyfinRemoteEventPayload) => void;
onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void;
@@ -110,6 +111,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
onDisconnected: () => {
deps.logWarn('Jellyfin remote websocket disconnected; retrying.');
},
logWarn: (message, details) => deps.logWarn(message, details),
onPlay: (payload) => {
void deps.handlePlay(payload).catch((error) => {
deps.logWarn('Failed handling Jellyfin remote Play event', error);
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import { resolveMpvExecutablePath, spawnMpvProcess } from './mpv-process';
const testWindows = process.platform === 'win32' ? test : test.skip;
test('mpv process launcher forwards arguments and environment to the child', async () => {
const child = spawnMpvProcess(
process.execPath,
['-e', 'process.exit(Number(process.env.SUBMINER_TEST_EXIT))'],
{ ...process.env, DISPLAY: '', SUBMINER_TEST_EXIT: '17' },
);
try {
const [code] = await once(child, 'exit');
assert.equal(code, 17);
} finally {
if (child.exitCode === null) child.kill();
}
});
testWindows('managed Windows playback launches the configured executable', async () => {
const executable = resolveMpvExecutablePath(` ${process.execPath} `);
const child = spawnMpvProcess(executable, ['-e', 'process.exit(17)']);
try {
assert.equal(child.spawnfile, process.execPath);
const [code] = await once(child, 'exit');
assert.equal(code, 17);
} finally {
if (child.exitCode === null) child.kill();
}
});
testWindows('managed Windows playback rejects an invalid configured executable', () => {
assert.throws(
() => resolveMpvExecutablePath(`${process.execPath}/missing-mpv.exe`),
/Could not find mpv.exe/,
);
});
+112
View File
@@ -0,0 +1,112 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
shouldForceX11WaylandSession,
} from '../../shared/mpv-x11-backend';
export interface WindowsMpvPathDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
}
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
function fileExists(candidate: string): boolean {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
export function getConfiguredWindowsMpvPathStatus(
configuredMpvPath = '',
exists: (candidate: string) => boolean = fileExists,
): ConfiguredWindowsMpvPathStatus {
const configPath = configuredMpvPath.trim();
if (!configPath) {
return 'blank';
}
return exists(configPath) ? 'configured' : 'invalid';
}
export function createWindowsMpvPathDeps(
overrides: Partial<WindowsMpvPathDeps> = {},
): WindowsMpvPathDeps {
return {
getEnv: overrides.getEnv ?? ((name) => process.env[name]),
fileExists: overrides.fileExists ?? fileExists,
runWhere:
overrides.runWhere ??
(() => {
const result = spawnSync('where.exe', ['mpv.exe'], {
encoding: 'utf8',
windowsHide: true,
});
return {
status: result.status,
stdout: result.stdout ?? '',
error: result.error ?? undefined,
};
}),
};
}
export function resolveWindowsMpvPath(deps: WindowsMpvPathDeps, configuredMpvPath = ''): string {
const configPath = configuredMpvPath.trim();
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
if (configuredPathStatus === 'configured') {
return configPath;
}
if (configuredPathStatus === 'invalid') {
return '';
}
const envPath = deps.getEnv('SUBMINER_MPV_PATH')?.trim();
if (envPath && deps.fileExists(envPath)) {
return envPath;
}
const whereResult = deps.runWhere();
if (whereResult.status === 0) {
const firstPath = whereResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && deps.fileExists(line));
if (firstPath) {
return firstPath;
}
}
return '';
}
export function spawnMpvProcess(
executablePath: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): ReturnType<typeof spawn> {
const forceX11 = shouldForceX11WaylandSession(env);
return spawn(executablePath, forceX11 ? [...args, ...MPV_X11_BACKEND_ARGS] : args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: forceX11 ? applyX11EnvOverrides({ ...env }) : env,
});
}
export function resolveMpvExecutablePath(configuredMpvPath = ''): string {
const executablePath =
process.platform === 'win32'
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
: 'mpv';
if (!executablePath) {
throw new Error(
'Could not find mpv.exe. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.',
);
}
return executablePath;
}
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
fs.rmSync(root, { recursive: true, force: true });
}
});
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
const sequence: CompiledSessionBinding = {
sourcePath: 'shortcuts.openSubtitleSelection',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
};
let nativeKeys: unknown = [];
let failDiscovery = false;
let published: CompiledSessionBinding[] = [];
const events: CompiledSessionBinding[][] = [];
const warnings: string[] = [];
const client = {
connected: true,
send: () => {},
requestProperty: async () => {
if (failDiscovery) throw new Error('temporarily unavailable');
return nativeKeys;
},
};
const runtime = createSessionBindingsRuntime({
configDir: root,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
getMpvClient: () => client,
setSessionBindings: (bindings) => {
published = bindings;
},
setSessionBindingsInitialized: () => {},
logWarn: () => {},
onBindingsChanged: (bindings) => events.push(bindings),
onWarning: (warning) => warnings.push(warning.message),
});
const readArtifact = () =>
JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8'));
try {
runtime.persistSessionBindings([sequence]);
nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }];
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, []);
assert.deepEqual(events.at(-1), readArtifact().bindings);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /mpv input binding "g"/);
await runtime.refreshMpvSessionBindings();
assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop');
assert.equal(warnings.length, 1);
failDiscovery = true;
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [], 'failed discovery retains the known conflict');
failDiscovery = false;
nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }];
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [sequence]);
assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS');
assert.deepEqual(readArtifact().warnings, []);
client.connected = false;
await runtime.refreshMpvSessionBindings();
assert.deepEqual(published, [sequence]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+83 -7
View File
@@ -6,16 +6,26 @@ import {
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
import {
reserveMpvSequencePrefixes,
resolveSessionSequenceConflicts,
} from '../../shared/session-key-sequences';
import type { SessionBindingWarning } from '../../types/session-bindings';
export interface SessionBindingsRuntimeDeps {
configDir: string;
getKeybindings: () => Keybinding[];
getConfiguredShortcuts: () => ConfiguredShortcuts;
getResolvedConfig: () => ResolvedConfig;
getMpvClient: () => MpvRuntimeClientLike | null;
getMpvClient: () =>
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
| null;
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
setSessionBindingsInitialized: (initialized: boolean) => void;
logWarn: (message: string, details?: unknown) => void;
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
onWarning?: (warning: SessionBindingWarning) => void;
}
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
) => void;
refreshCurrentSessionBindings: () => void;
refreshMpvSessionBindings: () => Promise<void>;
} {
let sourceBindings: CompiledSessionBinding[] = [];
let sourceWarnings: SessionBindingWarning[] = [];
let nativeSnapshot: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
keys: string[];
} | null = null;
let pending: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
promise: Promise<void>;
} | null = null;
let publishedSignature: string | null = null;
let reportedWarnings = new Set<string>();
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
bindings: CompiledSessionBinding[],
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
): void {
sourceBindings = bindings;
sourceWarnings = warnings;
publishBindings();
}
function publishBindings(): void {
const client = deps.getMpvClient();
const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : [];
const result = resolveSessionSequenceConflicts(
sourceBindings,
reserveMpvSequencePrefixes(keys),
);
const warnings = [...sourceWarnings, ...result.warnings];
const signature = JSON.stringify([
result.bindings,
warnings,
deps.getConfiguredShortcuts().multiCopyTimeoutMs,
]);
if (signature === publishedSignature) return;
const artifact = buildPluginSessionBindingsArtifact({
bindings,
bindings: result.bindings,
warnings,
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
});
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
throw error;
}
deps.setSessionBindings(bindings);
publishedSignature = signature;
deps.setSessionBindings(result.bindings);
deps.setSessionBindingsInitialized(true);
const nextWarnings = new Set(warnings.map((warning) => warning.message));
for (const warning of warnings) {
if (reportedWarnings.has(warning.message)) continue;
deps.logWarn(`[session-bindings] ${warning.message}`);
deps.onWarning?.(warning);
}
reportedWarnings = nextWarnings;
const mpvClient = deps.getMpvClient();
if (mpvClient?.connected) {
try {
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
}
}
deps.onBindingsChanged?.(result.bindings);
}
async function refreshMpvSessionBindings(): Promise<void> {
const client = deps.getMpvClient();
if (!client?.connected) {
nativeSnapshot = null;
publishBindings();
return;
}
if (pending?.client === client) return pending.promise;
const promise = (async () => {
try {
const raw = await client.requestProperty('input-bindings');
if (client !== deps.getMpvClient() || !client.connected) return;
nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) };
publishBindings();
} catch {
// Keep the last successful snapshot if discovery is temporarily unavailable.
}
})();
const request = { client, promise };
pending = request;
try {
await promise;
} finally {
if (pending === request) pending = null;
}
}
function refreshCurrentSessionBindings(): void {
const compiled = compileCurrentSessionBindings();
for (const warning of compiled.warnings) {
deps.logWarn(`[session-bindings] ${warning.message}`);
}
persistSessionBindings(compiled.bindings, compiled.warnings);
void refreshMpvSessionBindings();
}
return { persistSessionBindings, refreshCurrentSessionBindings };
return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
}
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSubtitleSelectionRuntime } from './subtitle-selection';
function setup() {
let enabled = true;
const properties = new Map<string, unknown>([
['path', '/video.mkv'],
[
'track-list',
[
{ id: 1, type: 'audio' },
{ id: 2, type: 'sub', title: 'Japanese', lang: 'ja', codec: 'ass' },
{ id: 3, type: 'sub', title: 'English', lang: 'en', external: true },
{ id: '4', type: 'sub' },
],
],
['sid', 2],
['secondary-sid', 3],
]);
const commands: unknown[][] = [];
const client = {
connected: true,
requestProperty: async (name: string) => properties.get(name),
request: async (command: unknown[]) => {
commands.push(command);
return { error: 'success' };
},
};
const runtime = createSubtitleSelectionRuntime({
isEnabled: () => enabled,
getMpvClient: () => client,
});
return {
runtime,
properties,
commands,
client,
disable: () => {
enabled = false;
},
};
}
test('subtitle selector lists only valid subtitle tracks and current selections', async () => {
const { runtime, properties } = setup();
assert.deepEqual(await runtime.getState(), {
mediaPath: '/video.mkv',
primary: 2,
secondary: 3,
tracks: [
{ id: 2, label: '#2 · Japanese · ja · ass' },
{ id: 3, label: '#3 · English · en · external' },
],
});
properties.set('sid', 'no');
properties.set('secondary-sid', false);
const state = await runtime.getState();
assert.equal(state.primary, null);
assert.equal(state.secondary, null);
});
test('subtitle selector swaps tracks and supports disabling both tracks', async () => {
const { runtime, commands } = setup();
await runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 });
assert.deepEqual(commands, [
['set_property', 'secondary-sid', 'no'],
['set_property', 'sid', 3],
['set_property', 'secondary-sid', 2],
]);
commands.length = 0;
await runtime.apply({ mediaPath: '/video.mkv', primary: null, secondary: null });
assert.ok(commands.every((command) => command[2] === 'no'));
});
test('subtitle selector rejects stale media, unavailable tracks, duplicate tracks and malformed requests without mutation', async () => {
const { runtime, commands } = setup();
for (const request of [
{ mediaPath: '/other.mkv', primary: 2, secondary: 3 },
{ mediaPath: '/video.mkv', primary: 99, secondary: null },
{ mediaPath: '/video.mkv', primary: 2, secondary: 2 },
{ mediaPath: '/video.mkv', primary: '2', secondary: null },
{ mediaPath: '/video.mkv', primary: -1, secondary: null },
null,
])
await assert.rejects(runtime.apply(request));
assert.deepEqual(commands, []);
});
test('subtitle selector gates access on config and connection and propagates mpv failures', async () => {
const { runtime, client, disable } = setup();
client.request = async () => ({ error: 'property unavailable' });
await assert.rejects(
runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 }),
/property unavailable/,
);
client.connected = false;
await assert.rejects(runtime.getState(), /Connect to mpv/);
disable();
await assert.rejects(runtime.getState(), /Enable subtitle selection/);
});
+117
View File
@@ -0,0 +1,117 @@
import type { IpcMain, WebContents } from 'electron';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
parseSubtitleSelectionRequest,
type SubtitleSelectionState,
} from '../../shared/subtitle-selection';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
interface SelectionMpvClient {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
request: (command: unknown[]) => Promise<{ error?: string }>;
}
export function openSubtitleSelectionModal(
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
): Promise<boolean> {
return retryOverlayModalOpen(deps, {
modal: 'subtitle-selection',
timeoutMs: 1500,
retryWarning: 'Subtitle selection modal did not acknowledge opening; retrying.',
sendOpen: () =>
openOverlayHostedModal(deps, {
channel: IPC_CHANNELS.event.subtitleSelectionOpen,
modal: 'subtitle-selection',
preferModalWindow: true,
}),
});
}
export function createSubtitleSelectionRuntime(deps: {
isEnabled: () => boolean;
getMpvClient: () => SelectionMpvClient | null;
}) {
function getClient(): SelectionMpvClient {
if (!deps.isEnabled()) throw new Error('Enable subtitle selection in Settings first.');
const client = deps.getMpvClient();
if (!client?.connected) throw new Error('Connect to mpv first.');
return client;
}
async function readState(client: SelectionMpvClient): Promise<SubtitleSelectionState> {
const mediaPath = await client.requestProperty('path');
if (typeof mediaPath !== 'string' || !mediaPath) throw new Error('Open a video first.');
const [rawTracks, primary, secondary] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('secondary-sid'),
]);
const tracks: SubtitleSelectionState['tracks'] = [];
const candidates: unknown[] = Array.isArray(rawTracks) ? rawTracks : [];
for (const track of candidates) {
if (
typeof track !== 'object' ||
track === null ||
!('type' in track) ||
track.type !== 'sub' ||
!('id' in track) ||
typeof track.id !== 'number' ||
!Number.isSafeInteger(track.id) ||
track.id <= 0
)
continue;
const details = [
'title' in track ? track.title : undefined,
'lang' in track ? track.lang : undefined,
'codec' in track ? track.codec : undefined,
].filter((value): value is string => typeof value === 'string' && value.length > 0);
if ('external' in track && track.external === true) details.push('external');
tracks.push({ id: track.id, label: `#${track.id} · ${details.join(' · ') || 'Subtitle'}` });
}
if ((await client.requestProperty('path')) !== mediaPath)
throw new Error('The video changed. Reopen subtitle selection.');
const selected = (value: unknown): number | null =>
tracks.find((track) => track.id === value)?.id ?? null;
return { mediaPath, tracks, primary: selected(primary), secondary: selected(secondary) };
}
async function apply(value: unknown): Promise<void> {
const selection = parseSubtitleSelectionRequest(value);
const client = getClient();
const current = await readState(client);
if (current.mediaPath !== selection.mediaPath)
throw new Error('The video changed. Reopen subtitle selection.');
for (const id of [selection.primary, selection.secondary]) {
if (id !== null && !current.tracks.some((track) => track.id === id))
throw new Error('A selected track is no longer available. Reopen subtitle selection.');
}
const set = async (property: string, id: number | null): Promise<void> => {
const response = await client.request(['set_property', property, id ?? 'no']);
if (response.error && response.error !== 'success') throw new Error(response.error);
};
// Clear secondary first so swapping the two tracks works in mpv.
await set('secondary-sid', null);
await set('sid', selection.primary);
await set('secondary-sid', selection.secondary);
}
return { getState: async () => readState(getClient()), apply };
}
export function registerSubtitleSelectionIpc(deps: {
ipc: Pick<IpcMain, 'handle'>;
isAllowedSender: (sender: WebContents) => boolean;
runtime: ReturnType<typeof createSubtitleSelectionRuntime>;
}): void {
deps.ipc.handle(IPC_CHANNELS.request.getSubtitleSelection, (event) => {
if (!deps.isAllowedSender(event.sender))
throw new Error('Subtitle selection requires the overlay.');
return deps.runtime.getState();
});
deps.ipc.handle(IPC_CHANNELS.request.applySubtitleSelection, (event, value: unknown) => {
if (!deps.isAllowedSender(event.sender))
throw new Error('Subtitle selection requires the overlay.');
return deps.runtime.apply(value);
});
}
+13 -3
View File
@@ -29,12 +29,12 @@ test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
});
test('resolveWindowsMpvPath prefers configured executable path before PATH', () => {
test('resolveWindowsMpvPath prefers configured executable path before environment and PATH', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
getEnv: () => undefined,
getEnv: () => 'C:\\other\\mpv.exe',
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
fileExists: (candidate) => ['C:\\mpv\\mpv.exe', 'C:\\other\\mpv.exe'].includes(candidate),
}),
' C:\\mpv\\mpv.exe ',
);
@@ -53,6 +53,16 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
assert.equal(resolved, 'C:\\tools\\mpv.exe');
});
test('resolveWindowsMpvPath ignores an invalid environment override but keeps config authoritative', () => {
const deps = createDeps({
getEnv: () => 'C:\\missing\\mpv.exe',
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
});
assert.equal(resolveWindowsMpvPath(deps), 'C:\\tools\\mpv.exe');
assert.equal(resolveWindowsMpvPath(deps, 'C:\\missing\\mpv.exe'), '');
});
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
assert.deepEqual(
buildWindowsMpvLaunchArgs(
+18 -75
View File
@@ -1,5 +1,3 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files';
import { canConnectSocket } from '../../shared/socket-probe';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
@@ -8,11 +6,19 @@ import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-
import type { MpvLaunchMode } from '../../types/config';
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
import {
createWindowsMpvPathDeps,
resolveWindowsMpvPath,
spawnMpvProcess,
type WindowsMpvPathDeps,
} from './mpv-process';
export {
getConfiguredWindowsMpvPathStatus,
resolveWindowsMpvPath,
type ConfiguredWindowsMpvPathStatus,
} from './mpv-process';
export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
export interface WindowsMpvLaunchDeps extends WindowsMpvPathDeps {
spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>;
isAppControlServerAvailable?: () => Promise<boolean>;
sendAppControlCommand?: (
@@ -23,8 +29,6 @@ export interface WindowsMpvLaunchDeps {
logInfo?: (message: string) => void;
}
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
export interface WindowsMpvRuntimePluginPolicy {
detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection;
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
@@ -38,54 +42,6 @@ function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
function defaultWindowsMpvFileExists(candidate: string): boolean {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
export function getConfiguredWindowsMpvPathStatus(
configuredMpvPath = '',
fileExists: (candidate: string) => boolean = defaultWindowsMpvFileExists,
): ConfiguredWindowsMpvPathStatus {
const configPath = normalizeCandidate(configuredMpvPath);
if (!configPath) {
return 'blank';
}
return fileExists(configPath) ? 'configured' : 'invalid';
}
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps, configuredMpvPath = ''): string {
const configPath = normalizeCandidate(configuredMpvPath);
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
if (configuredPathStatus === 'configured') {
return configPath;
}
if (configuredPathStatus === 'invalid') {
return '';
}
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
if (envPath && deps.fileExists(envPath)) {
return envPath;
}
const whereResult = deps.runWhere();
if (whereResult.status === 0) {
const firstPath = whereResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && deps.fileExists(line));
if (firstPath) {
return firstPath;
}
}
return '';
}
const DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
const RUNNING_APP_ATTACH_SOCKET_WAIT_MS = 10000;
@@ -332,19 +288,7 @@ export function createWindowsMpvLaunchDeps(options: {
logInfo?: (message: string) => void;
}): WindowsMpvLaunchDeps {
return {
getEnv: options.getEnv ?? ((name) => process.env[name]),
runWhere: () => {
const result = spawnSync('where.exe', ['mpv.exe'], {
encoding: 'utf8',
windowsHide: true,
});
return {
status: result.status,
stdout: result.stdout ?? '',
error: result.error ?? undefined,
};
},
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
...createWindowsMpvPathDeps(options),
isAppControlServerAvailable: options.isAppControlServerAvailable,
sendAppControlCommand: options.sendAppControlCommand,
waitForSocketReady,
@@ -352,12 +296,11 @@ export function createWindowsMpvLaunchDeps(options: {
spawnDetached: (command, args, env) =>
new Promise((resolve, reject) => {
try {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: env ? { ...process.env, ...env } : process.env,
});
const child = spawnMpvProcess(
command,
args,
env ? { ...process.env, ...env } : process.env,
);
let settled = false;
child.once('error', (error) => {
if (settled) return;
+14
View File
@@ -119,6 +119,7 @@ import { IPC_CHANNELS } from './shared/ipc/contracts';
import type { SubtitleGenerationProgress } from './shared/subtitle-generation';
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
const onSubtitleSelectionOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleSelectionOpen);
const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen);
type EmptyListener = () => void;
@@ -501,6 +502,11 @@ const electronAPI: ElectronAPI = {
) as Promise<boolean>,
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
onSubtitleSelectionOpen,
getSubtitleSelection: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSelection),
applySubtitleSelection: (
request: import('./shared/subtitle-selection').SubtitleSelectionRequest,
) => ipcRenderer.invoke(IPC_CHANNELS.request.applySubtitleSelection, request),
onSubsyncManualOpen: onSubsyncManualOpenEvent,
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
@@ -618,6 +624,14 @@ const electronAPI: ElectronAPI = {
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
},
onSessionBindingsChanged: (
callback: (bindings: import('./types').CompiledSessionBinding[]) => void,
) => {
ipcRenderer.on(
IPC_CHANNELS.event.sessionBindingsChanged,
(_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings),
);
},
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.configHotReload,
@@ -4,6 +4,7 @@ type ControllerInteractionModalState = {
jimakuModalOpen: boolean;
kikuModalOpen: boolean;
runtimeOptionsModalOpen: boolean;
subtitleSelectionModalOpen?: boolean;
subsyncModalOpen: boolean;
subtitleGenerationModalOpen?: boolean;
youtubePickerModalOpen: boolean;
@@ -18,6 +19,7 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal
state.jimakuModalOpen ||
state.kikuModalOpen ||
state.runtimeOptionsModalOpen ||
state.subtitleSelectionModalOpen ||
state.subsyncModalOpen ||
Boolean(state.subtitleGenerationModalOpen) ||
state.youtubePickerModalOpen ||
+84
View File
@@ -92,6 +92,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
@@ -2404,3 +2405,86 @@ test('stalled mpv discovery does not delay configured overlay controls', async (
testGlobals.restore();
}
});
test('session binding: g-s opens subtitle selection only after the complete sequence', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openSubtitleSelection',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.deepEqual(testGlobals.sessionActions, []);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'openSubtitleSelection', payload: undefined },
]);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
handlers.updateSessionBindings([]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.equal(testGlobals.sessionActions.length, 1);
} finally {
testGlobals.restore();
}
});
test('single-key actions run immediately even if a conflicting sequence reaches the renderer', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'sequence',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
{
sourcePath: 'single',
originalKey: 'g',
key: { code: 'KeyG', modifiers: [] },
actionType: 'mpv-command',
command: ['show-text', 'single'],
},
]);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.deepEqual(testGlobals.mpvCommands, [['show-text', 'single']]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, []);
} finally {
testGlobals.restore();
}
});
test('an unfinished built-in y chord cannot start a configured sequence', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'sequence',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
]);
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, []);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.equal(testGlobals.sessionActions.length, 1);
} finally {
testGlobals.restore();
}
});
+36 -1
View File
@@ -14,6 +14,7 @@ export function createKeyboardHandlers(
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleSubtitleSelectionKeydown?: (e: KeyboardEvent) => boolean;
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
@@ -132,7 +133,10 @@ export function createKeyboardHandlers(
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
}
let pendingSequence: { prefix: string; expires: number } | null = null;
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
pendingSequence = null;
ctx.state.sessionBindings = bindings;
ctx.state.sessionBindingMap = new Map(
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
@@ -1048,7 +1052,10 @@ export function createKeyboardHandlers(
window.addEventListener('focus', () => {
void importedMpvBindings.refresh();
});
window.addEventListener('blur', importedMpvBindings.releaseAll);
window.addEventListener('blur', () => {
pendingSequence = null;
importedMpvBindings.releaseAll();
});
window.addEventListener('beforeunload', () => {
clearTimeout(lateScriptRefresh);
importedMpvBindings.dispose();
@@ -1102,6 +1109,13 @@ export function createKeyboardHandlers(
);
document.addEventListener('keydown', (e: KeyboardEvent) => {
const sequence = pendingSequence;
pendingSequence = null;
if (ctx.state.subtitleSelectionModalOpen) {
pendingSequence = null;
options.handleSubtitleSelectionKeydown?.(e);
return;
}
if (ctx.state.subtitleGenerationModalOpen) {
options.handleSubtitleGenerationKeydown?.(e);
return;
@@ -1186,6 +1200,7 @@ export function createKeyboardHandlers(
}
if (isTextEntryTarget(e.target)) {
pendingSequence = null;
return;
}
@@ -1193,6 +1208,16 @@ export function createKeyboardHandlers(
return;
}
const sequenceKey = keyEventToString(e);
if (sequence && !ctx.state.chordPending && Date.now() <= sequence.expires && !e.repeat) {
const binding = ctx.state.sessionBindingMap.get(`${sequence.prefix}-${sequenceKey}`);
if (binding) {
e.preventDefault();
dispatchSessionBinding(binding);
return;
}
}
if (isStatsOverlayToggle(e)) {
e.preventDefault();
window.electronAPI.toggleStatsOverlay();
@@ -1275,6 +1300,16 @@ export function createKeyboardHandlers(
dispatchSessionBinding(binding);
return;
}
if (
!e.repeat &&
ctx.state.sessionBindings.some((binding) =>
keyEventToStringFromBinding(binding).startsWith(`${sequenceKey}-`),
)
) {
pendingSequence = { prefix: sequenceKey, expires: Date.now() + 1000 };
e.preventDefault();
return;
}
if (
keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen &&
+37
View File
@@ -833,6 +833,43 @@
</div>
</div>
</div>
<div id="subtitleSelectionModal" class="modal hidden" aria-hidden="true">
<div
class="modal-content subsync-modal-content"
role="dialog"
aria-modal="true"
aria-labelledby="subtitleSelectionTitle"
>
<div class="modal-header">
<h2 id="subtitleSelectionTitle">Select subtitles</h2>
<button id="subtitleSelectionClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="subsync-form">
<label class="subsync-field">
<span>Primary subtitle</span>
<select id="subtitleSelectionPrimary"></select>
</label>
<label class="subsync-field">
<span>Secondary subtitle</span>
<select id="subtitleSelectionSecondary"></select>
</label>
</div>
<div
id="subtitleSelectionStatus"
class="runtime-options-status"
role="status"
aria-live="polite"
></div>
<div class="subsync-footer">
<button id="subtitleSelectionApply" class="kiku-confirm-button" type="button">
Apply
</button>
</div>
</div>
</div>
</div>
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content">
<div class="modal-header">
@@ -225,6 +225,8 @@ function describeSessionAction(
return 'Open jimaku';
case 'openTsukihime':
return 'Open TsukiHime';
case 'openSubtitleSelection':
return 'Select subtitle tracks';
case 'openSubtitleGeneration':
return 'Generate Japanese subtitles';
case 'openYoutubePicker':
@@ -268,6 +270,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
case 'openJimaku':
case 'openTsukihime':
case 'openCharacterDictionaryManager':
case 'openSubtitleSelection':
case 'openSubtitleGeneration':
case 'openControllerSelect':
case 'openControllerDebug':
+172
View File
@@ -0,0 +1,172 @@
import type { SubtitleSelectionState } from '../../shared/subtitle-selection';
import type { RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
import { createModalFocusGuard } from './modal-focus-guard';
function element<T extends HTMLElement>(id: string, constructor: new () => T): T {
const node = document.getElementById(id);
if (!(node instanceof constructor)) throw new Error(`Missing subtitle selection element: ${id}`);
return node;
}
export function createSubtitleSelectionModal(
ctx: RendererContext,
options: { syncSettingsModalSubtitleSuppression: () => void },
) {
const dom = {
modal: element('subtitleSelectionModal', HTMLDivElement),
primary: element('subtitleSelectionPrimary', HTMLSelectElement),
secondary: element('subtitleSelectionSecondary', HTMLSelectElement),
status: element('subtitleSelectionStatus', HTMLDivElement),
apply: element('subtitleSelectionApply', HTMLButtonElement),
close: element('subtitleSelectionClose', HTMLButtonElement),
};
let snapshot: SubtitleSelectionState | null = null;
let generation = 0;
let pending = false;
let priorFocus: Element | null = null;
const focus = createModalFocusGuard({
isOpen: () => ctx.state.subtitleSelectionModalOpen,
getModalRoot: () => dom.modal,
getPreferredFocusTargets: () => [dom.primary, dom.secondary, dom.apply],
getFallbackFocusTarget: () => dom.close,
isModalLayer: ctx.platform.isModalLayer,
});
function status(message: string, error = false): void {
dom.status.textContent = message;
dom.status.classList.toggle('error', error);
}
function updateControls(): void {
const disabled = pending || !snapshot;
dom.primary.disabled = disabled;
dom.secondary.disabled = disabled;
const duplicate = dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value;
dom.apply.disabled = disabled || duplicate;
for (const option of dom.secondary.options)
option.disabled = option.value !== 'no' && option.value === dom.primary.value;
}
function populate(select: HTMLSelectElement, selected: number | null): void {
select.replaceChildren();
for (const track of [{ id: null, label: 'None' }, ...(snapshot?.tracks ?? [])]) {
const option = document.createElement('option');
option.value = track.id === null ? 'no' : String(track.id);
option.textContent = track.label;
select.append(option);
}
select.value = selected === null ? 'no' : String(selected);
}
async function refresh(openGeneration: number): Promise<void> {
try {
const next = await window.electronAPI.getSubtitleSelection();
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
snapshot = next;
populate(dom.primary, next.primary);
populate(dom.secondary, next.secondary);
status(
next.tracks.length
? 'Choose subtitle tracks, then apply.'
: 'No subtitle tracks loaded in this video.',
);
} catch (cause) {
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
status(cause instanceof Error ? cause.message : 'Could not read subtitle tracks.', true);
} finally {
if (generation === openGeneration && ctx.state.subtitleSelectionModalOpen) {
pending = false;
updateControls();
focus.focusFallbackTarget();
}
}
}
function open(): void {
if (ctx.state.subtitleSelectionModalOpen) return;
priorFocus = document.activeElement;
snapshot = null;
pending = true;
generation += 1;
populate(dom.primary, null);
populate(dom.secondary, null);
status('Loading subtitle tracks...');
updateControls();
ctx.state.subtitleSelectionModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
dom.modal.classList.remove('hidden');
dom.modal.setAttribute('aria-hidden', 'false');
syncOverlayMouseIgnoreState(ctx);
focus.attach();
focus.focusFallbackTarget();
window.electronAPI.notifyOverlayModalOpened('subtitle-selection');
void refresh(generation);
}
function close(): void {
if (!ctx.state.subtitleSelectionModalOpen) return;
generation += 1;
ctx.state.subtitleSelectionModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
dom.modal.classList.add('hidden');
dom.modal.setAttribute('aria-hidden', 'true');
focus.detach();
window.electronAPI.notifyOverlayModalClosed('subtitle-selection');
syncOverlayMouseIgnoreState(ctx);
if (priorFocus instanceof HTMLElement) priorFocus.focus({ preventScroll: true });
priorFocus = null;
}
async function apply(): Promise<void> {
if (dom.apply.disabled || pending || !snapshot) return;
const openGeneration = generation;
pending = true;
updateControls();
status('Applying subtitle tracks...');
try {
await window.electronAPI.applySubtitleSelection({
mediaPath: snapshot.mediaPath,
primary: dom.primary.value === 'no' ? null : Number(dom.primary.value),
secondary: dom.secondary.value === 'no' ? null : Number(dom.secondary.value),
});
if (generation === openGeneration) close();
} catch (cause) {
if (generation === openGeneration)
status(cause instanceof Error ? cause.message : 'Could not select subtitle tracks.', true);
} finally {
if (generation === openGeneration) {
pending = false;
updateControls();
}
}
}
function handleKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
close();
} else if (
event.key === 'Enter' &&
!(event.target instanceof HTMLSelectElement) &&
event.target !== dom.close
) {
event.preventDefault();
void apply();
}
return true;
}
function wireDomEvents(): void {
dom.close.addEventListener('click', close);
dom.apply.addEventListener('click', () => void apply());
dom.primary.addEventListener('change', () => {
if (dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value)
dom.secondary.value = 'no';
updateControls();
});
dom.secondary.addEventListener('change', updateControls);
}
return { open, close, handleKeydown, wireDomEvents, dispose: () => focus.detach() };
}
+1
View File
@@ -10,6 +10,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
state.youtubePickerModalOpen ||
state.kikuModalOpen ||
state.runtimeOptionsModalOpen ||
state.subtitleSelectionModalOpen ||
state.subsyncModalOpen ||
state.subtitleGenerationModalOpen ||
state.sessionHelpModalOpen,
+21 -1
View File
@@ -44,6 +44,7 @@ import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selectio
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubtitleSelectionModal } from './modals/subtitle-selection';
import { createSubsyncModal } from './modals/subsync.js';
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
@@ -153,6 +154,12 @@ const modalDescriptors = [
close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
suppressesSubtitles: true,
},
{
id: 'subtitle-selection',
isOpen: () => ctx.state.subtitleSelectionModalOpen,
close: () => subtitleSelectionModal.close(),
suppressesSubtitles: true,
},
{
id: 'subsync',
isOpen: () => ctx.state.subsyncModalOpen,
@@ -216,6 +223,9 @@ const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subtitleSelectionModal = createSubtitleSelectionModal(ctx, {
syncSettingsModalSubtitleSuppression,
});
const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -293,6 +303,7 @@ const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
@@ -624,6 +635,9 @@ function registerModalOpenHandlers(): void {
youtubePickerModal.closeYoutubePickerModal();
});
});
window.electronAPI.onSubtitleSelectionOpen(() => {
runGuarded('subtitle-selection:open', () => subtitleSelectionModal.open());
});
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
runGuarded('subsync:manual-open', () => {
subsyncModal.openSubsyncModal(payload);
@@ -849,6 +863,7 @@ async function init(): Promise<void> {
playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
subtitleSelectionModal.wireDomEvents();
subsyncModal.wireDomEvents();
subtitleGenerationModal.wireDomEvents();
controllerSelectModal.wireDomEvents();
@@ -858,6 +873,7 @@ async function init(): Promise<void> {
subtitleSidebarModal.wireDomEvents();
characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => {
subtitleSelectionModal.dispose();
subtitleGenerationModal.dispose();
subtitleSidebarModal.disposeDomEvents();
});
@@ -867,9 +883,13 @@ async function init(): Promise<void> {
runtimeOptionsModal.updateRuntimeOptions(options);
});
});
window.electronAPI.onSessionBindingsChanged(keyboardHandlers.updateSessionBindings);
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
runGuarded('config:hot-reload', () => {
keyboardHandlers.updateSessionBindings(payload.sessionBindings);
void window.electronAPI
.getSessionBindings()
.then(keyboardHandlers.updateSessionBindings)
.catch((error: unknown) => console.error('Could not refresh session bindings', error));
void keyboardHandlers.refreshConfiguredShortcuts();
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
+2
View File
@@ -89,6 +89,7 @@ export type RendererState = {
characterDictionaryStatus: string;
subsyncModalOpen: boolean;
subtitleSelectionModalOpen: boolean;
subtitleGenerationModalOpen: boolean;
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
subsyncSubmitting: boolean;
@@ -223,6 +224,7 @@ export function createRendererState(): RendererState {
characterDictionaryStatus: '',
subsyncModalOpen: false,
subtitleSelectionModalOpen: false,
subtitleGenerationModalOpen: false,
subsyncSubtitleTracks: [],
subsyncSubmitting: false,
+10
View File
@@ -3228,6 +3228,16 @@ iframe[id^='yomitan-popup'],
padding: 8px 10px;
}
.subsync-field select:focus-visible {
outline: 2px solid var(--ctp-blue);
outline-offset: 2px;
}
#subtitleSelectionModal .kiku-confirm-button:disabled {
opacity: 0.5;
cursor: default;
}
.subsync-footer {
display: flex;
justify-content: flex-end;
+5
View File
@@ -4,6 +4,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
export const OVERLAY_HOSTED_MODALS = [
'runtime-options',
'subsync',
'subtitle-selection',
'subtitle-generation',
'jimaku',
'tsukihime',
@@ -51,6 +52,8 @@ export const IPC_CHANNELS = {
dispatchSessionAction: 'session-action:dispatch',
},
request: {
getSubtitleSelection: 'subtitle-selection:get',
applySubtitleSelection: 'subtitle-selection:apply',
requestSubtitleGenerationOpen: 'subtitle-generation:open',
getSubtitleGenerationStatus: 'subtitle-generation:status',
startSubtitleGeneration: 'subtitle-generation:start',
@@ -143,6 +146,7 @@ export const IPC_CHANNELS = {
mediaTimingReviewResolve: 'media-timing-review:resolve',
},
event: {
subtitleSelectionOpen: 'subtitle-selection:opened',
subtitleGenerationOpen: 'subtitle-generation:opened',
subtitleGenerationProgress: 'subtitle-generation:progress',
subtitleSet: 'subtitle:set',
@@ -174,6 +178,7 @@ export const IPC_CHANNELS = {
controllerDebugOpen: 'controller-debug:open',
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
sessionBindingsChanged: 'session-bindings:changed',
configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification',
notificationHistoryToggle: 'notification-history:toggle',
+1
View File
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openControllerDebug',
'openJimaku',
'openTsukihime',
'openSubtitleSelection',
'openSubtitleGeneration',
'openYoutubePicker',
'openPlaylistBrowser',
+10
View File
@@ -103,3 +103,13 @@ test('SubMiner ownership recognizes leading mpv prefixes without matching comman
['d', 'e', 'f', 'g'],
);
});
test('sequence conflict discovery allows winning ignore bindings used by mpv sequence prefixes', () => {
const bindings = [
{ key: 'g', cmd: 'show-text old', priority: 0 },
{ key: 'g', cmd: 'no-osd ignore', priority: 1 },
{ key: 'h', cmd: 'show-text action', priority: 0 },
];
assert.deepEqual(parseMpvInputBindingKeys(bindings), ['g', 'h']);
assert.deepEqual(parseMpvInputBindingKeys(bindings, { includeIgnored: false }), ['h']);
});
+13 -4
View File
@@ -59,9 +59,12 @@ export function keyboardEventToMpvKey(
return normalizeMpvInputKey([...modifiers, key].join('+'));
}
export function parseMpvInputBindingKeys(value: unknown): string[] {
export function parseMpvInputBindingKeys(
value: unknown,
{ includeIgnored = true }: { includeIgnored?: boolean } = {},
): string[] {
if (!Array.isArray(value)) return [];
const bindings = new Map<string, { priority: number; owned: boolean }>();
const bindings = new Map<string, { priority: number; owned: boolean; ignored: boolean }>();
for (const candidate of value) {
const entry: unknown = candidate;
if (
@@ -94,8 +97,14 @@ export function parseMpvInputBindingKeys(value: unknown): string[] {
entry.priority > previous.priority ||
(entry.priority === previous.priority && owned)
) {
bindings.set(key, { priority: entry.priority, owned });
bindings.set(key, {
priority: entry.priority,
owned,
ignored: entry.cmd.trim().replace(MPV_COMMAND_PREFIXES, '') === 'ignore',
});
}
}
return [...bindings].filter(([, binding]) => !binding.owned).map(([key]) => key);
return [...bindings]
.filter(([, binding]) => !binding.owned && (includeIgnored || !binding.ignored))
.map(([key]) => key);
}
+71
View File
@@ -0,0 +1,71 @@
import type {
CompiledSessionBinding,
SessionBindingWarning,
SessionKeySpec,
} from '../types/session-bindings';
export interface SessionKeyReservation {
key: SessionKeySpec;
path: string;
}
export function getSessionSequencePrefix(key: SessionKeySpec): SessionKeySpec | null {
const match = /^(Key[A-Z])-Key[A-Z]$/.exec(key.code);
return match?.[1] ? { code: match[1], modifiers: key.modifiers } : null;
}
function signature(key: SessionKeySpec): string {
return [...key.modifiers, key.code].join('+');
}
export function resolveSessionSequenceConflicts(
bindings: CompiledSessionBinding[],
reservations: SessionKeyReservation[] = [],
): { bindings: CompiledSessionBinding[]; warnings: SessionBindingWarning[] } {
const singles = new Map<string, string[]>();
for (const { key, path } of [
...bindings.map((binding) => ({ key: binding.key, path: binding.sourcePath })),
...reservations,
]) {
if (getSessionSequencePrefix(key)) continue;
const id = signature(key);
singles.set(id, [...(singles.get(id) ?? []), path]);
}
const warnings: SessionBindingWarning[] = [];
const effective = bindings.filter((binding) => {
const prefix = getSessionSequencePrefix(binding.key);
if (!prefix) return true;
const conflicts = singles.get(signature(prefix));
if (!conflicts?.length) return true;
const paths = [...new Set(conflicts)];
warnings.push({
kind: 'conflict',
path: binding.sourcePath,
value: binding.originalKey,
conflictingPaths: paths,
message: `Disabled sequence "${binding.originalKey}" (${binding.sourcePath}): its first key is reserved by ${paths.join(', ')}. Single-key bindings take priority; remap the sequence or its conflicting binding.`,
});
return false;
});
return { bindings: effective, warnings };
}
// Imported mpv keys preserve case: g and G are different strokes.
export function reserveMpvSequencePrefixes(keys: string[]): SessionKeyReservation[] {
return keys.flatMap((value) => {
const parts = value.split('+');
const letter = parts.pop();
if (!letter || !/^[a-z]$/i.test(letter)) return [];
const modifiers: SessionKeySpec['modifiers'] = [];
if (parts.includes('ctrl')) modifiers.push('ctrl');
if (parts.includes('alt')) modifiers.push('alt');
if (parts.includes('shift') || /^[A-Z]$/.test(letter)) modifiers.push('shift');
if (parts.includes('meta')) modifiers.push('meta');
return [
{
key: { code: `Key${letter.toUpperCase()}`, modifiers },
path: `mpv input binding "${value}"`,
},
];
});
}
+33
View File
@@ -0,0 +1,33 @@
export interface SubtitleSelectionState {
mediaPath: string;
tracks: { id: number; label: string }[];
primary: number | null;
secondary: number | null;
}
export type SubtitleSelectionRequest = Pick<
SubtitleSelectionState,
'mediaPath' | 'primary' | 'secondary'
>;
export function parseSubtitleSelectionRequest(value: unknown): SubtitleSelectionRequest {
if (
typeof value !== 'object' ||
value === null ||
!('mediaPath' in value) ||
typeof value.mediaPath !== 'string' ||
!value.mediaPath ||
!('primary' in value) ||
!isTrackSelection(value.primary) ||
!('secondary' in value) ||
!isTrackSelection(value.secondary)
)
throw new Error('Invalid subtitle selection.');
if (value.primary !== null && value.primary === value.secondary)
throw new Error('Choose different primary and secondary tracks.');
return { mediaPath: value.mediaPath, primary: value.primary, secondary: value.secondary };
}
function isTrackSelection(value: unknown): value is number | null {
return value === null || (typeof value === 'number' && Number.isSafeInteger(value) && value > 0);
}
+3
View File
@@ -126,6 +126,7 @@ export interface ShortcutsConfig {
openRuntimeOptions?: string | null;
openJimaku?: string | null;
openTsukihime?: string | null;
openSubtitleSelection?: string | null;
openSubtitleGeneration?: string | null;
openSessionHelp?: string | null;
openControllerSelect?: string | null;
@@ -156,6 +157,7 @@ export interface Config {
shortcuts?: RawShortcutsConfig;
secondarySub?: SecondarySubConfig;
subsync?: SubsyncConfig;
subtitleSelection?: { enabled?: boolean };
subtitleGeneration?: Partial<SubtitleGenerationConfig>;
startupWarmups?: StartupWarmupsConfig;
subtitleStyle?: SubtitleStyleConfig;
@@ -310,6 +312,7 @@ export interface ResolvedConfig {
shortcuts: Required<ShortcutsConfig>;
secondarySub: Required<SecondarySubConfig>;
subsync: Required<SubsyncConfig>;
subtitleSelection: { enabled: boolean };
subtitleGeneration: SubtitleGenerationConfig;
startupWarmups: {
lowPowerMode: boolean;
+10
View File
@@ -525,6 +525,13 @@ export interface ElectronAPI {
focusMainWindow: () => Promise<void>;
activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>;
getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>;
onSubtitleSelectionOpen: (callback: () => void) => void;
getSubtitleSelection: () => Promise<
import('../shared/subtitle-selection').SubtitleSelectionState
>;
applySubtitleSelection: (
request: import('../shared/subtitle-selection').SubtitleSelectionRequest,
) => Promise<void>;
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
@@ -604,6 +611,7 @@ export interface ElectronAPI {
modal:
| 'runtime-options'
| 'subsync'
| 'subtitle-selection'
| 'subtitle-generation'
| 'jimaku'
| 'tsukihime'
@@ -622,6 +630,7 @@ export interface ElectronAPI {
modal:
| 'runtime-options'
| 'subsync'
| 'subtitle-selection'
| 'subtitle-generation'
| 'jimaku'
| 'tsukihime'
@@ -637,6 +646,7 @@ export interface ElectronAPI {
| 'changelog',
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
}
+1
View File
@@ -23,6 +23,7 @@ export type SessionActionId =
| 'openControllerDebug'
| 'openJimaku'
| 'openTsukihime'
| 'openSubtitleSelection'
| 'openSubtitleGeneration'
| 'openYoutubePicker'
| 'openPlaylistBrowser'