mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
fix(jellyfin): show overlay, inject plugin, and fix stats title on playback (#77)
* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb - Show visible overlay automatically during Jellyfin playback so subtitleStyle applies - Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus - Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles - Mark ffsubsync unavailable in subsync modal for remote media paths - Drain queued second-instance commands even when onReady throws * fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause - Keep overlay visible during macOS foreground probe after overlay blur - Hold sidebar hover-pause while a Yomitan lookup popup remains open * 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 * docs(release): trim and consolidate prerelease notes for 0.15.0 - Remove breaking changes section and several redundant bullet points - Consolidate per-platform updater notes into a single entry - Normalize em-dash separators to hyphens in section headers * fix(config): remove trailing commas from config.example.jsonc - Strip trailing commas throughout both config.example.jsonc copies - Reformat inline arrays to multi-line for JSON strictness - Update Jellyfin subtitle preload and playback launch tests and impl * fix(tokenizer): preserve known-word highlight when POS filters suppress - Known-word cache matches now set isKnown=true even for tokens excluded by POS filters - POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate - Jellyfin subtitle preload continues after cleanup failures instead of aborting - Update config docs and option description to document the known-word bypass behavior * fix(jellyfin): send explicit hide/show overlay instead of toggle - Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known - Prevent paused Jellyfin playback from resuming on overlay hide - Fix subtitle cache cleanup to only remove dirs after successful cleanup * fix(jellyfin): fix remote progress sync, seek reporting, and startup sto - arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events - force immediate progress report on seek-like position jumps at the mpv time-pos level - send positionTicks and failed=false in reportStopped payload - remove EventName from HTTP timeline payloads (websocket-only field) - add startup grace window to drop stop events before media finishes loading * fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi - Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift - Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress - Preserve manual hide across Jellyfin path-changing redirects even when media-title drops - Rearm managed subtitle defaults on path-changing redirects - Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC - Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus - Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary - Add stats window layer management so delete/update dialogs appear above stats window - Fix Jellyfin remote progress sync during Linux websocket reconnect windows * Fix CodeRabbit review feedback * fix(jellyfin): subtitle timing, resume progress, and overlay sync - Add per-stream subtitle delay persistence and auto timeline-offset correction - Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload - Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports - Keep Play vs Resume distinct to avoid early seek race on normal play - Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress - Deduplicate show/hide overlay commands using recorded visibility state - Rewrite docs-site Jellyfin page around cast-to-device UX * test: update lifecycle cleanup assertion * fix: clear aborted playback state, fix overlay passthrough, and guard du - Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item - Record visible overlay action only after command succeeds, not before - Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering) - Defer activeParsedSubtitleMediaPath assignment until after prefetch completes - Move autoplay gate release into the hide branch of toggleVisibleOverlay - Clear active Jellyfin playback when stopping media that never loaded - Reset managed subtitle delay and delay key when no external tracks are available - Await async removeDir in subtitle cache cleanup - Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs - Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
This commit is contained in:
@@ -41,19 +41,65 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 31);
|
||||
assert.equal(calls.length, 32);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
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');
|
||||
},
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
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;
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
@@ -60,7 +61,11 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.clearFirstRunSetupWindow();
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
try {
|
||||
deps.stopJellyfinRemoteSession();
|
||||
} finally {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
}
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
@@ -91,6 +92,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
@@ -145,6 +147,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -194,6 +197,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -141,6 +142,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ export function composeJellyfinRemoteHandlers(
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
getMpvClient: options.getMpvClient,
|
||||
getNow: options.getNow,
|
||||
ticksPerSecond: options.ticksPerSecond,
|
||||
logDebug: options.logDebug,
|
||||
});
|
||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
||||
@@ -101,6 +104,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,
|
||||
@@ -50,6 +48,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }),
|
||||
cleanupCachedSubtitles: () => {},
|
||||
logDebug: () => {},
|
||||
},
|
||||
playJellyfinItemInMpvMainDeps: {
|
||||
@@ -58,11 +58,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
mode: 'direct',
|
||||
url: 'https://example.test/video.m3u8',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => undefined,
|
||||
@@ -133,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
defaultDeviceId: 'dev',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: 'test',
|
||||
getHostName: () => 'workstation',
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
@@ -189,6 +195,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
|
||||
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
|
||||
assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function');
|
||||
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.runJellyfinCommand, 'function');
|
||||
|
||||
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
|
||||
>;
|
||||
startJellyfinRemoteSessionMainDeps: Omit<
|
||||
StartRemoteSessionMainDeps,
|
||||
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
|
||||
| 'getJellyfinConfig'
|
||||
| 'getClientInfo'
|
||||
| 'handlePlay'
|
||||
| 'handlePlaystate'
|
||||
| 'handleGeneralCommand'
|
||||
>;
|
||||
stopJellyfinRemoteSessionMainDeps: Parameters<
|
||||
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
|
||||
@@ -142,6 +146,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['handleJellyfinRemoteGeneralCommand'];
|
||||
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
||||
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
|
||||
@@ -235,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),
|
||||
@@ -280,6 +286,7 @@ export function composeJellyfinRuntimeHandlers(
|
||||
handleJellyfinRemotePlaystate,
|
||||
handleJellyfinRemoteGeneralCommand,
|
||||
playJellyfinItemInMpv,
|
||||
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
runJellyfinCommand,
|
||||
|
||||
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -11,16 +11,23 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
url: 'u',
|
||||
mode: 'direct',
|
||||
title: 't',
|
||||
itemTitle: 't',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`),
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => calls.push('preload'),
|
||||
preloadExternalSubtitles: () => {
|
||||
calls.push('preload');
|
||||
},
|
||||
setActivePlayback: () => calls.push('active'),
|
||||
setLastProgressAtMs: () => calls.push('progress'),
|
||||
reportPlaying: () => calls.push('report'),
|
||||
@@ -49,12 +56,17 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
url: 'u',
|
||||
mode: 'direct',
|
||||
title: 't',
|
||||
itemTitle: 't',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
},
|
||||
);
|
||||
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
|
||||
deps.showVisibleOverlay();
|
||||
deps.sendMpvCommand(['show-text', 'x']);
|
||||
deps.armQuitOnDisconnect();
|
||||
deps.schedule(() => {}, 500);
|
||||
@@ -85,6 +97,7 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'defaults',
|
||||
'visible-overlay',
|
||||
'cmd:show-text',
|
||||
'arm',
|
||||
'schedule:500',
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params),
|
||||
applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient),
|
||||
showVisibleOverlay: () => deps.showVisibleOverlay(),
|
||||
sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command),
|
||||
armQuitOnDisconnect: () => deps.armQuitOnDisconnect(),
|
||||
schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs),
|
||||
@@ -19,5 +20,11 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
||||
reportPlaying: (payload) => deps.reportPlaying(payload),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||
: undefined,
|
||||
updateCurrentMediaTitle: deps.updateCurrentMediaTitle
|
||||
? (title) => deps.updateCurrentMediaTitle!(title)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ test('playback handler throws when mpv is not connected', async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
@@ -52,6 +53,7 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
const calls: string[] = [];
|
||||
const activeStates: Array<Record<string, unknown>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const statsMetadata: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
@@ -59,22 +61,32 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Show Title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
startTimeTicks: 12_000_000,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
schedule: (callback, delayMs) => {
|
||||
scheduled.push({ delay: delayMs, callback });
|
||||
},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => calls.push('preload'),
|
||||
preloadExternalSubtitles: () => {
|
||||
calls.push('preload');
|
||||
},
|
||||
setActivePlayback: (state) => activeStates.push(state as Record<string, unknown>),
|
||||
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
recordJellyfinPlaybackMetadata: (metadata) => {
|
||||
statsMetadata.push(metadata as Record<string, unknown>);
|
||||
},
|
||||
});
|
||||
|
||||
await handler({
|
||||
@@ -84,19 +96,34 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
itemId: 'item-1',
|
||||
});
|
||||
|
||||
assert.deepEqual(commands.slice(0, 5), [
|
||||
assert.deepEqual(commands.slice(0, 8), [
|
||||
['set_property', 'sub-auto', 'no'],
|
||||
['loadfile', 'https://stream.example/video.m3u8', 'replace'],
|
||||
['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'],
|
||||
['set_property', 'sid', 'no'],
|
||||
['seek', 1.2, 'absolute+exact'],
|
||||
['set_property', 'secondary-sid', 'no'],
|
||||
['set_property', 'sub-visibility', 'no'],
|
||||
['set_property', 'secondary-sub-visibility', 'no'],
|
||||
['script-message', 'subminer-managed-subtitles-loading'],
|
||||
[
|
||||
'loadfile',
|
||||
'https://stream.example/video.m3u8',
|
||||
'replace',
|
||||
-1,
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2',
|
||||
],
|
||||
['set_property', 'force-media-title', 'Episode 1'],
|
||||
]);
|
||||
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.indexOf('preload') < calls.indexOf('visible-overlay'),
|
||||
'visible overlay should be shown after Jellyfin subtitles are selected',
|
||||
);
|
||||
assert.ok(calls.includes('visible-overlay'));
|
||||
assert.ok(calls.includes('arm'));
|
||||
assert.ok(calls.includes('preload'));
|
||||
assert.ok(calls.includes('progress:0'));
|
||||
@@ -104,8 +131,354 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
|
||||
assert.equal(activeStates.length, 1);
|
||||
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
|
||||
assert.equal(activeStates[0]?.lastKnownPositionSeconds, 1.2);
|
||||
assert.equal(reportPayloads.length, 1);
|
||||
assert.equal(reportPayloads[0]?.eventName, 'start');
|
||||
assert.equal(reportPayloads[0]?.positionTicks, 12_000_000);
|
||||
assert.equal(reportPayloads[0]?.isPaused, false);
|
||||
assert.deepEqual(statsMetadata, [
|
||||
{
|
||||
mediaPath: 'https://stream.example/video.m3u8',
|
||||
displayTitle: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Show Title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
itemId: 'item-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('playback handler waits for Jellyfin subtitle preload before showing visible overlay', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolvePreload!: () => void;
|
||||
const preloadComplete = new Promise<void>((resolve) => {
|
||||
resolvePreload = resolve;
|
||||
});
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Show Title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: async () => {
|
||||
calls.push('preload-start');
|
||||
await preloadComplete;
|
||||
calls.push('preload-done');
|
||||
},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
const playback = handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-1',
|
||||
});
|
||||
for (let i = 0; i < 5 && calls.length === 0; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0], 'preload-start');
|
||||
resolvePreload();
|
||||
await playback;
|
||||
|
||||
assert.deepEqual(calls, ['preload-start', 'preload-done', 'visible-overlay']);
|
||||
});
|
||||
|
||||
test('playback handler strips Jellyfin subtitle stream from mpv load URL', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const reports: Array<Record<string, unknown>> = [];
|
||||
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&AudioStreamIndex=3&SubtitleStreamIndex=4',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: 3,
|
||||
subtitleStreamIndex: 4,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: (payload) => reports.push(payload),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'ep-1',
|
||||
});
|
||||
|
||||
const loadCommand = commands.find((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadCommand);
|
||||
const url = new URL(String(loadCommand[1]));
|
||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
|
||||
assert.equal(url.searchParams.has('SubtitleStreamIndex'), false);
|
||||
assert.equal(reports[0]?.subtitleStreamIndex, 4);
|
||||
});
|
||||
|
||||
test('playback handler starts remote Play from beginning when requested despite saved plan progress', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000',
|
||||
mode: 'transcode',
|
||||
title: 'Episode 2',
|
||||
itemTitle: 'Episode 2',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 35_000_000,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-2',
|
||||
startTimeTicksOverride: 0,
|
||||
fallbackToPlanStartTimeOnZeroOverride: false,
|
||||
});
|
||||
|
||||
const loadCommand = commands.find((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadCommand);
|
||||
const loadedUrl = String(loadCommand[1] ?? '');
|
||||
const parsed = new URL(loadedUrl);
|
||||
assert.equal(parsed.searchParams.get('StartTimeTicks'), null);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'seek'),
|
||||
false,
|
||||
);
|
||||
assert.equal(reportPayloads[0]?.positionTicks, 0);
|
||||
});
|
||||
|
||||
test('playback handler disables mpv subtitle selection before Jellyfin media loads', 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 1',
|
||||
itemTitle: 'Episode 1',
|
||||
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: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-1',
|
||||
});
|
||||
|
||||
const loadIndex = commands.findIndex((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadIndex > 0);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'script-message' &&
|
||||
command[1] === 'subminer-managed-subtitles-loading',
|
||||
) >= 0,
|
||||
);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'sid' &&
|
||||
command[2] === 'no',
|
||||
) >= 0,
|
||||
);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'secondary-sid' &&
|
||||
command[2] === 'no',
|
||||
) >= 0,
|
||||
);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'sub-visibility' &&
|
||||
command[2] === 'no',
|
||||
) >= 0,
|
||||
);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'secondary-sub-visibility' &&
|
||||
command[2] === 'no',
|
||||
) >= 0,
|
||||
);
|
||||
assert.equal(
|
||||
commands[loadIndex]?.[4],
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
|
||||
);
|
||||
});
|
||||
|
||||
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 arms unloaded active playback before loading mpv media', async () => {
|
||||
const timeline: string[] = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}`),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: (state) => timeline.push(`active:${String(state.loadedMediaPath)}`),
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-1',
|
||||
});
|
||||
|
||||
assert.ok(timeline.indexOf('active:null') >= 0);
|
||||
assert.ok(timeline.indexOf('active:null') < timeline.indexOf('cmd:loadfile'));
|
||||
});
|
||||
|
||||
test('playback handler applies start override to stream url for remote resume', async () => {
|
||||
@@ -117,11 +490,16 @@ test('playback handler applies start override to stream url for remote resume',
|
||||
url: 'https://stream.example/video.m3u8?api_key=token',
|
||||
mode: 'transcode',
|
||||
title: 'Episode 2',
|
||||
itemTitle: 'Episode 2',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
@@ -141,9 +519,226 @@ test('playback handler applies start override to stream url for remote resume',
|
||||
startTimeTicksOverride: 55_000_000,
|
||||
});
|
||||
|
||||
assert.equal(commands[1]?.[0], 'loadfile');
|
||||
const loadedUrl = String(commands[1]?.[1] ?? '');
|
||||
const loadCommand = commands.find((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadCommand);
|
||||
const loadedUrl = String(loadCommand[1] ?? '');
|
||||
const parsed = new URL(loadedUrl);
|
||||
assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
|
||||
assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'seek'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('playback handler keeps Jellyfin resume ticks when remote start override is zero', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000',
|
||||
mode: 'transcode',
|
||||
title: 'Episode 2',
|
||||
itemTitle: 'Episode 2',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 35_000_000,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-2',
|
||||
startTimeTicksOverride: 0,
|
||||
fallbackToPlanStartTimeOnZeroOverride: true,
|
||||
});
|
||||
|
||||
const loadCommand = commands.find((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadCommand);
|
||||
const loadedUrl = String(loadCommand[1] ?? '');
|
||||
const parsed = new URL(loadedUrl);
|
||||
assert.equal(parsed.searchParams.get('StartTimeTicks'), '35000000');
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'seek'),
|
||||
false,
|
||||
);
|
||||
assert.equal(reportPayloads[0]?.positionTicks, 35_000_000);
|
||||
});
|
||||
|
||||
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.find((command) => command[0] === 'loadfile'),
|
||||
[
|
||||
'loadfile',
|
||||
'https://stream.example/video.m3u8',
|
||||
'replace',
|
||||
-1,
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('playback handler does not let media title 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 4',
|
||||
itemTitle: 'Episode 4',
|
||||
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: () => {},
|
||||
updateCurrentMediaTitle: () => {
|
||||
throw new Error('title state unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-4',
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
commands.find((command) => command[0] === 'loadfile'),
|
||||
[
|
||||
'loadfile',
|
||||
'https://stream.example/video.m3u8',
|
||||
'replace',
|
||||
-1,
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('playback handler handles rejected best-effort hook promises', 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 5',
|
||||
itemTitle: 'Episode 5',
|
||||
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: () => {},
|
||||
updateCurrentMediaTitle: async () => {
|
||||
throw new Error('title async unavailable');
|
||||
},
|
||||
recordJellyfinPlaybackMetadata: async () => {
|
||||
throw new Error('stats async unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-5',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(
|
||||
commands.find((command) => command[0] === 'loadfile'),
|
||||
[
|
||||
'loadfile',
|
||||
'https://stream.example/video.m3u8',
|
||||
'replace',
|
||||
-1,
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,45 @@ type ActivePlaybackState = {
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
loadedMediaPath?: string | null;
|
||||
stopReportsAfterMs?: number;
|
||||
lastKnownPositionSeconds?: number;
|
||||
};
|
||||
|
||||
export type JellyfinPlaybackStatsMetadata = {
|
||||
mediaPath: string;
|
||||
displayTitle: string;
|
||||
itemTitle: string;
|
||||
seriesTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
itemId: string;
|
||||
};
|
||||
|
||||
const JELLYFIN_LOADFILE_SUBTITLE_SUPPRESSION_OPTIONS = [
|
||||
'sid=no',
|
||||
'secondary-sid=no',
|
||||
'sub-auto=no',
|
||||
'sub-visibility=no',
|
||||
'secondary-sub-visibility=no',
|
||||
];
|
||||
|
||||
function runBestEffortPlaybackHook(callback: () => void | Promise<void>): void {
|
||||
try {
|
||||
void Promise.resolve(callback()).catch(() => {});
|
||||
} catch {
|
||||
// Best-effort metadata/title hooks must not block playback startup.
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitBestEffortPlaybackHook(callback: () => void | Promise<void>): Promise<void> {
|
||||
try {
|
||||
await Promise.resolve(callback());
|
||||
} catch {
|
||||
// Best-effort startup hooks must not block playback startup.
|
||||
}
|
||||
}
|
||||
|
||||
function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string {
|
||||
if (typeof startTimeTicksOverride !== 'number') return url;
|
||||
try {
|
||||
@@ -31,6 +68,48 @@ function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?:
|
||||
}
|
||||
}
|
||||
|
||||
function stripStartTimeTicksFromPlaybackUrl(url: string): string {
|
||||
try {
|
||||
const resolved = new URL(url);
|
||||
resolved.searchParams.delete('StartTimeTicks');
|
||||
return resolved.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function stripManagedSubtitleStreamFromPlaybackUrl(url: string): string {
|
||||
try {
|
||||
const resolved = new URL(url);
|
||||
resolved.searchParams.delete('SubtitleStreamIndex');
|
||||
return resolved.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEffectiveStartTimeTicks(
|
||||
planStartTimeTicks: number,
|
||||
startTimeTicksOverride?: number,
|
||||
fallbackToPlanStartTimeOnZeroOverride = false,
|
||||
) {
|
||||
if (typeof startTimeTicksOverride === 'number' && startTimeTicksOverride > 0) {
|
||||
return Math.max(0, startTimeTicksOverride);
|
||||
}
|
||||
if (typeof startTimeTicksOverride === 'number') {
|
||||
return fallbackToPlanStartTimeOnZeroOverride ? Math.max(0, planStartTimeTicks) : 0;
|
||||
}
|
||||
return Math.max(0, planStartTimeTicks);
|
||||
}
|
||||
|
||||
function buildJellyfinLoadfileOptions(plan: JellyfinPlaybackPlan, startSeconds: number): string {
|
||||
const options = [...JELLYFIN_LOADFILE_SUBTITLE_SUPPRESSION_OPTIONS];
|
||||
if (plan.mode === 'direct' && startSeconds > 0) {
|
||||
options.push(`start=${startSeconds}`);
|
||||
}
|
||||
return options.join(',');
|
||||
}
|
||||
|
||||
export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
ensureMpvConnectedForPlayback: () => Promise<boolean>;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
@@ -43,6 +122,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
subtitleStreamIndex?: number | null;
|
||||
}) => Promise<JellyfinPlaybackPlan>;
|
||||
applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void;
|
||||
showVisibleOverlay: () => void;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
armQuitOnDisconnect: () => void;
|
||||
schedule: (callback: () => void, delayMs: number) => void;
|
||||
@@ -51,18 +131,24 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
session: JellyfinAuthSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}) => void;
|
||||
}) => void | Promise<void>;
|
||||
setActivePlayback: (state: ActivePlaybackState) => void;
|
||||
setLastProgressAtMs: (value: number) => void;
|
||||
reportPlaying: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId: undefined;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
positionTicks?: number;
|
||||
isPaused?: boolean;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'start';
|
||||
}) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
recordJellyfinPlaybackMetadata?: (
|
||||
metadata: JellyfinPlaybackStatsMetadata,
|
||||
) => void | Promise<void>;
|
||||
updateCurrentMediaTitle?: (title: string) => void | Promise<void>;
|
||||
}) {
|
||||
return async (params: {
|
||||
session: JellyfinAuthSession;
|
||||
@@ -72,6 +158,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
startTimeTicksOverride?: number;
|
||||
fallbackToPlanStartTimeOnZeroOverride?: boolean;
|
||||
setQuitOnDisconnectArm?: boolean;
|
||||
}): Promise<void> => {
|
||||
const connected = await deps.ensureMpvConnectedForPlayback();
|
||||
@@ -93,48 +180,68 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
deps.sendMpvCommand([
|
||||
'set_property',
|
||||
'force-media-title',
|
||||
`[Jellyfin/${plan.mode}] ${plan.title}`,
|
||||
]);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.schedule(() => {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
}, 500);
|
||||
|
||||
const startTimeTicks =
|
||||
typeof params.startTimeTicksOverride === 'number'
|
||||
? Math.max(0, params.startTimeTicksOverride)
|
||||
: plan.startTimeTicks;
|
||||
if (startTimeTicks > 0) {
|
||||
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
|
||||
}
|
||||
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
itemId: params.itemId,
|
||||
});
|
||||
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
|
||||
const startTimeTicks = resolveEffectiveStartTimeTicks(
|
||||
plan.startTimeTicks,
|
||||
params.startTimeTicksOverride,
|
||||
params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
);
|
||||
const startSeconds =
|
||||
startTimeTicks > 0 ? Math.max(0, deps.convertTicksToSeconds(startTimeTicks)) : 0;
|
||||
const playbackUrlBase =
|
||||
plan.mode === 'direct'
|
||||
? stripStartTimeTicksFromPlaybackUrl(plan.url)
|
||||
: applyStartTimeTicksToPlaybackUrl(plan.url, startTimeTicks);
|
||||
const playbackUrl = stripManagedSubtitleStreamFromPlaybackUrl(playbackUrlBase);
|
||||
const loadfileOptions = buildJellyfinLoadfileOptions(plan, startSeconds);
|
||||
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
|
||||
runBestEffortPlaybackHook(() => deps.updateCurrentMediaTitle?.(plan.title));
|
||||
runBestEffortPlaybackHook(() =>
|
||||
deps.recordJellyfinPlaybackMetadata?.({
|
||||
mediaPath: playbackUrl,
|
||||
displayTitle: plan.title,
|
||||
itemTitle: plan.itemTitle,
|
||||
seriesTitle: plan.seriesTitle,
|
||||
seasonNumber: plan.seasonNumber,
|
||||
episodeNumber: plan.episodeNumber,
|
||||
itemId: params.itemId,
|
||||
}),
|
||||
);
|
||||
deps.setActivePlayback({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
playMethod,
|
||||
loadedMediaPath: null,
|
||||
lastKnownPositionSeconds: startSeconds > 0 ? startSeconds : undefined,
|
||||
});
|
||||
deps.setLastProgressAtMs(0);
|
||||
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']);
|
||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
|
||||
|
||||
await awaitBestEffortPlaybackHook(() =>
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
itemId: params.itemId,
|
||||
}),
|
||||
);
|
||||
deps.showVisibleOverlay();
|
||||
|
||||
deps.reportPlaying({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
playMethod,
|
||||
positionTicks: startTimeTicks,
|
||||
isPaused: false,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
eventName: 'start',
|
||||
|
||||
@@ -21,7 +21,13 @@ test('getConfiguredJellyfinSession returns null for incomplete config', () => {
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', async () => {
|
||||
const calls: Array<{ itemId: string; audio?: number; subtitle?: number; start?: number }> = [];
|
||||
const calls: Array<{
|
||||
itemId: string;
|
||||
audio?: number;
|
||||
subtitle?: number;
|
||||
start?: number;
|
||||
fallback?: boolean;
|
||||
}> = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
@@ -37,6 +43,7 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a
|
||||
audio: params.audioStreamIndex,
|
||||
subtitle: params.subtitleStreamIndex,
|
||||
start: params.startTimeTicksOverride,
|
||||
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
});
|
||||
},
|
||||
logWarn: () => {},
|
||||
@@ -49,11 +56,13 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a
|
||||
StartPositionTicks: 1000,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]);
|
||||
assert.deepEqual(calls, [
|
||||
{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000, fallback: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () => {
|
||||
const calls: Array<{ itemId: string; start?: number }> = [];
|
||||
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
@@ -67,6 +76,7 @@ test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async ()
|
||||
calls.push({
|
||||
itemId: params.itemId,
|
||||
start: params.startTimeTicksOverride,
|
||||
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
});
|
||||
},
|
||||
logWarn: () => {},
|
||||
@@ -77,7 +87,64 @@ test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async ()
|
||||
StartPositionTicks: '12345',
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345 }]);
|
||||
assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345, fallback: true }]);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay starts from beginning when StartPositionTicks is omitted', async () => {
|
||||
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'user',
|
||||
username: 'name',
|
||||
}),
|
||||
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
|
||||
getJellyfinConfig: () => ({ enabled: true }),
|
||||
playJellyfinItem: async (params) => {
|
||||
calls.push({
|
||||
itemId: params.itemId,
|
||||
start: params.startTimeTicksOverride,
|
||||
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
});
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await handlePlay({
|
||||
ItemIds: ['item-3'],
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ itemId: 'item-3', start: 0, fallback: false }]);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay lets explicit zero fall back to Jellyfin item progress', async () => {
|
||||
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'user',
|
||||
username: 'name',
|
||||
}),
|
||||
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
|
||||
getJellyfinConfig: () => ({ enabled: true }),
|
||||
playJellyfinItem: async (params) => {
|
||||
calls.push({
|
||||
itemId: params.itemId,
|
||||
start: params.startTimeTicksOverride,
|
||||
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
});
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await handlePlay({
|
||||
ItemIds: ['item-4'],
|
||||
StartPositionTicks: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ itemId: 'item-4', start: 0, fallback: true }]);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
|
||||
@@ -101,6 +168,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)[]> = [];
|
||||
|
||||
@@ -4,6 +4,9 @@ export type ActiveJellyfinRemotePlaybackState = {
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
loadedMediaPath?: string | null;
|
||||
stopReportsAfterMs?: number;
|
||||
lastKnownPositionSeconds?: number;
|
||||
};
|
||||
|
||||
type JellyfinSession = {
|
||||
@@ -51,6 +54,7 @@ export type JellyfinRemotePlayHandlerDeps = {
|
||||
getConfiguredSession: () => JellyfinSession | null;
|
||||
getClientInfo: () => JellyfinClientInfo;
|
||||
getJellyfinConfig: () => unknown;
|
||||
getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
playJellyfinItem: (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
@@ -59,6 +63,7 @@ export type JellyfinRemotePlayHandlerDeps = {
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
startTimeTicksOverride?: number;
|
||||
fallbackToPlanStartTimeOnZeroOverride?: boolean;
|
||||
setQuitOnDisconnectArm?: boolean;
|
||||
}) => Promise<void>;
|
||||
logWarn: (message: string) => void;
|
||||
@@ -79,6 +84,13 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
|
||||
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
|
||||
return;
|
||||
}
|
||||
if (deps.getActivePlayback?.()?.itemId === itemId) {
|
||||
return;
|
||||
}
|
||||
const hasStartPositionTicks = Object.prototype.hasOwnProperty.call(data, 'StartPositionTicks');
|
||||
const startTimeTicksOverride = hasStartPositionTicks
|
||||
? (asInteger(data.StartPositionTicks) ?? 0)
|
||||
: 0;
|
||||
await deps.playJellyfinItem({
|
||||
session,
|
||||
clientInfo,
|
||||
@@ -86,7 +98,8 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
|
||||
itemId,
|
||||
audioStreamIndex: asInteger(data.AudioStreamIndex),
|
||||
subtitleStreamIndex: asInteger(data.SubtitleStreamIndex),
|
||||
startTimeTicksOverride: asInteger(data.StartPositionTicks),
|
||||
startTimeTicksOverride,
|
||||
fallbackToPlanStartTimeOnZeroOverride: hasStartPositionTicks,
|
||||
setQuitOnDisconnectArm: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -36,6 +36,14 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
getLaunchMode: () => 'fullscreen',
|
||||
platform: 'darwin',
|
||||
execPath: '/tmp/subminer',
|
||||
getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
source: null,
|
||||
message: null,
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mpv.log',
|
||||
defaultMpvArgs: ['--no-config'],
|
||||
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
||||
@@ -51,6 +59,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
assert.equal(deps.getLaunchMode(), 'fullscreen');
|
||||
assert.equal(deps.platform, 'darwin');
|
||||
assert.equal(deps.execPath, '/tmp/subminer');
|
||||
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
|
||||
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
|
||||
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');
|
||||
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
||||
deps.removeSocketPath('/tmp/mpv.sock');
|
||||
|
||||
@@ -20,6 +20,8 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
getLaunchMode: () => deps.getLaunchMode(),
|
||||
platform: deps.platform,
|
||||
execPath: deps.execPath,
|
||||
getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint,
|
||||
getInstalledPluginDetection: deps.getInstalledPluginDetection,
|
||||
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
|
||||
defaultMpvLogPath: deps.defaultMpvLogPath,
|
||||
defaultMpvArgs: deps.defaultMpvArgs,
|
||||
|
||||
@@ -34,6 +34,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
getLaunchMode: () => 'maximized',
|
||||
platform: 'darwin',
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
getRuntimePluginEntrypoint: () =>
|
||||
'/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
@@ -52,6 +54,11 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
assert.equal(spawnedArgs.length, 1);
|
||||
assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes'));
|
||||
assert.ok(spawnedArgs[0]!.includes('--idle=yes'));
|
||||
assert.ok(
|
||||
spawnedArgs[0]!.includes(
|
||||
'--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||
),
|
||||
);
|
||||
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
|
||||
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
|
||||
});
|
||||
@@ -101,6 +108,43 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: true,
|
||||
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
|
||||
version: '0.1.0',
|
||||
source: 'default-config',
|
||||
message: null,
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
unref: () => {},
|
||||
};
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
launch();
|
||||
assert.equal(
|
||||
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
|
||||
false,
|
||||
);
|
||||
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
|
||||
});
|
||||
|
||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||
let autoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let launchCalls = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type SubminerPluginRuntimeScriptOptConfig,
|
||||
} from '../../shared/subminer-plugin-script-opts';
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
@@ -44,6 +45,8 @@ export type LaunchMpvForJellyfinDeps = {
|
||||
getLaunchMode: () => MpvLaunchMode;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
||||
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
|
||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
@@ -75,9 +78,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
)
|
||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||
const installedPlugin = deps.getInstalledPluginDetection?.();
|
||||
const runtimePluginEntrypoint = installedPlugin?.installed
|
||||
? ''
|
||||
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
|
||||
if (installedPlugin?.installed && installedPlugin.path) {
|
||||
deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`);
|
||||
}
|
||||
const mpvArgs = [
|
||||
...deps.defaultMpvArgs,
|
||||
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
|
||||
...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []),
|
||||
'--idle=yes',
|
||||
scriptOpts,
|
||||
`--log-file=${deps.defaultMpvLogPath}`,
|
||||
|
||||
@@ -103,12 +103,16 @@ test('jellyfin remote stopped main deps builder maps callbacks', () => {
|
||||
getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }),
|
||||
clearActivePlayback: () => calls.push('clear'),
|
||||
getSession: () => session as never,
|
||||
getMpvClient: () => ({ id: 2, currentTimePos: 4 }) as never,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' });
|
||||
deps.clearActivePlayback();
|
||||
assert.equal(deps.getSession(), session);
|
||||
assert.deepEqual(deps.getMpvClient(), { id: 2, currentTimePos: 4 });
|
||||
assert.equal(deps.ticksPerSecond, 10_000_000);
|
||||
deps.logDebug('stopped', null);
|
||||
assert.deepEqual(calls, ['clear', 'debug:stopped']);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -68,6 +71,9 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
||||
getActivePlayback: () => deps.getActivePlayback(),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
getSession: () => deps.getSession(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||
ticksPerSecond: deps.ticksPerSecond,
|
||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
markJellyfinRemotePlaybackLoaded,
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
secondsToJellyfinTicks,
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from './jellyfin-remote-playback';
|
||||
|
||||
test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => {
|
||||
@@ -12,6 +14,39 @@ test('secondsToJellyfinTicks converts seconds and clamps invalid values', () =>
|
||||
assert.equal(secondsToJellyfinTicks(Number.NaN, 10_000_000), 0);
|
||||
});
|
||||
|
||||
test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic secondary autoload for active Jellyfin media', () => {
|
||||
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{ itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null },
|
||||
'http://pve-main:8096/Videos/item/stream',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
||||
},
|
||||
'http://pve-main:8096/Videos/item/stream',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
||||
},
|
||||
'/tmp/local.mkv',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
|
||||
let lastProgressAtMs = 0;
|
||||
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
|
||||
@@ -61,6 +96,74 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
||||
assert.equal(lastProgressAtMs, 5000);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
|
||||
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
}),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => ({
|
||||
isConnected: () => false,
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
isPaused: payload.isPaused,
|
||||
});
|
||||
},
|
||||
reportStopped: async () => {},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 42,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
|
||||
}),
|
||||
getNow: () => 5000,
|
||||
getLastProgressAtMs: () => 0,
|
||||
setLastProgressAtMs: () => {},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportProgress(true);
|
||||
|
||||
assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]);
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -91,9 +194,61 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during debounce', async () => {
|
||||
let now = 5000;
|
||||
let lastProgressAtMs = 0;
|
||||
let position = 10;
|
||||
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
}),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
eventName: payload.eventName,
|
||||
});
|
||||
},
|
||||
reportStopped: async () => {},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: position,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
||||
}),
|
||||
getNow: () => now,
|
||||
getLastProgressAtMs: () => lastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportProgress(true);
|
||||
now = 5500;
|
||||
position = 90;
|
||||
await reportProgress(false);
|
||||
|
||||
assert.deepEqual(reportPayloads, [
|
||||
{ positionTicks: 100_000_000, eventName: 'TimeUpdate' },
|
||||
{ positionTicks: 900_000_000, eventName: 'TimeUpdate' },
|
||||
]);
|
||||
assert.equal(lastProgressAtMs, 5500);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => {
|
||||
let cleared = false;
|
||||
let stoppedItemId: string | null = null;
|
||||
let stoppedPayload: {
|
||||
itemId: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
@@ -109,13 +264,267 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async (payload) => {
|
||||
stoppedItemId = payload.itemId;
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 12.5,
|
||||
requestProperty: async () => {
|
||||
throw new Error('unloaded');
|
||||
},
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
assert.equal(stoppedItemId, 'item-2');
|
||||
assert.deepEqual(stoppedPayload, {
|
||||
itemId: 'item-2',
|
||||
positionTicks: 125_000_000,
|
||||
failed: false,
|
||||
});
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler clears aborted playback that never loaded', async () => {
|
||||
let cleared = false;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
mediaSourceId: undefined,
|
||||
playMethod: 'Transcode',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
loadedMediaPath: null,
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {
|
||||
throw new Error('should not report stopped for unloaded media');
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => null,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
||||
let cleared = false;
|
||||
let stoppedPayload: {
|
||||
itemId: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
mediaSourceId: undefined,
|
||||
playMethod: 'Transcode',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => false,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 12.5,
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.deepEqual(stoppedPayload, {
|
||||
itemId: 'item-2',
|
||||
positionTicks: 125_000_000,
|
||||
failed: false,
|
||||
});
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
|
||||
let cleared = false;
|
||||
const calls: Array<{ event: string; positionTicks?: number }> = [];
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () =>
|
||||
({
|
||||
itemId: 'item-2',
|
||||
mediaSourceId: undefined,
|
||||
playMethod: 'DirectPlay',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
lastKnownPositionSeconds: 72.25,
|
||||
}) as never,
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async (payload) => {
|
||||
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
|
||||
},
|
||||
reportStopped: async (payload) => {
|
||||
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 0,
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ event: 'progress', positionTicks: 722_500_000 },
|
||||
{ event: 'stopped', positionTicks: 722_500_000 },
|
||||
]);
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
|
||||
let cleared = false;
|
||||
let stopped = false;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () =>
|
||||
({
|
||||
itemId: 'item-2',
|
||||
playMethod: 'Transcode',
|
||||
loadedMediaPath: null,
|
||||
}) as never,
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 0,
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
|
||||
let position = 42;
|
||||
let lastProgressAtMs = 0;
|
||||
const playback = {
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay' as const,
|
||||
};
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
getActivePlayback: () => playback,
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: position,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
||||
}),
|
||||
getNow: () => 5000,
|
||||
getLastProgressAtMs: () => lastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportProgress(true);
|
||||
position = 0;
|
||||
await reportProgress(true);
|
||||
|
||||
assert.equal((playback as { lastKnownPositionSeconds?: number }).lastKnownPositionSeconds, 42);
|
||||
});
|
||||
|
||||
test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload paths', () => {
|
||||
const playback = {
|
||||
itemId: 'item-2',
|
||||
playMethod: 'Transcode' as const,
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
};
|
||||
|
||||
markJellyfinRemotePlaybackLoaded(playback, '');
|
||||
markJellyfinRemotePlaybackLoaded(playback, ' ');
|
||||
assert.equal(playback.loadedMediaPath, 'https://stream.example/video.m3u8');
|
||||
|
||||
markJellyfinRemotePlaybackLoaded(playback, ' https://stream.example/next.m3u8 ');
|
||||
assert.equal(playback.loadedMediaPath, 'https://stream.example/next.m3u8');
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before grace expires', async () => {
|
||||
let cleared = false;
|
||||
let stopped = false;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () =>
|
||||
({
|
||||
itemId: 'item-2',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
stopReportsAfterMs: 20_000,
|
||||
}) as never,
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 0,
|
||||
}),
|
||||
getNow: () => 12_000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(cleared, false);
|
||||
});
|
||||
|
||||
@@ -10,11 +10,13 @@ type JellyfinRemoteSessionLike = {
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'timeupdate';
|
||||
eventName: 'TimeUpdate';
|
||||
}) => Promise<unknown>;
|
||||
reportStopped: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
@@ -23,7 +25,8 @@ type JellyfinRemoteSessionLike = {
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
currentTimePos?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number {
|
||||
@@ -31,6 +34,106 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number):
|
||||
return Math.max(0, Math.floor(seconds * ticksPerSecond));
|
||||
}
|
||||
|
||||
export function markJellyfinRemotePlaybackLoaded(
|
||||
playback: ActiveJellyfinRemotePlaybackState | null,
|
||||
path: string,
|
||||
): void {
|
||||
const normalizedPath = path.trim();
|
||||
if (playback && normalizedPath) {
|
||||
playback.loadedMediaPath = normalizedPath;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
playback: ActiveJellyfinRemotePlaybackState | null,
|
||||
path: string,
|
||||
): boolean {
|
||||
const normalizedPath = path.trim();
|
||||
if (!normalizedPath || !playback) {
|
||||
return true;
|
||||
}
|
||||
const loadedMediaPath = playback.loadedMediaPath?.trim() ?? '';
|
||||
if (!loadedMediaPath) {
|
||||
return false;
|
||||
}
|
||||
return loadedMediaPath !== normalizedPath;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeMpvPositionSeconds(value: unknown): number {
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds)) return 0;
|
||||
return Math.max(0, seconds);
|
||||
}
|
||||
|
||||
function getCachedMpvPositionSeconds(client: MpvClientLike | null): number | null {
|
||||
if (!client) return null;
|
||||
const seconds = Number(client.currentTimePos);
|
||||
return Number.isFinite(seconds) ? Math.max(0, seconds) : null;
|
||||
}
|
||||
|
||||
async function readMpvPositionSeconds(client: MpvClientLike | null): Promise<number> {
|
||||
const cached = getCachedMpvPositionSeconds(client);
|
||||
if (cached !== null) return cached;
|
||||
const position = await client?.requestProperty?.('time-pos');
|
||||
return normalizeMpvPositionSeconds(position);
|
||||
}
|
||||
|
||||
async function readMpvPositionSecondsOrFallback(
|
||||
client: MpvClientLike | null,
|
||||
fallback = 0,
|
||||
): Promise<number> {
|
||||
try {
|
||||
return await readMpvPositionSeconds(client);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheLastKnownPosition(
|
||||
playback: ActiveJellyfinRemotePlaybackState,
|
||||
positionSeconds: number,
|
||||
): void {
|
||||
if (!Number.isFinite(positionSeconds)) return;
|
||||
if (positionSeconds > 0 || playback.lastKnownPositionSeconds === undefined) {
|
||||
playback.lastKnownPositionSeconds = Math.max(0, positionSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReportablePositionSeconds(
|
||||
playback: ActiveJellyfinRemotePlaybackState,
|
||||
positionSeconds: number,
|
||||
): number {
|
||||
const normalizedPosition = normalizeMpvPositionSeconds(positionSeconds);
|
||||
if (normalizedPosition > 0) return normalizedPosition;
|
||||
const cachedPosition = playback.lastKnownPositionSeconds;
|
||||
if (typeof cachedPosition === 'number' && Number.isFinite(cachedPosition) && cachedPosition > 0) {
|
||||
return cachedPosition;
|
||||
}
|
||||
return normalizedPosition;
|
||||
}
|
||||
|
||||
function isSeekLikePositionJump(
|
||||
previousPositionSeconds: number | null,
|
||||
nextPositionSeconds: number,
|
||||
thresholdSeconds: number,
|
||||
): boolean {
|
||||
if (previousPositionSeconds === null) return false;
|
||||
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
|
||||
}
|
||||
|
||||
export type JellyfinRemoteProgressReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
@@ -47,29 +150,44 @@ export type JellyfinRemoteProgressReporterDeps = {
|
||||
export function createReportJellyfinRemoteProgressHandler(
|
||||
deps: JellyfinRemoteProgressReporterDeps,
|
||||
) {
|
||||
let lastReportedPositionSeconds: number | null = null;
|
||||
|
||||
return async (force = false): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
const session = deps.getSession();
|
||||
if (!session || !session.isConnected()) return;
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) return;
|
||||
const now = deps.getNow();
|
||||
if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
const position = await mpvClient?.requestProperty('time-pos');
|
||||
const paused = await mpvClient?.requestProperty('pause');
|
||||
const observedPositionSeconds = await readMpvPositionSeconds(mpvClient);
|
||||
cacheLastKnownPosition(playback, observedPositionSeconds);
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
const forceForSeekJump = isSeekLikePositionJump(
|
||||
lastReportedPositionSeconds,
|
||||
positionSeconds,
|
||||
Math.max(2, deps.progressIntervalMs / 1000),
|
||||
);
|
||||
if (
|
||||
!force &&
|
||||
!forceForSeekJump &&
|
||||
now - deps.getLastProgressAtMs() < deps.progressIntervalMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const paused = await mpvClient?.requestProperty?.('pause');
|
||||
await session.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
|
||||
isPaused: paused === true,
|
||||
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||
isPaused: isMpvPauseEnabled(paused),
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
eventName: 'timeupdate',
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
lastReportedPositionSeconds = positionSeconds;
|
||||
deps.setLastProgressAtMs(now);
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote progress', error);
|
||||
@@ -81,6 +199,9 @@ export type JellyfinRemoteStoppedReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
getSession: () => JellyfinRemoteSessionLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getNow?: () => number;
|
||||
ticksPerSecond: number;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -88,15 +209,46 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
return async (): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
if (playback.loadedMediaPath === null) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof playback.stopReportsAfterMs === 'number' &&
|
||||
Number.isFinite(playback.stopReportsAfterMs) &&
|
||||
(deps.getNow?.() ?? Date.now()) < playback.stopReportsAfterMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const session = deps.getSession();
|
||||
if (!session || !session.isConnected()) {
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond);
|
||||
try {
|
||||
await session.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
isPaused: false,
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
||||
}
|
||||
await session.reportStopped({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
failed: false,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
|
||||
<meta charset="utf-8" />
|
||||
<title>Jellyfin Setup</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
|
||||
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
|
||||
main { padding: 22px; }
|
||||
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
|
||||
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; }
|
||||
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
|
||||
button:disabled { cursor: wait; opacity: .68; }
|
||||
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
|
||||
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext1: #b8c0e0;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--ctp-overlay2: #939ab7;
|
||||
--ctp-overlay1: #8087a2;
|
||||
--ctp-overlay0: #6e738d;
|
||||
--ctp-surface1: #494d64;
|
||||
--ctp-surface0: #363a4f;
|
||||
--ctp-base: #24273a;
|
||||
--ctp-mantle: #1e2030;
|
||||
--ctp-crust: #181926;
|
||||
--line: rgba(110, 115, 141, 0.28);
|
||||
--line-soft: rgba(110, 115, 141, 0.14);
|
||||
--text: var(--ctp-text);
|
||||
--muted: var(--ctp-subtext0);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
html { background: var(--ctp-base); }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--ctp-base);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
main { padding: 32px 22px; max-width: 520px; margin: 0 auto; }
|
||||
h1 { margin: 0 0 6px; font-size: 20px; font-weight: 800; color: var(--ctp-text); letter-spacing: -0.01em; }
|
||||
p { margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||
label { display: block; margin: 14px 0 6px; font-size: 11px; font-weight: 800; color: var(--ctp-overlay2); text-transform: uppercase; letter-spacing: 0.1em; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||
}
|
||||
input::placeholder { color: var(--ctp-overlay0); }
|
||||
input:hover { border-color: rgba(138, 173, 244, 0.32); }
|
||||
input:focus {
|
||||
border-color: rgba(138, 173, 244, 0.65);
|
||||
background: rgba(24, 25, 38, 0.95);
|
||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.15);
|
||||
}
|
||||
button {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 60ms ease;
|
||||
}
|
||||
button:active { transform: translateY(1px); }
|
||||
button:disabled { cursor: wait; opacity: 0.7; }
|
||||
button.primary {
|
||||
border-color: transparent;
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
button.primary:hover:not(:disabled) { filter: brightness(1.06); }
|
||||
button.primary:disabled {
|
||||
background: rgba(54, 58, 79, 0.55);
|
||||
color: var(--ctp-overlay0);
|
||||
border-color: var(--line);
|
||||
}
|
||||
button.secondary {
|
||||
background: rgba(54, 58, 79, 0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
button.secondary:hover:not(:disabled) {
|
||||
border-color: rgba(138, 173, 244, 0.45);
|
||||
background: rgba(73, 77, 100, 0.6);
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
button.danger {
|
||||
background: rgba(237, 135, 150, 0.12);
|
||||
color: var(--ctp-red);
|
||||
border-color: rgba(237, 135, 150, 0.45);
|
||||
}
|
||||
button.danger:hover:not(:disabled) {
|
||||
background: rgba(237, 135, 150, 0.22);
|
||||
}
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
|
||||
.actions .primary { grid-column: 1 / -1; }
|
||||
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); }
|
||||
.status.success { color: var(--accent); }
|
||||
.status.error { color: var(--danger); }
|
||||
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); }
|
||||
.status {
|
||||
min-height: 18px;
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.status:empty { display: none; }
|
||||
.status.loading,
|
||||
.status.success,
|
||||
.status.error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--ctp-surface0);
|
||||
font-weight: 600;
|
||||
}
|
||||
.status.success {
|
||||
border-color: rgba(166, 218, 149, 0.45);
|
||||
background: rgba(166, 218, 149, 0.1);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
.status.error {
|
||||
border-color: rgba(237, 135, 150, 0.55);
|
||||
background: rgba(237, 135, 150, 0.1);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
font-size: 11.5px;
|
||||
color: var(--ctp-overlay2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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']);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle cache io awaits async temp cleanup when download fails', async () => {
|
||||
let removed = false;
|
||||
const cacheIo = createJellyfinSubtitleCacheIo({
|
||||
tmpDir: () => '/tmp',
|
||||
makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed',
|
||||
writeFile: async () => {},
|
||||
removeDir: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
removed = true;
|
||||
},
|
||||
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.equal(removed, true);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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 | Promise<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) {
|
||||
try {
|
||||
await Promise.resolve(deps.removeDir(cacheDir, { recursive: true, force: true }));
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
return { path: subtitlePath, cleanupDir: cacheDir };
|
||||
},
|
||||
cleanupCachedSubtitles(dirs: string[]): void {
|
||||
for (const dir of dirs) {
|
||||
void Promise.resolve(deps.removeDir(dir, { recursive: true, force: true })).catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,24 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
wait: async () => {
|
||||
calls.push('wait');
|
||||
},
|
||||
cacheSubtitleTrack: async () => {
|
||||
calls.push('cache');
|
||||
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
|
||||
},
|
||||
cleanupCachedSubtitles: () => calls.push('cleanup'),
|
||||
getSavedSubtitleDelay: (_itemId, streamIndex) => {
|
||||
calls.push(`load-delay:${streamIndex}`);
|
||||
return 1.25;
|
||||
},
|
||||
setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`),
|
||||
loadSubtitleSourceText: async (source) => {
|
||||
calls.push(`load-source:${source}`);
|
||||
return 'subtitle';
|
||||
},
|
||||
saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => {
|
||||
calls.push(`save-delay:${streamIndex}:${delaySeconds}`);
|
||||
return true;
|
||||
},
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
@@ -21,6 +39,23 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'auto']);
|
||||
await deps.wait(1);
|
||||
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
|
||||
deps.cleanupCachedSubtitles(['/tmp/subs']);
|
||||
assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25);
|
||||
deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 });
|
||||
assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle');
|
||||
assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true);
|
||||
deps.logDebug('oops', null);
|
||||
assert.deepEqual(calls, ['list', 'send', 'wait', 'debug:oops']);
|
||||
assert.deepEqual(calls, [
|
||||
'list',
|
||||
'send',
|
||||
'wait',
|
||||
'cache',
|
||||
'cleanup',
|
||||
'load-delay:3',
|
||||
'active-delay:3',
|
||||
'load-source:/tmp/sub.srt',
|
||||
'save-delay:3:-31.5',
|
||||
'debug:oops',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,21 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
sendMpvCommand: (command) => deps.sendMpvCommand(command),
|
||||
wait: (ms: number) => deps.wait(ms),
|
||||
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
|
||||
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
|
||||
getSavedSubtitleDelay: deps.getSavedSubtitleDelay
|
||||
? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex)
|
||||
: undefined,
|
||||
setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey
|
||||
? (key) => deps.setActiveSubtitleDelayKey!(key)
|
||||
: undefined,
|
||||
loadSubtitleSourceText: deps.loadSubtitleSourceText
|
||||
? (source) => deps.loadSubtitleSourceText!(source)
|
||||
: undefined,
|
||||
saveSubtitleDelay: deps.saveSubtitleDelay
|
||||
? (itemId, streamIndex, delaySeconds) =>
|
||||
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
|
||||
: undefined,
|
||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset';
|
||||
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
@@ -15,13 +18,53 @@ type JellyfinSubtitleTrack = {
|
||||
index: number;
|
||||
language?: string;
|
||||
title?: string;
|
||||
codec?: string;
|
||||
isDefault?: boolean;
|
||||
isForced?: boolean;
|
||||
isExternal?: boolean;
|
||||
deliveryMethod?: string;
|
||||
deliveryUrl?: string | null;
|
||||
};
|
||||
|
||||
type CachedSubtitleTrack = {
|
||||
path: string;
|
||||
cleanupDir: string;
|
||||
};
|
||||
|
||||
type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
|
||||
source: JellyfinSubtitleTrack;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayKey = {
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
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;
|
||||
itemId: string;
|
||||
}) => Promise<void>) & {
|
||||
cleanupCachedSubtitles: () => void;
|
||||
};
|
||||
|
||||
function normalizeLang(value: unknown): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
@@ -58,17 +101,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,
|
||||
@@ -81,6 +119,192 @@ 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) +
|
||||
(cached?.source.isDefault ? 35 : 0) +
|
||||
(cached?.source.isExternal === false ? 25 : 0) +
|
||||
(cached?.source.isExternal === true ? -10 : 0) +
|
||||
(cached?.source.isForced ? -25 : 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 findCachedTrackForMpvTrackId(
|
||||
tracks: MpvSubtitleTrack[],
|
||||
cachedTracks: CachedExternalSubtitleTrack[],
|
||||
trackId: number | null,
|
||||
): CachedExternalSubtitleTrack | null {
|
||||
if (trackId === null) return null;
|
||||
const mpvTrack = tracks.find((track) => track.id === trackId);
|
||||
if (!mpvTrack?.externalFilename) return null;
|
||||
return cachedTracks.find((track) => track.path === mpvTrack.externalFilename) ?? 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',
|
||||
)
|
||||
.map((track) => ({
|
||||
id: parseTrackId(track.id),
|
||||
lang: String(track.lang || ''),
|
||||
title: String(track.title || ''),
|
||||
external: track.external === true,
|
||||
externalFilename: String(track['external-filename'] || ''),
|
||||
}))
|
||||
.filter((track): track is MpvSubtitleTrack => track.id !== null)
|
||||
: [];
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric =
|
||||
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
async function readMpvSubtitleTracks(deps: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
}): Promise<MpvSubtitleTrack[] | null> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client || client.connected === false) {
|
||||
return null;
|
||||
}
|
||||
let trackListRaw: unknown;
|
||||
try {
|
||||
trackListRaw = await client.requestProperty('track-list');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
async function estimateSubtitleDelayFromReference(
|
||||
deps: {
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
},
|
||||
primaryTrack: CachedExternalSubtitleTrack | null,
|
||||
referenceTrack: CachedExternalSubtitleTrack | null,
|
||||
): Promise<number | null> {
|
||||
if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [primaryContent, referenceContent] = await Promise.all([
|
||||
deps.loadSubtitleSourceText(primaryTrack.path),
|
||||
deps.loadSubtitleSourceText(referenceTrack.path),
|
||||
]);
|
||||
const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path);
|
||||
const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path);
|
||||
return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null;
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveEstimatedSubtitleDelay(
|
||||
deps: {
|
||||
saveSubtitleDelay?: (
|
||||
itemId: string,
|
||||
streamIndex: number,
|
||||
delaySeconds: number,
|
||||
) => boolean | void;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
},
|
||||
key: JellyfinSubtitleDelayKey,
|
||||
delaySeconds: number,
|
||||
): void {
|
||||
try {
|
||||
const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds);
|
||||
if (saved === false) {
|
||||
deps.logDebug('Failed to save Jellyfin auto subtitle delay', key);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to save Jellyfin auto subtitle delay', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: JellyfinSession,
|
||||
@@ -90,14 +314,41 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
|
||||
cleanupCachedSubtitles: (dirs: string[]) => void;
|
||||
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
|
||||
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
const activeCacheDirs = new Set<string>();
|
||||
let preloadQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function resetManagedSubtitleDelay(): void {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||
}
|
||||
|
||||
function cleanupActiveCache(): void {
|
||||
const dirs = [...activeCacheDirs];
|
||||
if (dirs.length === 0) return;
|
||||
deps.cleanupCachedSubtitles(dirs);
|
||||
for (const dir of dirs) {
|
||||
activeCacheDirs.delete(dir);
|
||||
}
|
||||
}
|
||||
|
||||
const runPreload = async (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
try {
|
||||
cleanupActiveCache();
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to cleanup Jellyfin cached subtitles', error);
|
||||
}
|
||||
const tracks = await deps.listJellyfinSubtitleTracks(
|
||||
params.session,
|
||||
params.clientInfo,
|
||||
@@ -105,11 +356,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
);
|
||||
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
||||
if (externalTracks.length === 0) {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
|
||||
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;
|
||||
@@ -117,36 +375,80 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
seenUrls.add(track.deliveryUrl);
|
||||
const labelBase = (track.title || track.language || '').trim();
|
||||
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
||||
deps.sendMpvCommand(['sub-add', track.deliveryUrl, 'cached', label, track.language || '']);
|
||||
const cached = await deps.cacheSubtitleTrack(track);
|
||||
activeCacheDirs.add(cached.cleanupDir);
|
||||
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 resolvedSubtitleTracks = subtitleTracks ?? [];
|
||||
const japanesePrimaryId =
|
||||
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isJapanese) ??
|
||||
pickBestTrackId(resolvedSubtitleTracks, isJapanese);
|
||||
const englishSecondaryId =
|
||||
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isEnglish, japanesePrimaryId) ??
|
||||
pickBestTrackId(resolvedSubtitleTracks, isEnglish, japanesePrimaryId);
|
||||
if (japanesePrimaryId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
const selectedCachedTrack = findCachedTrackForMpvTrackId(
|
||||
resolvedSubtitleTracks,
|
||||
cachedTracks,
|
||||
japanesePrimaryId,
|
||||
);
|
||||
if (selectedCachedTrack) {
|
||||
const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index };
|
||||
deps.setActiveSubtitleDelayKey?.(delayKey);
|
||||
const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex);
|
||||
if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]);
|
||||
} else {
|
||||
const referenceCachedTrack = findCachedTrackForMpvTrackId(
|
||||
resolvedSubtitleTracks,
|
||||
cachedTracks,
|
||||
englishSecondaryId,
|
||||
);
|
||||
const estimatedDelay = await estimateSubtitleDelayFromReference(
|
||||
deps,
|
||||
selectedCachedTrack,
|
||||
referenceCachedTrack,
|
||||
);
|
||||
if (estimatedDelay !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]);
|
||||
saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay);
|
||||
} else {
|
||||
resetManagedSubtitleDelay();
|
||||
}
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
} else {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
}
|
||||
} else {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
}
|
||||
|
||||
const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId);
|
||||
if (englishSecondaryId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
|
||||
}
|
||||
@@ -154,4 +456,20 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
deps.logDebug('Failed to preload Jellyfin external subtitles', error);
|
||||
}
|
||||
};
|
||||
|
||||
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[] = [];
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -175,3 +175,57 @@ test('managed local subtitle selection keeps waiting for primary after early sec
|
||||
['set_property', 'sid', 3],
|
||||
]);
|
||||
});
|
||||
|
||||
test('managed local subtitle selection keeps pending refresh after early primary-only track list', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const scheduled = new Map<number, () => void>();
|
||||
let nextTimerId = 1;
|
||||
|
||||
const runtime = createManagedLocalSubtitleSelectionRuntime({
|
||||
getCurrentMediaPath: () => '/videos/example.mkv',
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
|
||||
{ type: 'sub', id: 4, lang: 'en', title: 'en.srt', external: true },
|
||||
];
|
||||
}
|
||||
throw new Error(`Unexpected property: ${name}`);
|
||||
},
|
||||
}) as never,
|
||||
getPrimarySubtitleLanguages: () => [],
|
||||
getSecondarySubtitleLanguages: () => [],
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
const timerId = nextTimerId++;
|
||||
scheduled.set(timerId, callback);
|
||||
return timerId as never;
|
||||
},
|
||||
clearScheduled: (timer) => {
|
||||
scheduled.delete(timer as never);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('/videos/example.mkv');
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
|
||||
]);
|
||||
|
||||
assert.deepEqual(commands, [['set_property', 'sid', 3]]);
|
||||
assert.equal(scheduled.size, 1);
|
||||
|
||||
const refresh = [...scheduled.values()][0];
|
||||
assert.ok(refresh);
|
||||
refresh();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'sid', 3],
|
||||
['set_property', 'secondary-sid', 4],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -212,12 +212,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const hasAppliedSelectionForCurrentMediaPath = (): boolean =>
|
||||
appliedPrimaryMediaPath === currentMediaPath && appliedSecondaryMediaPath === currentMediaPath;
|
||||
|
||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||
if (
|
||||
!currentMediaPath ||
|
||||
(appliedPrimaryMediaPath === currentMediaPath &&
|
||||
appliedSecondaryMediaPath === currentMediaPath)
|
||||
) {
|
||||
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||
return;
|
||||
}
|
||||
const selection = resolveManagedLocalSubtitleSelection({
|
||||
@@ -236,7 +235,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||
appliedSecondaryMediaPath = currentMediaPath;
|
||||
}
|
||||
if (appliedPrimaryMediaPath === currentMediaPath) {
|
||||
if (hasAppliedSelectionForCurrentMediaPath()) {
|
||||
clearPendingTimer();
|
||||
}
|
||||
};
|
||||
@@ -260,7 +259,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
|
||||
const scheduleRefresh = (): void => {
|
||||
clearPendingTimer();
|
||||
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
|
||||
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
bindEventHandlers: (client: TClient) => void;
|
||||
@@ -26,6 +27,9 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
getReconnectTimer: () => deps.getReconnectTimer(),
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
|
||||
deps.setReconnectTimer(timer),
|
||||
shouldAutoLoadSecondarySubTrack: deps.shouldAutoLoadSecondarySubTrack
|
||||
? (path: string) => deps.shouldAutoLoadSecondarySubTrack?.(path) ?? true
|
||||
: undefined,
|
||||
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||
requestAppQuit: () => deps.requestAppQuit?.(),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ export type MpvClientRuntimeServiceOptions = {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
};
|
||||
|
||||
@@ -14,10 +14,11 @@ test('apply jellyfin mpv defaults sends expected property commands', () => {
|
||||
|
||||
applyDefaults({ connected: true, send: () => {} });
|
||||
assert.deepEqual(calls, [
|
||||
'set_property:sub-auto:fuzzy',
|
||||
'set_property:sub-auto:no',
|
||||
'set_property:aid:auto',
|
||||
'set_property:sid:auto',
|
||||
'set_property:secondary-sid:auto',
|
||||
'set_property:sid:no',
|
||||
'set_property:secondary-sid:no',
|
||||
'set_property:sub-visibility:no',
|
||||
'set_property:secondary-sub-visibility:no',
|
||||
'set_property:alang:ja,jp',
|
||||
'set_property:slang:ja,jp',
|
||||
|
||||
@@ -6,10 +6,11 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: {
|
||||
jellyfinLangPref: string;
|
||||
}) {
|
||||
return (client: MpvRuntimeClientLike): void => {
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-visibility', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'alang', deps.jellyfinLangPref]);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'slang', deps.jellyfinLangPref]);
|
||||
|
||||
@@ -168,6 +168,28 @@ test('media path change handler signals autoplay readiness from warm media path'
|
||||
]);
|
||||
});
|
||||
|
||||
test('media path change handler marks Jellyfin remote playback loaded from media path', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
markJellyfinRemotePlaybackLoaded: (path) => calls.push(`jellyfin-loaded:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ path: 'https://stream.example/video.m3u8' });
|
||||
|
||||
assert.ok(calls.includes('jellyfin-loaded:https://stream.example/video.m3u8'));
|
||||
assert.equal(calls.includes('stopped'), false);
|
||||
});
|
||||
|
||||
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
|
||||
const calls: string[] = [];
|
||||
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
|
||||
@@ -222,6 +244,36 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('time-pos handler forces Jellyfin progress when mpv position jumps', () => {
|
||||
const calls: string[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
|
||||
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
});
|
||||
|
||||
timeHandler({ time: 10 });
|
||||
timeHandler({ time: 11 });
|
||||
timeHandler({ time: 90 });
|
||||
timeHandler({ time: 30 });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'time:10',
|
||||
'progress:normal',
|
||||
'presence',
|
||||
'time:11',
|
||||
'progress:normal',
|
||||
'presence',
|
||||
'time:90',
|
||||
'progress:force',
|
||||
'presence',
|
||||
'time:30',
|
||||
'progress:force',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
||||
const watchedSeconds: unknown[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
|
||||
@@ -4,6 +4,15 @@ type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
||||
|
||||
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
|
||||
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
|
||||
return false;
|
||||
}
|
||||
return Math.abs(nextTime - previousTime) >= SEEK_LIKE_TIME_DELTA_SECONDS;
|
||||
}
|
||||
|
||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
@@ -59,6 +68,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
syncImmersionMediaState: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
@@ -81,6 +91,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
}
|
||||
deps.syncImmersionMediaState();
|
||||
if (normalizedPath.trim().length > 0) {
|
||||
deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath);
|
||||
deps.scheduleCharacterDictionarySync?.();
|
||||
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
|
||||
}
|
||||
@@ -113,9 +124,15 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
}) {
|
||||
let lastObservedTime: number | null = null;
|
||||
|
||||
return ({ time }: { time: number }): void => {
|
||||
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
|
||||
if (Number.isFinite(time)) {
|
||||
lastObservedTime = time;
|
||||
}
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate);
|
||||
deps.refreshDiscordPresence();
|
||||
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||
|
||||
@@ -63,6 +63,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
@@ -142,6 +143,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
|
||||
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
|
||||
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||
markJellyfinRemotePlaybackLoaded: (path) => deps.markJellyfinRemotePlaybackLoaded?.(path),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
@@ -178,6 +179,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||
markJellyfinRemotePlaybackLoaded: (path: string) =>
|
||||
deps.markJellyfinRemotePlaybackLoaded?.(path),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
|
||||
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
||||
|
||||
@@ -63,7 +63,7 @@ test('overlay modal input state activates modal window interactivity and syncs d
|
||||
assert.deepEqual(modalWindow.calls, [
|
||||
'focusable:true',
|
||||
'ignore:false',
|
||||
'top:true:screen-saver:1',
|
||||
'top:true:screen-saver:3',
|
||||
'focus',
|
||||
'web-focus',
|
||||
]);
|
||||
|
||||
@@ -42,7 +42,7 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
setWindowFocusable(modalWindow);
|
||||
requestOverlayApplicationFocus();
|
||||
modalWindow.setIgnoreMouseEvents(false);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||
modalWindow.focus();
|
||||
if (!modalWindow.webContents.isFocused()) {
|
||||
modalWindow.webContents.focus();
|
||||
|
||||
@@ -10,6 +10,9 @@ export function createBuildSetVisibleOverlayVisibleMainDepsHandler(
|
||||
deps: SetVisibleOverlayVisibleMainDeps,
|
||||
) {
|
||||
return (): SetVisibleOverlayVisibleMainDeps => ({
|
||||
getVisibleOverlayVisible: deps.getVisibleOverlayVisible
|
||||
? () => deps.getVisibleOverlayVisible?.() ?? false
|
||||
: undefined,
|
||||
setVisibleOverlayVisibleCore: (options) => deps.setVisibleOverlayVisibleCore(options),
|
||||
setVisibleOverlayVisibleState: (visible: boolean) =>
|
||||
deps.setVisibleOverlayVisibleState(visible),
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
test('set visible overlay handler forwards dependencies to core', () => {
|
||||
const calls: string[] = [];
|
||||
let warmupStarts = 0;
|
||||
let currentVisible = false;
|
||||
const setVisible = createSetVisibleOverlayVisibleHandler({
|
||||
getVisibleOverlayVisible: () => currentVisible,
|
||||
setVisibleOverlayVisibleCore: (options) => {
|
||||
calls.push(`core:${options.visible}`);
|
||||
currentVisible = options.visible;
|
||||
options.setVisibleOverlayVisibleState(options.visible);
|
||||
options.updateVisibleOverlayVisibility();
|
||||
},
|
||||
@@ -25,6 +28,10 @@ test('set visible overlay handler forwards dependencies to core', () => {
|
||||
assert.deepEqual(calls, ['core:true', 'state:true', 'update-visible']);
|
||||
assert.equal(warmupStarts, 1);
|
||||
|
||||
setVisible(true);
|
||||
assert.deepEqual(calls, ['core:true', 'state:true', 'update-visible']);
|
||||
assert.equal(warmupStarts, 1);
|
||||
|
||||
setVisible(false);
|
||||
assert.equal(warmupStarts, 1);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export function createSetVisibleOverlayVisibleHandler(deps: {
|
||||
getVisibleOverlayVisible?: () => boolean;
|
||||
setVisibleOverlayVisibleCore: (options: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
@@ -9,6 +10,9 @@ export function createSetVisibleOverlayVisibleHandler(deps: {
|
||||
onVisibleOverlayEnabled?: () => void;
|
||||
}) {
|
||||
return (visible: boolean): void => {
|
||||
if (deps.getVisibleOverlayVisible?.() === visible) {
|
||||
return;
|
||||
}
|
||||
if (visible) {
|
||||
deps.onVisibleOverlayEnabled?.();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||
getWindowsOverlayProcessName: () => 'subminer',
|
||||
getWindowsFocusHandoffGraceActive: () => true,
|
||||
getMacOSForegroundProbeActive: () => true,
|
||||
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown) => {
|
||||
trackerNotReadyWarningShown = shown;
|
||||
@@ -47,6 +48,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
||||
assert.equal(deps.getMacOSForegroundProbeActive?.(), true);
|
||||
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
||||
deps.setTrackerNotReadyWarningShown(true);
|
||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
|
||||
@@ -17,6 +17,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
|
||||
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||
getMacOSForegroundProbeActive: () => deps.getMacOSForegroundProbeActive?.() ?? false,
|
||||
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
|
||||
@@ -27,6 +27,8 @@ test('overlay visibility runtime wires set/toggle handlers through composed deps
|
||||
|
||||
runtime.setVisibleOverlayVisible(true);
|
||||
assert.equal(visible, true);
|
||||
runtime.setVisibleOverlayVisible(true);
|
||||
assert.equal(setVisibleCoreCalls, 1);
|
||||
|
||||
runtime.toggleVisibleOverlay();
|
||||
assert.equal(visible, false);
|
||||
|
||||
@@ -22,9 +22,10 @@ export type OverlayVisibilityRuntimeDeps = {
|
||||
};
|
||||
|
||||
export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDeps) {
|
||||
const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler(
|
||||
deps.setVisibleOverlayVisibleDeps,
|
||||
)();
|
||||
const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler({
|
||||
...deps.setVisibleOverlayVisibleDeps,
|
||||
getVisibleOverlayVisible: deps.getVisibleOverlayVisible,
|
||||
})();
|
||||
const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler(
|
||||
setVisibleOverlayVisibleMainDeps,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { openSubsyncManualModal } from './subsync-open';
|
||||
import type { SubsyncManualPayload } from '../../types';
|
||||
|
||||
const payload: SubsyncManualPayload = {
|
||||
ffsubsyncAvailable: true,
|
||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||
};
|
||||
|
||||
|
||||
@@ -100,3 +100,33 @@ test('subtitle prefetch runtime preserves parsed cues when YouTube active track
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -28,6 +28,57 @@ test('update dialog presenter focuses app and yields the run loop before showing
|
||||
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter suspends stats window layer while showing dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
withStatsWindowLayerSuspended: async (showDialog) => {
|
||||
calls.push('suspend-stats-window');
|
||||
try {
|
||||
return await showDialog();
|
||||
} finally {
|
||||
calls.push('restore-stats-window');
|
||||
}
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'suspend-stats-window',
|
||||
'dialog:SubMiner is up to date (v0.14.0)',
|
||||
'restore-stats-window',
|
||||
]);
|
||||
});
|
||||
|
||||
test('update dialog presenter restores stats window layer when dialog fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
withStatsWindowLayerSuspended: async (showDialog) => {
|
||||
calls.push('suspend-stats-window');
|
||||
try {
|
||||
return await showDialog();
|
||||
} finally {
|
||||
calls.push('restore-stats-window');
|
||||
}
|
||||
},
|
||||
showMessageBox: async () => {
|
||||
calls.push('dialog');
|
||||
throw new Error('dialog failed');
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(() => presenter.showNoUpdateDialog('0.14.0'), /dialog failed/);
|
||||
|
||||
assert.deepEqual(calls, ['suspend-stats-window', 'dialog', 'restore-stats-window']);
|
||||
});
|
||||
|
||||
test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface UpdateDialogPresenterDeps {
|
||||
showMessageBox: ShowMessageBox;
|
||||
focusApp?: () => void | Promise<void>;
|
||||
yieldToRunLoop?: () => Promise<void>;
|
||||
withStatsWindowLayerSuspended?: <T>(showDialog: () => Promise<T>) => Promise<T>;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
@@ -46,12 +47,18 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<
|
||||
|
||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||
try {
|
||||
await maybeFocusAppForDialog(deps);
|
||||
} catch {
|
||||
// Best-effort focus only; never block the dialog itself.
|
||||
}
|
||||
return deps.showMessageBox(options);
|
||||
const showDialog = async (): Promise<MessageBoxResultLike> => {
|
||||
try {
|
||||
await maybeFocusAppForDialog(deps);
|
||||
} catch {
|
||||
// Best-effort focus only; never block the dialog itself.
|
||||
}
|
||||
return deps.showMessageBox(options);
|
||||
};
|
||||
|
||||
return deps.withStatsWindowLayerSuspended
|
||||
? deps.withStatsWindowLayerSuspended(showDialog)
|
||||
: showDialog();
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -361,3 +361,34 @@ test('manual prerelease update check uses prerelease release and launcher channe
|
||||
'restart-dialog',
|
||||
]);
|
||||
});
|
||||
|
||||
test('manual update check keeps current prerelease builds on configured stable channel', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
getCurrentVersion: () => '0.15.0-beta.3',
|
||||
checkAppUpdate: async (channel) => {
|
||||
calls.push(`app:${channel}`);
|
||||
return { available: false, version: '0.15.0-beta.3' };
|
||||
},
|
||||
fetchLatestStableRelease: async (channel) => {
|
||||
calls.push(`fetch:${channel}`);
|
||||
return {
|
||||
tag_name: 'v0.14.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
},
|
||||
showNoUpdateDialog: async (version) => {
|
||||
calls.push(`no-update:${version}`);
|
||||
},
|
||||
showUpdateAvailableDialog: async () => {
|
||||
throw new Error('unexpected update dialog');
|
||||
},
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'up-to-date');
|
||||
assert.deepEqual(calls, ['app:stable', 'fetch:stable', 'no-update:0.15.0-beta.3']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user