fix(jellyfin): fix discovery loop, device identity, tray state, and Disc

- Derive device identity from OS hostname; remove legacy configurable client/device fields
- Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores
- Restart stale tray discovery sessions without re-login when server drops SubMiner cast target
- Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes
- Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads
- Fix picker library discovery when log level is above info
- Fix config.example.jsonc trailing commas and array formatting
This commit is contained in:
2026-05-22 01:36:11 -07:00
parent 1a7f015f4e
commit 536d99251e
72 changed files with 2063 additions and 589 deletions
+10 -3
View File
@@ -74,7 +74,10 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal('clientName' in config.jellyfin, false);
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
assert.equal('deviceId' in config.jellyfin, false);
assert.equal('clientVersion' in config.jellyfin, false);
assert.equal(config.ai.enabled, false);
assert.equal(config.ai.apiKeyCommand, '');
assert.equal(config.texthooker.openBrowser, false);
@@ -825,7 +828,7 @@ test('parses anilist.characterDictionary.collapsibleSections booleans and warns
);
});
test('parses jellyfin remote control fields', () => {
test('parses jellyfin remote control fields and ignores legacy identity fields', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
@@ -836,6 +839,7 @@ test('parses jellyfin remote control fields', () => {
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
"autoAnnounce": true,
"clientName": "Custom Client",
"remoteControlDeviceName": "SubMiner"
}
}`,
@@ -850,7 +854,8 @@ test('parses jellyfin remote control fields', () => {
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, true);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal('clientName' in config.jellyfin, false);
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
});
test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
@@ -2462,6 +2467,8 @@ test('template generator includes known keys', () => {
assert.match(output, /"startupWarmups":/);
assert.match(output, /"updates":/);
assert.match(output, /"youtube":/);
assert.doesNotMatch(output, /"deviceId":/);
assert.doesNotMatch(output, /"clientVersion":/);
assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/);
@@ -126,14 +126,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
serverUrl: '',
recentServers: [],
username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
clientVersion: '0.1.0',
defaultLibraryId: '',
remoteControlEnabled: true,
remoteControlAutoConnect: true,
autoAnnounce: false,
remoteControlDeviceName: 'SubMiner',
pullPictures: false,
iconCacheDir: '/tmp/subminer-jellyfin-icons',
directPlayPreferred: true,
@@ -520,26 +520,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.username,
description: 'Default Jellyfin username used during CLI login.',
},
{
path: 'jellyfin.deviceId',
kind: 'string',
defaultValue: defaultConfig.jellyfin.deviceId,
description:
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientName,
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientVersion',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientVersion,
description:
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.defaultLibraryId',
kind: 'string',
@@ -565,12 +545,6 @@ export function buildIntegrationConfigOptionRegistry(
description:
'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
},
{
path: 'jellyfin.remoteControlDeviceName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
description: 'Device name reported for Jellyfin remote control sessions.',
},
{
path: 'jellyfin.pullPictures',
kind: 'boolean',
-3
View File
@@ -364,9 +364,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
const stringKeys = [
'serverUrl',
'username',
'deviceId',
'clientName',
'clientVersion',
'defaultLibraryId',
'iconCacheDir',
'transcodeVideoCodec',
-4
View File
@@ -57,7 +57,6 @@ test('settings registry hides removed modal-only fields', () => {
'shortcuts.multiCopyTimeoutMs',
'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
]) {
assert.equal(
fields.some((candidate) => candidate.configPath === path),
@@ -244,10 +243,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.whisperBin',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.clientName',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
]) {
-4
View File
@@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'anilist.characterDictionary.profileScope',
'jellyfin.accessToken',
'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey',
@@ -91,6 +91,22 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
}
});
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
mediaTitle: null,
mediaPath:
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
});
assert.equal(payload.details, 'Unknown media');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
const serialized = JSON.stringify(payload);
assert.equal(serialized.includes('api_key'), false);
assert.equal(serialized.includes('secret-token'), false);
assert.equal(serialized.includes('/Videos/item-1/stream'), false);
});
test('service deduplicates identical updates and sends changed timeline', async () => {
const sent: DiscordActivityPayload[] = [];
const timers = new Map<number, () => void>();
+13 -1
View File
@@ -106,6 +106,15 @@ function basename(filePath: string | null): string {
return parts[parts.length - 1] ?? '';
}
function fallbackTitleFromMediaPath(mediaPath: string | null): string {
const trimmed = mediaPath?.trim();
if (!trimmed) return '';
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.toLowerCase().startsWith('file://')) {
return '';
}
return basename(trimmed).split(/[?#]/)[0] ?? '';
}
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
if (snapshot.paused) return 'Paused';
@@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity(
): DiscordActivityPayload {
const style = resolvePresenceStyle(config.presenceStyle);
const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
const title = sanitizeText(
snapshot.mediaTitle,
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
);
const details =
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
+38 -46
View File
@@ -399,6 +399,11 @@ import {
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
} from './main/runtime/jellyfin-device-identity';
import {
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
@@ -502,6 +507,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import {
createElectronAppUpdater,
@@ -608,6 +614,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
appDataDir: process.env.APPDATA,
});
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => os.tmpdir(),
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
removeDir: (dir, options) => {
fs.rmSync(dir, options);
},
fetch: (url) => fetch(url),
});
const ANILIST_SETUP_RESPONSE_TYPE = 'token';
const ANILIST_DEFAULT_CLIENT_ID = '36084';
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
@@ -2808,7 +2823,9 @@ const {
},
getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin,
getHostName: () => os.hostname(),
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
},
waitForMpvConnectedMainDeps: {
getMpvClient: () => appState.mpvClient,
@@ -2864,41 +2881,8 @@ const {
sendMpvCommandRuntime(appState.mpvClient, command);
},
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
cacheSubtitleTrack: async (track) => {
if (!track.deliveryUrl) {
throw new Error('Jellyfin subtitle track has no delivery URL');
}
const cacheDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'subminer-jellyfin-subtitles-'),
);
const urlPath = (() => {
try {
return new URL(track.deliveryUrl).pathname;
} catch {
return track.deliveryUrl;
}
})();
const ext = path.extname(urlPath).slice(0, 16) || '.srt';
const subtitlePath = path.join(cacheDir, `track-${track.index}${ext}`);
try {
const response = await fetch(track.deliveryUrl);
if (!response.ok) {
throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
await fs.promises.writeFile(subtitlePath, bytes);
} catch (error) {
fs.rmSync(cacheDir, { recursive: true, force: true });
throw error;
}
return { path: subtitlePath, cleanupDir: cacheDir };
},
cleanupCachedSubtitles: (dirs) => {
for (const dir of dirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
},
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
logDebug: (message, error) => {
logger.debug(message, error);
},
@@ -2941,6 +2925,9 @@ const {
showMpvOsd: (text) => {
showMpvOsd(text);
},
updateCurrentMediaTitle: (title) => {
mediaRuntime.updateCurrentMediaTitle(title);
},
recordJellyfinPlaybackMetadata: (metadata) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
@@ -3005,11 +2992,13 @@ const {
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
},
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId,
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName,
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion,
getHostName: () => os.hostname(),
defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()),
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
logInfo: (message) => logger.info(message),
logWarn: (message, details) => logger.warn(message, details),
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
},
stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => appState.jellyfinRemoteSession,
@@ -3019,6 +3008,7 @@ const {
clearActivePlayback: () => {
activeJellyfinRemotePlayback = null;
},
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
},
runJellyfinCommandMainDeps: {
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
@@ -3039,7 +3029,6 @@ const {
clearStoredSession: () =>
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
patchJellyfinConfig: (session) => {
const clientInfo = getJellyfinClientInfo();
const recentServers = mergeJellyfinRecentServers(
session.serverUrl,
getResolvedConfig().jellyfin.recentServers || [],
@@ -3049,9 +3038,6 @@ const {
enabled: true,
serverUrl: session.serverUrl,
username: session.username,
deviceId: clientInfo.deviceId,
clientName: clientInfo.clientName,
clientVersion: clientInfo.clientVersion,
recentServers,
},
});
@@ -4373,8 +4359,8 @@ const {
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
}
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
currentMediaTokenizationGate.updateCurrentMediaPath(path);
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
startupOsdSequencer.reset();
@@ -6061,6 +6047,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
},
buildTrayMenuTemplateDeps: {
buildTrayMenuTemplateRuntime,
platform: process.platform,
initializeOverlayRuntime: () => initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
openSessionHelpModal: () => openSessionHelpOverlay(),
@@ -6076,8 +6063,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
isJellyfinConfigured: () =>
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
toggleJellyfinDiscovery: () =>
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
toggleJellyfinDiscovery: (checked: boolean) =>
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), {
desiredActive: checked,
}),
openAnilistSetupWindow: () => openAnilistSetupWindow(),
checkForUpdates: () => {
void getUpdateService().checkForUpdates({ source: 'manual' });
@@ -6309,6 +6298,7 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
}
if (visible) {
@@ -6320,6 +6310,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions();
autoplayReadyGate.markCurrentMediaAutoplayReady();
if (overlayManager.getVisibleOverlayVisible()) {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} else {
@@ -6330,6 +6321,7 @@ function toggleVisibleOverlay(): void {
}
function setOverlayVisible(visible: boolean): void {
if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
}
if (visible) {
+27
View File
@@ -43,6 +43,33 @@ test('media path changes clear rendered subtitle state', () => {
);
});
test('same media path updates do not reset autoplay ready fallback state', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/,
);
});
test('manual visible overlay toggles suppress current-media autoplay release', () => {
const source = readMainSource();
const actionBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);/);
assert.ok(
actionBlock.indexOf('autoplayReadyGate.markCurrentMediaAutoplayReady();') <
actionBlock.indexOf('toggleVisibleOverlayHandler();'),
);
});
test('main process uses one shared mpv plugin runtime config helper', () => {
const source = readMainSource();
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
@@ -54,6 +54,49 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
assert.throws(() => cleanup(), /stop failed/);
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
});
test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false;
let windowCount = 1;
+5 -2
View File
@@ -60,8 +60,11 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow();
deps.stopJellyfinRemoteSession();
deps.cleanupJellyfinSubtitleCache();
try {
deps.stopJellyfinRemoteSession();
} finally {
deps.cleanupJellyfinSubtitleCache();
}
deps.stopDiscordPresenceService();
};
}
+80 -1
View File
@@ -46,7 +46,6 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
);
assert.equal(scheduled.length > 0, true);
});
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
@@ -144,6 +143,86 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
);
});
test('autoplay ready gate cancels release retries after playback is paused again', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
let playbackPaused = true;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => playbackPaused,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => playbackPaused,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
playbackPaused = false;
}
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
const retry = scheduled.shift();
retry?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);
});
test('autoplay ready gate suppresses release after manual current-media dismissal', async () => {
const commands: Array<Array<string | boolean>> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.markCurrentMediaAutoplayReady();
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
});
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReady = false;
+16
View File
@@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const getSignalMediaPath = (): string =>
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const markCurrentMediaAutoplayReady = (): void => {
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = getSignalMediaPath();
autoPlayReadySignalGeneration += 1;
};
const maybeSignalPluginAutoplayReady = (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
@@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
forceWhilePaused: options?.forceWhilePaused === true,
retryDelayMs: releaseRetryDelayMs,
});
let releaseUnpauseSent = false;
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
try {
@@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return;
}
if (releaseUnpauseSent && deps.getPlaybackPaused() === true) {
deps.logDebug(
`[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`,
);
return;
}
const shouldUnpause = await isPlaybackPaused(mpvClient);
if (!shouldUnpause) {
return;
}
mpvClient.send({ command: ['set_property', 'pause', false] });
releaseUnpauseSent = true;
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
}
@@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
flushPendingAutoplayReadySignal,
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks,
markCurrentMediaAutoplayReady,
maybeSignalPluginAutoplayReady,
};
}
@@ -101,6 +101,7 @@ export function composeJellyfinRemoteHandlers(
getConfiguredSession: options.getConfiguredSession,
getClientInfo: options.getClientInfo,
getJellyfinConfig: options.getJellyfinConfig,
getActivePlayback: options.getActivePlayback,
playJellyfinItem: options.playJellyfinItem,
logWarn: options.logWarn,
});
@@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
},
getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => ({}) as never,
getDefaultJellyfinConfig: () => ({
clientName: 'SubMiner',
clientVersion: 'test',
deviceId: 'dev',
}),
getHostName: () => 'workstation',
defaultClientName: 'SubMiner',
defaultClientVersion: 'test',
},
waitForMpvConnectedMainDeps: {
getMpvClient: () => null,
@@ -140,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
defaultDeviceId: 'dev',
defaultClientName: 'SubMiner',
defaultClientVersion: 'test',
getHostName: () => 'workstation',
logInfo: () => {},
logWarn: () => {},
},
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
>;
startJellyfinRemoteSessionMainDeps: Omit<
StartRemoteSessionMainDeps,
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
| 'getJellyfinConfig'
| 'getClientInfo'
| 'handlePlay'
| 'handlePlaystate'
| 'handleGeneralCommand'
>;
stopJellyfinRemoteSessionMainDeps: Parameters<
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
@@ -236,6 +240,7 @@ export function composeJellyfinRuntimeHandlers(
createBuildStartJellyfinRemoteSessionMainDepsHandler({
...options.startJellyfinRemoteSessionMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(),
getClientInfo: () => getJellyfinClientInfo(),
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
+1
View File
@@ -7,6 +7,7 @@ export * from '../jellyfin-client-info';
export * from '../jellyfin-client-info-main-deps';
export * from '../jellyfin-command-dispatch';
export * from '../jellyfin-command-dispatch-main-deps';
export * from '../jellyfin-device-identity';
export * from '../jellyfin-playback-launch';
export * from '../jellyfin-playback-launch-main-deps';
export * from '../jellyfin-remote-commands';
+33 -7
View File
@@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => {
enabled: true,
serverUrl: 'http://localhost',
username: 'user',
deviceId: 'd1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: ['http://localhost'],
},
});
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
});
test('persistJellyfinAuthSession stores client metadata and recent servers', () => {
test('persistJellyfinAuthSession stores session config and recent servers', () => {
let patchPayload: unknown = null;
let storedSession: unknown = null;
@@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
enabled: true,
serverUrl: 'http://localhost:8096',
username: 'alice',
deviceId: 'device-1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: [
'http://localhost:8096',
'http://old.example:8096',
@@ -146,6 +140,38 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
});
});
test('persistJellyfinAuthSession does not write generated local device id to config', () => {
let patchPayload: unknown = null;
persistJellyfinAuthSession({
session: {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
clientInfo: {
deviceId: 'subminer-local-pc',
clientName: 'SubMiner',
clientVersion: '1.0',
},
existingRecentServers: [],
saveStoredSession: () => {},
patchRawConfig: (patch) => {
patchPayload = patch;
},
});
assert.deepEqual(patchPayload, {
jellyfin: {
enabled: true,
serverUrl: 'http://localhost:8096',
username: 'alice',
recentServers: ['http://localhost:8096'],
},
});
});
test('jellyfin auth handler no-ops when no auth command', async () => {
const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => {},
-9
View File
@@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: {
enabled: boolean;
serverUrl: string;
username: string;
deviceId: string;
clientName: string;
clientVersion: string;
recentServers: string[];
}>;
}) => void;
@@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: {
enabled: true,
serverUrl: deps.session.serverUrl,
username: deps.session.username,
deviceId: deps.clientInfo.deviceId,
clientName: deps.clientInfo.clientName,
clientVersion: deps.clientInfo.clientVersion,
recentServers: mergeJellyfinRecentServers(
deps.session.serverUrl,
deps.existingRecentServers || [],
@@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: {
enabled: boolean;
serverUrl: string;
username: string;
deviceId: string;
clientName: string;
clientVersion: string;
}>;
}) => void;
authenticateWithPassword: (
@@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => {
test('get jellyfin client info main deps builder maps callbacks', () => {
const configured = { clientName: 'Configured' };
const defaults = { clientName: 'Default' };
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
getResolvedJellyfinConfig: () => configured as never,
getDefaultJellyfinConfig: () => defaults as never,
getHostName: () => 'workstation',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
})();
assert.equal(deps.getResolvedJellyfinConfig(), configured);
assert.equal(deps.getDefaultJellyfinConfig(), defaults);
assert.equal(deps.getHostName?.(), 'workstation');
assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0.0');
});
@@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler(
) {
return (): GetJellyfinClientInfoMainDeps => ({
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(),
getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined,
defaultClientName: deps.defaultClientName,
defaultClientVersion: deps.defaultClientVersion,
});
}
+32 -18
View File
@@ -80,23 +80,20 @@ test('get resolved jellyfin config uses stored user id when env token set withou
test('jellyfin client info resolves defaults when fields are missing', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never,
getDefaultJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
getResolvedJellyfinConfig: () => ({ clientName: '' }) as never,
getHostName: () => 'workstation',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
});
assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'default-device',
deviceId: 'workstation',
});
});
test('jellyfin client info keeps explicit config values', () => {
test('jellyfin client info ignores legacy configured client name, device id, and version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () =>
({
@@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => {
clientVersion: '2.3.4',
deviceId: 'custom-device',
}) as never,
getDefaultJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
getHostName: () => 'Kyle-PC',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
});
assert.deepEqual(getClientInfo(), {
clientName: 'Custom',
clientVersion: '2.3.4',
deviceId: 'custom-device',
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'Kyle-PC',
});
});
test('jellyfin client info ignores legacy configured device id and client version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '9.9.9',
deviceId: 'custom-device',
}) as never,
getHostName: () => 'media-box',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
});
assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'media-box',
});
});
+13 -11
View File
@@ -1,5 +1,10 @@
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
import type { ResolvedConfig } from '../../types';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
} from './jellyfin-device-identity';
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
@@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
}
export function createGetJellyfinClientInfoHandler(deps: {
getResolvedJellyfinConfig: () => Partial<
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
>;
getDefaultJellyfinConfig: () => Partial<
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
>;
getResolvedJellyfinConfig: () => unknown;
getHostName?: () => string;
defaultClientName?: string;
defaultClientVersion?: string;
}) {
return (
config = deps.getResolvedJellyfinConfig(),
_config = deps.getResolvedJellyfinConfig(),
): {
clientName: string;
clientVersion: string;
deviceId: string;
} => {
const defaults = deps.getDefaultJellyfinConfig();
return {
clientName: config.clientName || defaults.clientName || '',
clientVersion: config.clientVersion || defaults.clientVersion || '',
deviceId: config.deviceId || defaults.deviceId || '',
clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME,
clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION,
deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''),
};
};
}
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createHostDerivedJellyfinDeviceId,
resolveJellyfinRemoteDeviceName,
} from './jellyfin-device-identity';
test('createHostDerivedJellyfinDeviceId uses the hostname as the stable id', () => {
assert.equal(createHostDerivedJellyfinDeviceId('Kyle-PC.local'), 'Kyle-PC.local');
assert.equal(createHostDerivedJellyfinDeviceId(''), 'device');
});
test('resolveJellyfinRemoteDeviceName uses hostname by default', () => {
assert.equal(
resolveJellyfinRemoteDeviceName({
hostName: 'kyle-pc',
}),
'kyle-pc',
);
});
test('resolveJellyfinRemoteDeviceName falls back when hostname is empty', () => {
assert.equal(resolveJellyfinRemoteDeviceName({ hostName: '' }), 'device');
});
@@ -0,0 +1,18 @@
export const DEFAULT_JELLYFIN_CLIENT_VERSION = '0.1.0';
export const DEFAULT_JELLYFIN_CLIENT_NAME = 'SubMiner';
export function normalizeJellyfinHostName(value: string): string {
return value.trim();
}
export function createHostDerivedJellyfinDeviceId(hostName: string): string {
return normalizeJellyfinHostName(hostName) || 'device';
}
export function resolveJellyfinDeviceId(params: { hostName: string }): string {
return createHostDerivedJellyfinDeviceId(params.hostName);
}
export function resolveJellyfinRemoteDeviceName(params: { hostName: string }): string {
return normalizeJellyfinHostName(params.hostName) || 'device';
}
@@ -23,5 +23,8 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
: undefined,
updateCurrentMediaTitle: deps.updateCurrentMediaTitle
? (title) => deps.updateCurrentMediaTitle!(title)
: undefined,
});
}
@@ -100,10 +100,11 @@ test('playback handler drives mpv commands and playback state', async () => {
['set_property', 'sid', 'no'],
['seek', 1.2, 'absolute+exact'],
]);
assert.equal(scheduled.length, 1);
assert.equal(scheduled[0]?.delay, 500);
scheduled[0]?.callback();
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']);
assert.equal(scheduled.length, 0);
assert.equal(
commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length,
1,
);
assert.ok(calls.includes('defaults'));
assert.ok(calls.includes('visible-overlay'));
@@ -133,6 +134,52 @@ test('playback handler drives mpv commands and playback state', async () => {
]);
});
test('playback handler publishes Jellyfin title before loading tokenized stream url', async () => {
const timeline: string[] = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
mode: 'direct',
title: 'Galaxy Quest S02E07 A New Hope',
itemTitle: 'A New Hope',
seriesTitle: 'Galaxy Quest',
seasonNumber: 2,
episodeNumber: 7,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}:${String(command[1] ?? '')}`),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
updateCurrentMediaTitle: (title) => timeline.push(`title:${title}`),
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'ep-1',
});
const titleIndex = timeline.indexOf('title:Galaxy Quest S02E07 A New Hope');
const loadIndex = timeline.findIndex((entry) => entry.startsWith('cmd:loadfile:'));
assert.ok(titleIndex >= 0);
assert.ok(loadIndex >= 0);
assert.ok(titleIndex < loadIndex);
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
});
test('playback handler applies start override to stream url for remote resume', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
@@ -177,3 +224,46 @@ test('playback handler applies start override to stream url for remote resume',
assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']);
});
test('playback handler does not let stats metadata failures block playback startup', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 3',
itemTitle: 'Episode 3',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
recordJellyfinPlaybackMetadata: () => {
throw new Error('stats db unavailable');
},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-3',
});
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
});
+15 -12
View File
@@ -75,6 +75,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
}) => void;
showMpvOsd: (text: string) => void;
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
updateCurrentMediaTitle?: (title: string) => void;
}) {
return async (params: {
session: JellyfinAuthSession;
@@ -106,24 +107,26 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
deps.recordJellyfinPlaybackMetadata?.({
mediaPath: playbackUrl,
displayTitle: plan.title,
itemTitle: plan.itemTitle,
seriesTitle: plan.seriesTitle,
seasonNumber: plan.seasonNumber,
episodeNumber: plan.episodeNumber,
itemId: params.itemId,
});
deps.updateCurrentMediaTitle?.(plan.title);
try {
deps.recordJellyfinPlaybackMetadata?.({
mediaPath: playbackUrl,
displayTitle: plan.title,
itemTitle: plan.itemTitle,
seriesTitle: plan.seriesTitle,
seasonNumber: plan.seasonNumber,
episodeNumber: plan.episodeNumber,
itemId: params.itemId,
});
} catch {
// Best-effort stats metadata must not block playback startup.
}
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.schedule(() => {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
}, 500);
const startTimeTicks =
typeof params.startTimeTicksOverride === 'number'
@@ -101,6 +101,32 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']);
});
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
let playCalls = 0;
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({}),
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
playJellyfinItem: async () => {
playCalls += 1;
},
logWarn: () => {},
});
await handlePlay({ ItemIds: ['item-1'] });
assert.equal(playCalls, 0);
});
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
const mpvClient = {};
const commands: Array<(string | number)[]> = [];
@@ -51,6 +51,7 @@ export type JellyfinRemotePlayHandlerDeps = {
getConfiguredSession: () => JellyfinSession | null;
getClientInfo: () => JellyfinClientInfo;
getJellyfinConfig: () => unknown;
getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null;
playJellyfinItem: (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
@@ -79,6 +80,9 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
return;
}
if (deps.getActivePlayback?.()?.itemId === itemId) {
return;
}
await deps.playJellyfinItem({
session,
clientInfo,
@@ -15,6 +15,9 @@ export function createBuildHandleJellyfinRemotePlayMainDepsHandler(
getConfiguredSession: () => deps.getConfiguredSession(),
getClientInfo: () => deps.getClientInfo(),
getJellyfinConfig: () => deps.getJellyfinConfig(),
...(deps.getActivePlayback
? { getActivePlayback: () => deps.getActivePlayback?.() ?? null }
: {}),
playJellyfinItem: (params) => deps.playJellyfinItem(params),
logWarn: (message: string) => deps.logWarn(message),
});
@@ -61,6 +61,38 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
assert.equal(lastProgressAtMs, 5000);
});
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
const reportPayloads: Array<{ isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({ isPaused: payload.isPaused });
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
}),
getNow: () => 5000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(true);
assert.deepEqual(reportPayloads, [{ isPaused: true }]);
});
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
let called = false;
const reportProgress = createReportJellyfinRemoteProgressHandler({
+14 -1
View File
@@ -31,6 +31,19 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number):
return Math.max(0, Math.floor(seconds * ticksPerSecond));
}
function isMpvPauseEnabled(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}
return true;
}
return false;
}
export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
@@ -64,7 +77,7 @@ export function createReportJellyfinRemoteProgressHandler(
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
isPaused: paused === true,
isPaused: isMpvPauseEnabled(paused),
playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex,
@@ -13,10 +13,6 @@ function createConfig(overrides?: Partial<Record<string, unknown>>) {
serverUrl: 'http://localhost',
accessToken: 'token',
userId: 'user-id',
deviceId: '',
clientName: '',
clientVersion: '',
remoteControlDeviceName: '',
autoAnnounce: false,
...(overrides || {}),
} as never;
@@ -39,6 +35,12 @@ test('start handler no-ops when jellyfin integration is disabled', async () => {
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
@@ -67,6 +69,12 @@ test('start handler no-ops when remote control is disabled', async () => {
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
@@ -95,6 +103,12 @@ test('start handler respects auto-connect unless explicit start is requested', a
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
@@ -117,6 +131,7 @@ test('start handler creates, starts, and stores session', async () => {
} | null = null;
let started = false;
const infos: string[] = [];
let stateChanges = 0;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig({ clientName: 'Desk' }),
getCurrentSession: () => null,
@@ -124,7 +139,7 @@ test('start handler creates, starts, and stores session', async () => {
storedSession = session as never;
},
createRemoteSessionService: (options) => {
assert.equal(options.deviceName, 'Desk');
assert.equal(options.deviceName, 'workstation');
return {
start: () => {
started = true;
@@ -136,18 +151,119 @@ test('start handler creates, starts, and stores session', async () => {
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: (message) => infos.push(message),
logWarn: () => {},
onSessionStateChanged: () => {
stateChanges += 1;
},
});
await startRemote();
assert.equal(started, true);
assert.ok(storedSession);
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).')));
assert.equal(stateChanges, 1);
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (workstation).')));
});
test('start handler uses hostname-derived client info and visible device name', async () => {
let createdOptions: {
deviceId: string;
clientName: string;
clientVersion: string;
deviceName: string;
} | null = null;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () =>
createConfig({
clientName: 'SubMiner',
}),
getClientInfo: () => ({
deviceId: 'kyle-pc',
clientName: 'SubMiner',
clientVersion: '0.1.0',
}),
getHostName: () => 'kyle-pc',
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: (options) => {
createdOptions = {
deviceId: options.deviceId,
clientName: options.clientName,
clientVersion: options.clientVersion,
deviceName: options.deviceName,
};
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'subminer',
defaultClientName: 'SubMiner',
defaultClientVersion: '0.1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote({ explicit: true });
assert.deepEqual(createdOptions, {
deviceId: 'kyle-pc',
clientName: 'SubMiner',
clientVersion: '0.1.0',
deviceName: 'kyle-pc',
});
});
test('start handler ignores configured visible device name', async () => {
let createdDeviceName = '';
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () =>
createConfig({
remoteControlDeviceName: 'SubMiner Cachy sudacode',
}),
getClientInfo: () => ({
deviceId: 'cachy',
clientName: 'SubMiner',
clientVersion: '0.1.0',
}),
getHostName: () => 'cachy',
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: (options) => {
createdDeviceName = options.deviceName;
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'subminer',
defaultClientName: 'SubMiner',
defaultClientVersion: '0.1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote({ explicit: true });
assert.equal(createdDeviceName, 'cachy');
});
test('start handler stops previous session before replacing', async () => {
@@ -175,6 +291,12 @@ test('start handler stops previous session before replacing', async () => {
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
@@ -189,6 +311,7 @@ test('start handler stops previous session before replacing', async () => {
test('stop handler stops active session and clears playback', () => {
let stopCalls = 0;
let clearCalls = 0;
let stateChanges = 0;
let currentSession: { stop: () => void } | null = {
stop: () => {
stopCalls += 1;
@@ -203,10 +326,14 @@ test('stop handler stops active session and clears playback', () => {
clearActivePlayback: () => {
clearCalls += 1;
},
onSessionStateChanged: () => {
stateChanges += 1;
},
});
stopRemote();
assert.equal(stopCalls, 1);
assert.equal(clearCalls, 1);
assert.equal(currentSession, null);
assert.equal(stateChanges, 1);
});
@@ -1,3 +1,5 @@
import { resolveJellyfinRemoteDeviceName } from './jellyfin-device-identity';
type JellyfinRemoteConfig = {
enabled: boolean;
remoteControlEnabled: boolean;
@@ -5,11 +7,13 @@ type JellyfinRemoteConfig = {
serverUrl: string;
accessToken?: string;
userId?: string;
autoAnnounce: boolean;
};
type JellyfinClientInfo = {
deviceId: string;
clientName: string;
clientVersion: string;
remoteControlDeviceName: string;
autoAnnounce: boolean;
};
type JellyfinRemoteService = {
@@ -44,6 +48,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
getCurrentSession: () => JellyfinRemoteService | null;
setCurrentSession: (session: JellyfinRemoteService | null) => void;
createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService;
getClientInfo: () => JellyfinClientInfo;
getHostName: () => string;
defaultDeviceId: string;
defaultClientName: string;
defaultClientVersion: string;
@@ -52,6 +58,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>;
logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void;
onSessionStateChanged?: () => void;
}) {
return async (options?: { explicit?: boolean }): Promise<void> => {
const jellyfinConfig = deps.getJellyfinConfig();
@@ -60,6 +67,13 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return;
const clientInfo = deps.getClientInfo();
const clientName = clientInfo.clientName || deps.defaultClientName;
const clientVersion = clientInfo.clientVersion || deps.defaultClientVersion;
const deviceName = resolveJellyfinRemoteDeviceName({
hostName: deps.getHostName(),
});
const existing = deps.getCurrentSession();
if (existing) {
existing.stop();
@@ -69,13 +83,10 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
const service = deps.createRemoteSessionService({
serverUrl: jellyfinConfig.serverUrl,
accessToken: jellyfinConfig.accessToken,
deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId,
clientName: jellyfinConfig.clientName || deps.defaultClientName,
clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion,
deviceName:
jellyfinConfig.remoteControlDeviceName ||
jellyfinConfig.clientName ||
deps.defaultClientName,
deviceId: clientInfo.deviceId || deps.defaultDeviceId,
clientName,
clientVersion,
deviceName,
capabilities: {
PlayableMediaTypes: 'Video,Audio',
SupportedCommands:
@@ -118,9 +129,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
service.start();
deps.setCurrentSession(service);
deps.logInfo(
`Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`,
);
deps.onSessionStateChanged?.();
deps.logInfo(`Jellyfin remote session enabled (${deviceName}).`);
};
}
@@ -128,6 +138,7 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
getCurrentSession: () => JellyfinRemoteService | null;
setCurrentSession: (session: JellyfinRemoteService | null) => void;
clearActivePlayback: () => void;
onSessionStateChanged?: () => void;
}) {
return (): void => {
const session = deps.getCurrentSession();
@@ -135,5 +146,6 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
session.stop();
deps.setCurrentSession(null);
deps.clearActivePlayback();
deps.onSessionStateChanged?.();
};
}
@@ -13,6 +13,13 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
getCurrentSession: () => null,
setCurrentSession: () => calls.push('set-session'),
createRemoteSessionService: () => session as never,
getClientInfo: () =>
({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}) as never,
getHostName: () => 'workstation',
defaultDeviceId: 'device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
@@ -27,19 +34,34 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
},
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
onSessionStateChanged: () => calls.push('state-changed'),
})();
assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' });
assert.equal(deps.defaultDeviceId, 'device');
assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0');
assert.equal(deps.getHostName(), 'workstation');
assert.deepEqual(deps.getClientInfo(), {
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
});
assert.equal(deps.createRemoteSessionService({} as never), session);
await deps.handlePlay({});
await deps.handlePlaystate({});
await deps.handleGeneralCommand({});
deps.logInfo('connected');
deps.logWarn('missing');
assert.deepEqual(calls, ['play', 'playstate', 'general', 'info:connected', 'warn:missing']);
deps.onSessionStateChanged?.();
assert.deepEqual(calls, [
'play',
'playstate',
'general',
'info:connected',
'warn:missing',
'state-changed',
]);
});
test('stop jellyfin remote session main deps builder maps callbacks', () => {
@@ -49,10 +71,12 @@ test('stop jellyfin remote session main deps builder maps callbacks', () => {
getCurrentSession: () => session as never,
setCurrentSession: () => calls.push('set-null'),
clearActivePlayback: () => calls.push('clear'),
onSessionStateChanged: () => calls.push('state-changed'),
})();
assert.equal(deps.getCurrentSession(), session);
deps.setCurrentSession(null);
deps.clearActivePlayback();
assert.deepEqual(calls, ['set-null', 'clear']);
deps.onSessionStateChanged?.();
assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']);
});
@@ -18,6 +18,8 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
getCurrentSession: () => deps.getCurrentSession(),
setCurrentSession: (session) => deps.setCurrentSession(session),
createRemoteSessionService: (options) => deps.createRemoteSessionService(options),
getClientInfo: () => deps.getClientInfo(),
getHostName: () => deps.getHostName(),
defaultDeviceId: deps.defaultDeviceId,
defaultClientName: deps.defaultClientName,
defaultClientVersion: deps.defaultClientVersion,
@@ -26,6 +28,7 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload),
logInfo: (message: string) => deps.logInfo(message),
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
onSessionStateChanged: deps.onSessionStateChanged,
});
}
@@ -36,5 +39,6 @@ export function createBuildStopJellyfinRemoteSessionMainDepsHandler(
getCurrentSession: () => deps.getCurrentSession(),
setCurrentSession: (session) => deps.setCurrentSession(session),
clearActivePlayback: () => deps.clearActivePlayback(),
onSessionStateChanged: deps.onSessionStateChanged,
});
}
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createJellyfinSubtitleCacheIo } from './jellyfin-subtitle-cache-io';
test('jellyfin subtitle cache io downloads tracks to temp files and cleans cache dirs', async () => {
const writes: Array<{ filePath: string; bytes: string }> = [];
const removed: Array<{ dir: string; recursive: boolean; force: boolean }> = [];
const cacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => '/tmp',
makeTempDir: async (prefix) => {
assert.equal(prefix, '/tmp/subminer-jellyfin-subtitles-');
return '/tmp/subminer-jellyfin-subtitles-abc';
},
writeFile: async (filePath, bytes) => {
writes.push({ filePath, bytes: new TextDecoder().decode(bytes) });
},
removeDir: (dir, options) => {
removed.push({ dir, ...options });
},
fetch: async () => ({
ok: true,
status: 200,
arrayBuffer: async () => new TextEncoder().encode('subtitle body').buffer as ArrayBuffer,
}),
});
const cached = await cacheIo.cacheSubtitleTrack({
index: 7,
deliveryUrl: 'https://example.test/Items/1/Subtitles/7/Stream.ass?api_key=secret',
});
cacheIo.cleanupCachedSubtitles([cached.cleanupDir]);
assert.deepEqual(cached, {
path: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass',
cleanupDir: '/tmp/subminer-jellyfin-subtitles-abc',
});
assert.deepEqual(writes, [
{
filePath: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass',
bytes: 'subtitle body',
},
]);
assert.deepEqual(removed, [
{ dir: '/tmp/subminer-jellyfin-subtitles-abc', recursive: true, force: true },
]);
});
test('jellyfin subtitle cache io removes temp dir when download fails', async () => {
const removed: string[] = [];
const cacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => '/tmp',
makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed',
writeFile: async () => {},
removeDir: (dir) => {
removed.push(dir);
},
fetch: async () => ({
ok: false,
status: 500,
arrayBuffer: async () => new ArrayBuffer(0),
}),
});
await assert.rejects(
() => cacheIo.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }),
/HTTP 500/,
);
assert.deepEqual(removed, ['/tmp/subminer-jellyfin-subtitles-failed']);
});
@@ -0,0 +1,73 @@
import * as path from 'path';
type JellyfinSubtitleCacheTrack = {
index: number;
deliveryUrl?: string | null;
};
type JellyfinSubtitleCacheEntry = {
path: string;
cleanupDir: string;
};
type FetchResponseLike = {
ok: boolean;
status: number;
arrayBuffer: () => Promise<ArrayBuffer>;
};
type JellyfinSubtitleCacheIoDeps = {
tmpDir: () => string;
makeTempDir: (prefix: string) => Promise<string>;
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
removeDir: (dir: string, options: { recursive: true; force: true }) => void;
fetch: (url: string) => Promise<FetchResponseLike>;
};
function getSubtitleExtension(deliveryUrl: string): string {
const urlPath = (() => {
try {
return new URL(deliveryUrl).pathname;
} catch {
return deliveryUrl;
}
})();
return path.extname(urlPath).slice(0, 16) || '.srt';
}
export function createJellyfinSubtitleCacheIo(deps: JellyfinSubtitleCacheIoDeps) {
return {
async cacheSubtitleTrack(
track: JellyfinSubtitleCacheTrack,
): Promise<JellyfinSubtitleCacheEntry> {
if (!track.deliveryUrl) {
throw new Error('Jellyfin subtitle track has no delivery URL');
}
const cacheDir = await deps.makeTempDir(
path.join(deps.tmpDir(), 'subminer-jellyfin-subtitles-'),
);
const subtitlePath = path.join(
cacheDir,
`track-${track.index}${getSubtitleExtension(track.deliveryUrl)}`,
);
try {
const response = await deps.fetch(track.deliveryUrl);
if (!response.ok) {
throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
await deps.writeFile(subtitlePath, bytes);
} catch (error) {
deps.removeDir(cacheDir, { recursive: true, force: true });
throw error;
}
return { path: subtitlePath, cleanupDir: cacheDir };
},
cleanupCachedSubtitles(dirs: string[]): void {
for (const dir of dirs) {
deps.removeDir(dir, { recursive: true, force: true });
}
},
};
}
@@ -61,8 +61,22 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
],
getMpvClient: () => ({
requestProperty: async () => [
{ type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true },
{ type: 'sub', id: 6, lang: 'eng', title: 'English', external: true },
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
@@ -76,13 +90,225 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(commands, [
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'cached', 'Japanese', 'jpn'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'cached', 'English SDH', 'eng'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'],
['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6],
]);
});
test('preload jellyfin subtitles waits for delayed cached japanese track before selecting', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount < 3) {
return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }];
}
return [
{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false },
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 3);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6],
],
);
});
test('preload jellyfin subtitles waits for delayed external japanese track instead of embedded japanese', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount < 3) {
return [{ type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' }];
}
return [
{ type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' },
{
type: 'sub',
id: 42,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 43,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 3);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 42],
['set_property', 'secondary-sid', 43],
],
);
});
test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 10, language: 'deu', title: 'German', deliveryUrl: 'https://sub/deu.ass' },
{ index: 12, language: 'rus', title: 'Russian', deliveryUrl: 'https://sub/rus.ass' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount === 1) {
return [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
}
return [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
{
type: 'sub',
id: 18,
lang: 'deu',
title: 'German',
external: true,
selected: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/10.srt',
},
{
type: 'sub',
id: 20,
lang: 'rus',
title: 'Russian',
external: true,
selected: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/12.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 2);
assert.deepEqual(
commands.filter((command) => command[0] === 'sub-add'),
[
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'Japanese', 'jpn'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/10.srt', 'auto', 'German', 'deu'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/12.srt', 'auto', 'Russian', 'rus'],
],
);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[['set_property', 'sid', 11]],
);
});
test('preload jellyfin subtitles leaves current track alone when reported japanese track never appears', async () => {
const commands: Array<Array<string | number>> = [];
const logs: string[] = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }];
},
}),
sendMpvCommand: (command) => commands.push(command),
logDebug: (message) => logs.push(message),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 10);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no',
),
false,
);
assert.deepEqual(logs, ['Timed out waiting for Jellyfin Japanese subtitle track']);
});
test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => {
const cleanupCalls: string[][] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
@@ -105,6 +331,34 @@ test('preload jellyfin subtitles cleans previous cached subtitles before a new p
assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-0']]);
});
test('preload jellyfin subtitles serializes overlapping preload runs', async () => {
let releaseFirstList!: () => void;
const firstListBlocked = new Promise<void>((resolve) => {
releaseFirstList = resolve;
});
const listCalls: string[] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async (_session, _clientInfo, itemId) => {
listCalls.push(itemId);
if (itemId === 'item-1') {
await firstListBlocked;
}
return [];
},
}),
);
const first = preload({ session, clientInfo, itemId: 'item-1' });
const second = preload({ session, clientInfo, itemId: 'item-2' });
await Promise.resolve();
assert.deepEqual(listCalls, ['item-1']);
releaseFirstList();
await Promise.all([first, second]);
assert.deepEqual(listCalls, ['item-1', 'item-2']);
});
test('preload jellyfin subtitles exposes cleanup for active cached subtitles', async () => {
const cleanupCalls: string[][] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
+173 -29
View File
@@ -23,10 +23,27 @@ type CachedSubtitleTrack = {
cleanupDir: string;
};
type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
source: JellyfinSubtitleTrack;
};
type MpvSubtitleTrack = {
id: number;
lang: string;
title: string;
external: boolean;
externalFilename: string;
};
type MpvClientLike = {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
const TRACK_SELECTION_INITIAL_WAIT_MS = 250;
const TRACK_SELECTION_RETRY_MS = 150;
const TRACK_SELECTION_MAX_ATTEMPTS = 10;
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
@@ -71,17 +88,12 @@ function isLikelyHearingImpaired(title: string): boolean {
}
function pickBestTrackId(
tracks: Array<{
id: number;
lang: string;
title: string;
external: boolean;
}>,
tracks: MpvSubtitleTrack[],
languageMatcher: (value: string) => boolean,
excludeId: number | null = null,
): number | null {
const ranked = tracks
.filter((track) => languageMatcher(track.lang))
.filter((track) => languageMatcher(track.lang) || languageMatcher(track.title))
.filter((track) => track.id !== excludeId)
.map((track) => ({
track,
@@ -94,6 +106,119 @@ function pickBestTrackId(
return ranked[0]?.track.id ?? null;
}
function pickBestCachedTrackId(
tracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
sourceMatcher: (value: string) => boolean,
excludeId: number | null = null,
): number | null {
const cachedByPath = new Map(cachedTracks.map((track) => [track.path, track]));
const ranked = tracks
.map((track) => ({
track,
cached: cachedByPath.get(track.externalFilename),
}))
.filter(({ cached }) =>
cached
? sourceMatcher(cached.source.language || '') || sourceMatcher(cached.source.title || '')
: false,
)
.filter(({ track }) => track.id !== excludeId)
.map(({ track, cached }) => {
const title = cached?.source.title || track.title;
return {
track,
score:
(track.external ? 100 : 0) +
(isLikelyHearingImpaired(title) ? -10 : 10) +
(/\bdefault\b/i.test(title) ? 3 : 0),
};
})
.sort((a, b) => b.score - a.score);
return ranked[0]?.track.id ?? null;
}
function isJapaneseTrack(track: MpvSubtitleTrack): boolean {
return isJapanese(track.lang) || isJapanese(track.title);
}
function hasExternalJapaneseTrack(tracks: MpvSubtitleTrack[]): boolean {
return tracks.some((track) => track.external && isJapaneseTrack(track));
}
function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
return Array.isArray(trackListRaw)
? trackListRaw
.filter(
(track): track is Record<string, unknown> =>
Boolean(track) &&
typeof track === 'object' &&
track.type === 'sub' &&
typeof track.id === 'number',
)
.map((track) => ({
id: track.id as number,
lang: String(track.lang || ''),
title: String(track.title || ''),
external: track.external === true,
externalFilename: String(track['external-filename'] || ''),
}))
: [];
}
function hasExpectedExternalSubtitleTracks(
tracks: MpvSubtitleTrack[],
expectedExternalFilenames: string[],
): boolean {
if (expectedExternalFilenames.length === 0) {
return true;
}
const loadedExternalFilenames = new Set(
tracks
.filter((track) => track.externalFilename)
.map((track) => track.externalFilename),
);
return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath));
}
async function readMpvSubtitleTracks(deps: {
getMpvClient: () => MpvClientLike | null;
}): Promise<MpvSubtitleTrack[] | null> {
const client = deps.getMpvClient();
if (!client || client.connected === false) {
return null;
}
const trackListRaw = await client.requestProperty('track-list');
return parseMpvSubtitleTracks(trackListRaw);
}
async function waitForPreferredSubtitleTracks(
deps: {
getMpvClient: () => MpvClientLike | null;
wait: (ms: number) => Promise<void>;
},
shouldWaitForExternalJapanese: boolean,
expectedExternalFilenames: string[],
): Promise<MpvSubtitleTrack[] | null> {
let subtitleTracks: MpvSubtitleTrack[] = [];
for (let attempt = 1; attempt <= TRACK_SELECTION_MAX_ATTEMPTS; attempt += 1) {
const nextTracks = await readMpvSubtitleTracks(deps);
if (nextTracks !== null) {
subtitleTracks = nextTracks;
if (
(!shouldWaitForExternalJapanese || hasExternalJapaneseTrack(subtitleTracks)) &&
hasExpectedExternalSubtitleTracks(subtitleTracks, expectedExternalFilenames)
) {
return subtitleTracks;
}
}
if (attempt < TRACK_SELECTION_MAX_ATTEMPTS) {
await deps.wait(TRACK_SELECTION_RETRY_MS);
}
}
return subtitleTracks;
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: (
session: JellyfinSession,
@@ -108,6 +233,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
const activeCacheDirs = new Set<string>();
let preloadQueue: Promise<void> = Promise.resolve();
function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs];
@@ -116,7 +242,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
deps.cleanupCachedSubtitles(dirs);
}
const preload = async (params: {
const runPreload = async (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
@@ -136,6 +262,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
await deps.wait(300);
const seenUrls = new Set<string>();
const cachedTracks: CachedExternalSubtitleTrack[] = [];
for (const track of externalTracks) {
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
continue;
@@ -145,36 +272,41 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
const cached = await deps.cacheSubtitleTrack(track);
activeCacheDirs.add(cached.cleanupDir);
deps.sendMpvCommand(['sub-add', cached.path, 'cached', label, track.language || '']);
cachedTracks.push({ ...cached, source: track });
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
}
await deps.wait(250);
const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list');
const subtitleTracks = Array.isArray(trackListRaw)
? trackListRaw
.filter(
(track): track is Record<string, unknown> =>
Boolean(track) &&
typeof track === 'object' &&
track.type === 'sub' &&
typeof track.id === 'number',
)
.map((track) => ({
id: track.id as number,
lang: String(track.lang || ''),
title: String(track.title || ''),
external: track.external === true,
}))
: [];
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
const shouldWaitForExternalJapanese = externalTracks.some(
(track) => isJapanese(track.language || '') || isJapanese(track.title || ''),
);
const subtitleTracks = await waitForPreferredSubtitleTracks(
deps,
shouldWaitForExternalJapanese,
cachedTracks.map((track) => track.path),
);
if (
shouldWaitForExternalJapanese &&
(!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks))
) {
deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
itemId: params.itemId,
});
return;
}
const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese);
const japanesePrimaryId =
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ??
pickBestTrackId(subtitleTracks ?? [], isJapanese);
if (japanesePrimaryId !== null) {
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
} else {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
}
const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId);
const englishSecondaryId =
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isEnglish, japanesePrimaryId) ??
pickBestTrackId(subtitleTracks ?? [], isEnglish, japanesePrimaryId);
if (englishSecondaryId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
}
@@ -183,6 +315,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
}
};
const preload = (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}): Promise<void> => {
preloadQueue = preloadQueue.then(
() => runPreload(params),
() => runPreload(params),
);
return preloadQueue;
};
return Object.assign(preload, {
cleanupCachedSubtitles: cleanupActiveCache,
});
@@ -194,6 +194,124 @@ test('stops active discovery from tray', async () => {
]);
});
test('uses checked tray state to start discovery instead of blind toggling', async () => {
const calls: string[] = [];
let session: { advertiseNow: () => Promise<boolean> } | null = null;
await toggleJellyfinDiscoveryFromTray(
{
getRemoteSession: () => session,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise');
return true;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
},
{ desiredActive: true },
);
assert.deepEqual(calls, [
'start',
'advertise',
'info:Jellyfin discovery started; cast target is visible in server sessions.',
'osd:Jellyfin discovery started',
'refresh',
]);
});
test('uses unchecked tray state to stop discovery without visibility probing', async () => {
const calls: string[] = [];
await toggleJellyfinDiscoveryFromTray(
{
getRemoteSession: () => ({
advertiseNow: async () => {
calls.push('advertise');
return true;
},
}),
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async () => {
calls.push('start');
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
},
{ desiredActive: false },
);
assert.deepEqual(calls, [
'stop',
'info:Jellyfin discovery stopped.',
'osd:Jellyfin discovery stopped',
'refresh',
]);
});
test('restarts active discovery when current session is not visible', async () => {
const calls: string[] = [];
let session: { advertiseNow: () => Promise<boolean> } | null = {
advertiseNow: async () => {
calls.push('advertise-stale');
return false;
},
};
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => session,
stopRemoteSession: () => {
calls.push('stop');
session = null;
},
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise-fresh');
return true;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
assert.deepEqual(calls, [
'advertise-stale',
'warn:Jellyfin discovery was active but not visible; restarting.',
'stop',
'start',
'advertise-fresh',
'info:Jellyfin discovery started; cast target is visible in server sessions.',
'osd:Jellyfin discovery started',
'refresh',
]);
});
test('warns and refreshes tray when explicit discovery cannot create a session', async () => {
const calls: string[] = [];
+30 -4
View File
@@ -66,16 +66,42 @@ export async function toggleJellyfinDiscoveryFromTray<TSession extends JellyfinT
| 'logger'
| 'showMpvOsd'
>,
options: { desiredActive?: boolean } = {},
): Promise<void> {
try {
const activeSession = deps.getRemoteSession();
if (activeSession) {
deps.stopRemoteSession();
deps.logger.info('Jellyfin discovery stopped.');
deps.showMpvOsd('Jellyfin discovery stopped');
if (options.desiredActive === false) {
if (activeSession) {
deps.stopRemoteSession();
deps.logger.info('Jellyfin discovery stopped.');
deps.showMpvOsd('Jellyfin discovery stopped');
}
return;
}
if (activeSession) {
let visible = false;
try {
visible = await activeSession.advertiseNow();
} catch {
deps.logger.warn('Jellyfin discovery visibility check failed; restarting.');
}
if (visible) {
if (options.desiredActive === true) {
deps.logger.info('Jellyfin discovery already active.');
} else {
deps.stopRemoteSession();
deps.logger.info('Jellyfin discovery stopped.');
deps.showMpvOsd('Jellyfin discovery stopped');
}
return;
}
deps.logger.warn('Jellyfin discovery was active but not visible; restarting.');
deps.stopRemoteSession();
}
await deps.startRemoteSession({ explicit: true });
const remoteSession = deps.getRemoteSession();
if (!remoteSession) {
@@ -57,3 +57,33 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable
cleanup: resolved?.cleanup,
});
});
test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => {
let extracted = false;
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg-custom',
extractInternalSubtitleTrack: async () => {
extracted = true;
return {
path: '/tmp/subminer-sidebar-123/track_7.ass',
cleanup: async () => {},
};
},
});
const resolved = await resolveSource({
currentExternalFilenameRaw: null,
currentTrackRaw: {
type: 'sub',
id: 3,
'ff-index': 7,
codec: 'ass',
},
trackListRaw: [],
sidRaw: 3,
videoPath: 'http://jellyfin.local/Videos/movie/stream?static=true',
});
assert.equal(resolved, null);
assert.equal(extracted, false);
});
@@ -28,6 +28,15 @@ function parseTrackId(value: unknown): number | null {
return null;
}
function isRemoteMediaPath(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function getActiveSubtitleTrack(
currentTrackRaw: unknown,
trackListRaw: unknown,
@@ -104,6 +113,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
return { path: externalFilename, sourceKey: externalFilename };
}
if (isRemoteMediaPath(input.videoPath)) {
return null;
}
const extracted = await deps.extractInternalSubtitleTrack(
deps.getFfmpegPath(),
input.videoPath,
+57
View File
@@ -43,6 +43,63 @@ test('ensure tray updates menu when tray already exists', () => {
assert.deepEqual(calls, ['set-menu']);
});
test('ensure tray refreshes existing tray menu on linux with setContextMenu', () => {
const calls: string[] = [];
let trayRef: unknown = {
setContextMenu: () => calls.push('old-set-menu'),
setToolTip: () => calls.push('old-set-tooltip'),
on: () => calls.push('old-bind-click'),
destroy: () => calls.push('old-destroy'),
};
const ensureTray = createEnsureTrayHandler({
getTray: () => trayRef as never,
setTray: (tray) => {
trayRef = tray;
calls.push(tray ? 'set-new-tray' : 'clear-tray');
},
buildTrayMenu: () => ({ id: 'menu' }),
resolveTrayIconPath: () => '/tmp/icon.png',
createImageFromPath: () =>
({
isEmpty: () => false,
resize: (options: { width: number; height: number }) => {
calls.push(`resize:${options.width}x${options.height}`);
return {
isEmpty: () => false,
resize: () => {
throw new Error('unexpected');
},
setTemplateImage: () => {},
};
},
setTemplateImage: () => {},
}) as never,
createEmptyImage: () =>
({
isEmpty: () => true,
resize: () => {
throw new Error('unexpected');
},
setTemplateImage: () => {},
}) as never,
createTray: () =>
({
setContextMenu: () => calls.push('new-set-menu'),
setToolTip: () => calls.push('new-set-tooltip'),
on: () => calls.push('new-bind-click'),
destroy: () => calls.push('new-destroy'),
}) as never,
trayTooltip: 'SubMiner',
platform: 'linux',
logWarn: () => calls.push('warn'),
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
});
ensureTray();
assert.deepEqual(calls, ['old-set-menu']);
});
test('ensure tray creates new tray and binds click handler', () => {
const calls: string[] = [];
let trayRef: unknown = null;
+7 -4
View File
@@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => {
let initialized = false;
const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => {
calls.push(`platform:${handlers.platform}`);
handlers.openSessionHelp();
handlers.openTexthookerInBrowser();
calls.push(`show-texthooker:${handlers.showTexthookerPage}`);
@@ -50,7 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery();
handlers.toggleJellyfinDiscovery(true);
handlers.openAnilistSetup();
handlers.checkForUpdates();
handlers.quitApp();
@@ -72,9 +73,10 @@ test('build tray template handler wires actions and init guards', () => {
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: async () => {
calls.push('jellyfin-discovery');
toggleJellyfinDiscovery: async (checked) => {
calls.push(`jellyfin-discovery:${checked}`);
},
platform: 'linux',
openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
@@ -83,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => {
const template = buildTemplate();
assert.deepEqual(template, [{ label: 'ok' }]);
assert.deepEqual(calls, [
'platform:linux',
'init',
'help',
'texthooker',
@@ -92,7 +95,7 @@ test('build tray template handler wires actions and init guards', () => {
'yomitan',
'configuration',
'jellyfin',
'jellyfin-discovery',
'jellyfin-discovery:true',
'anilist',
'updates',
'quit',
+7 -4
View File
@@ -37,6 +37,7 @@ export function shouldShowTexthookerTrayEntry(config: {
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
platform?: string;
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
@@ -49,7 +50,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
toggleJellyfinDiscovery: (checked: boolean) => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
@@ -67,13 +68,15 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
toggleJellyfinDiscovery: (checked: boolean) => void | Promise<void>;
platform?: string;
openAnilistSetupWindow: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) {
return (): TMenuItem[] => {
return deps.buildTrayMenuTemplateRuntime({
platform: deps.platform,
openSessionHelp: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
@@ -103,8 +106,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
},
showJellyfinDiscovery: deps.isJellyfinConfigured(),
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
toggleJellyfinDiscovery: () => {
void deps.toggleJellyfinDiscovery();
toggleJellyfinDiscovery: (checked) => {
void deps.toggleJellyfinDiscovery(checked);
},
openAnilistSetup: () => {
deps.openAnilistSetupWindow();
+6 -3
View File
@@ -35,15 +35,18 @@ test('tray main deps builders return mapped handlers', () => {
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => {
calls.push('jellyfin-discovery');
toggleJellyfinDiscovery: (checked) => {
calls.push(`jellyfin-discovery:${checked}`);
},
platform: 'linux',
openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
})();
assert.equal(menuDeps.platform, 'linux');
const template = menuDeps.buildTrayMenuTemplateRuntime({
platform: menuDeps.platform,
openSessionHelp: () => calls.push('open-help'),
openTexthookerInBrowser: () => calls.push('open-texthooker'),
showTexthookerPage: true,
@@ -56,7 +59,7 @@ test('tray main deps builders return mapped handlers', () => {
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
toggleJellyfinDiscovery: (checked) => calls.push(`open-jellyfin-discovery:${checked}`),
openAnilistSetup: () => calls.push('open-anilist'),
checkForUpdates: () => calls.push('open-updates'),
quitApp: () => calls.push('quit-app'),
+5 -2
View File
@@ -27,6 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
platform?: string;
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
@@ -39,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
toggleJellyfinDiscovery: (checked: boolean) => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
@@ -57,13 +58,15 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
toggleJellyfinDiscovery: (checked: boolean) => void | Promise<void>;
platform?: string;
openAnilistSetupWindow: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) {
return () => ({
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
platform: deps.platform,
initializeOverlayRuntime: deps.initializeOverlayRuntime,
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
openSessionHelpModal: deps.openSessionHelpModal,
+29 -3
View File
@@ -41,7 +41,7 @@ test('tray menu template contains expected entries and handlers', () => {
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
openAnilistSetup: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
@@ -60,7 +60,7 @@ test('tray menu template contains expected entries and handlers', () => {
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, false);
discovery?.click?.();
discovery?.click?.({ checked: true });
template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
@@ -70,7 +70,7 @@ test('tray menu template contains expected entries and handlers', () => {
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery',
'jellyfin-discovery:true',
'help',
'texthooker',
'updates',
@@ -155,3 +155,29 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, true);
});
test('tray menu template renders a visible linux discovery check mark when active', () => {
const template = buildTrayMenuTemplateRuntime({
platform: 'linux',
openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: true,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: true,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
checkForUpdates: () => undefined,
quitApp: () => undefined,
});
const discovery = template.find((entry) => entry.label === '✓ Jellyfin Discovery');
assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, true);
});
+20 -4
View File
@@ -30,6 +30,7 @@ export function resolveTrayIconPathRuntime(deps: {
}
export type TrayMenuActionHandlers = {
platform?: string;
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
@@ -42,19 +43,28 @@ export type TrayMenuActionHandlers = {
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
toggleJellyfinDiscovery: (checked: boolean) => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
};
type TrayMenuClickItem = {
checked?: boolean;
};
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
label?: string;
type?: 'separator' | 'checkbox';
checked?: boolean;
enabled?: boolean;
click?: () => void;
click?: (menuItem?: TrayMenuClickItem) => void;
}> {
const jellyfinDiscoveryLabel =
handlers.platform === 'linux' && handlers.jellyfinDiscoveryActive
? '✓ Jellyfin Discovery'
: 'Jellyfin Discovery';
return [
{
label: 'Open Help',
@@ -99,11 +109,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
...(handlers.showJellyfinDiscovery
? [
{
label: 'Jellyfin Discovery',
label: jellyfinDiscoveryLabel,
type: 'checkbox' as const,
checked: handlers.jellyfinDiscoveryActive,
enabled: true,
click: handlers.toggleJellyfinDiscovery,
click: (menuItem?: TrayMenuClickItem) => {
const checked =
typeof menuItem?.checked === 'boolean'
? menuItem.checked
: !handlers.jellyfinDiscoveryActive;
handlers.toggleJellyfinDiscovery(checked);
},
},
]
: []),
+26
View File
@@ -244,3 +244,29 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
harness.restoreGlobals();
}
});
test('subsync modal ignores enter submission when no sync engine is available', async () => {
let runCalls = 0;
const harness = createTestHarness(async () => {
runCalls += 1;
return { ok: true, message: 'ok' };
});
try {
harness.modal.openSubsyncModal({
sourceTracks: [],
ffsubsyncAvailable: false,
});
harness.modal.handleSubsyncKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
await flushMicrotasks();
assert.equal(runCalls, 0);
assert.equal(harness.ctx.state.subsyncModalOpen, true);
} finally {
harness.restoreGlobals();
}
});
+9 -1
View File
@@ -105,8 +105,16 @@ export function createSubsyncModal(
async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return;
if (ctx.dom.subsyncRunButton.disabled) return;
const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync';
const useAlass = ctx.dom.subsyncEngineAlass.checked;
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
if (!useAlass && !useFfsubsync) {
setSubsyncStatus('No sync engine available for current media.', true);
return;
}
const engine = useAlass ? 'alass' : 'ffsubsync';
const sourceTrackId =
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
-4
View File
@@ -300,14 +300,10 @@ export interface ResolvedConfig {
serverUrl: string;
recentServers: string[];
username: string;
deviceId: string;
clientName: string;
clientVersion: string;
defaultLibraryId: string;
remoteControlEnabled: boolean;
remoteControlAutoConnect: boolean;
autoAnnounce: boolean;
remoteControlDeviceName: string;
pullPictures: boolean;
iconCacheDir: string;
directPlayPreferred: boolean;
-4
View File
@@ -87,14 +87,10 @@ export interface JellyfinConfig {
serverUrl?: string;
recentServers?: string[];
username?: string;
deviceId?: string;
clientName?: string;
clientVersion?: string;
defaultLibraryId?: string;
remoteControlEnabled?: boolean;
remoteControlAutoConnect?: boolean;
autoAnnounce?: boolean;
remoteControlDeviceName?: string;
pullPictures?: boolean;
iconCacheDir?: string;
directPlayPreferred?: boolean;