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
This commit is contained in:
2026-05-22 23:01:08 -07:00
parent 27e3d956c9
commit 49a94579b6
16 changed files with 419 additions and 30 deletions
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, and startup path changes.
+38
View File
@@ -289,6 +289,44 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
});
test('timeline payload omits websocket-only event names', () => {
const payload = buildJellyfinTimelinePayload({
itemId: 'movie-2',
positionTicks: 123456,
eventName: 'TimeUpdate',
});
assert.equal('EventName' in payload, false);
});
test('reportStopped posts final position and explicit non-failed state', async () => {
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stop-payload',
deviceId: 'device-stop-payload',
webSocketFactory: () => new FakeWebSocket() as unknown as any,
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
const ok = await service.reportStopped({
itemId: 'movie-stop',
positionTicks: 7654321,
failed: false,
});
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
assert.equal(ok, true);
assert.ok(stoppedCall);
assert.ok(typeof stoppedCall.init.body === 'string');
const posted = JSON.parse(String(stoppedCall.init.body));
assert.equal(posted.PositionTicks, 7654321);
assert.equal(posted.Failed, false);
});
test('advertiseNow validates server registration using Sessions endpoint', async () => {
const sockets: FakeWebSocket[] = [];
const calls: string[] = [];
+5 -7
View File
@@ -20,6 +20,7 @@ export interface JellyfinTimelinePlaybackState {
subtitleStreamIndex?: number | null;
playlistItemId?: string | null;
eventName?: string;
failed?: boolean;
}
export interface JellyfinTimelinePayload {
@@ -36,7 +37,7 @@ export interface JellyfinTimelinePayload {
AudioStreamIndex?: number | null;
SubtitleStreamIndex?: number | null;
PlaylistItemId?: string | null;
EventName: string;
Failed?: boolean;
}
interface JellyfinRemoteSocket {
@@ -168,7 +169,7 @@ export function buildJellyfinTimelinePayload(
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
PlaylistItemId: state.playlistItemId,
EventName: state.eventName || 'timeupdate',
Failed: state.failed,
};
}
@@ -269,10 +270,7 @@ export class JellyfinRemoteSessionService {
}
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing', {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'start',
});
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
}
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
@@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService {
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing/Stopped', {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'stop',
Failed: state.failed === true,
});
}
+11 -1
View File
@@ -654,6 +654,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false;
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000;
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
@@ -2968,7 +2969,11 @@ const {
},
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
setActivePlayback: (state) => {
activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState;
activeJellyfinRemotePlayback = {
...(state as ActiveJellyfinRemotePlaybackState),
stopReportsAfterMs:
state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS,
};
},
setLastProgressAtMs: (value) => {
jellyfinRemoteLastProgressAtMs = value;
@@ -4458,6 +4463,11 @@ const {
immersionMediaRuntime.syncFromCurrentMediaState();
},
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
markJellyfinRemotePlaybackLoaded: (path) => {
if (activeJellyfinRemotePlayback) {
activeJellyfinRemotePlayback.loadedMediaPath = path;
}
},
scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
return;
@@ -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(
@@ -121,6 +121,8 @@ test('playback handler drives mpv commands and playback state', async () => {
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
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',
@@ -180,6 +182,47 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
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 () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
+16 -9
View File
@@ -14,6 +14,8 @@ type ActivePlaybackState = {
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
playMethod: 'DirectPlay' | 'Transcode';
loadedMediaPath?: string | null;
stopReportsAfterMs?: number;
};
export type JellyfinPlaybackStatsMetadata = {
@@ -69,6 +71,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
itemId: string;
mediaSourceId: undefined;
playMethod: 'DirectPlay' | 'Transcode';
positionTicks?: number;
isPaused?: boolean;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'start';
@@ -107,6 +111,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
try {
deps.updateCurrentMediaTitle?.(plan.title);
deps.recordJellyfinPlaybackMetadata?.({
@@ -121,6 +126,15 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
} catch {
// Best-effort metadata/title hooks must not block playback startup.
}
deps.setActivePlayback({
itemId: params.itemId,
mediaSourceId: undefined,
audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex,
playMethod,
loadedMediaPath: null,
});
deps.setLastProgressAtMs(0);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
@@ -143,19 +157,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
itemId: params.itemId,
});
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
deps.setActivePlayback({
itemId: params.itemId,
mediaSourceId: undefined,
audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex,
playMethod,
});
deps.setLastProgressAtMs(0);
deps.reportPlaying({
itemId: params.itemId,
mediaSourceId: undefined,
playMethod,
positionTicks: startTimeTicks,
isPaused: false,
audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex,
eventName: 'start',
@@ -4,6 +4,8 @@ export type ActiveJellyfinRemotePlaybackState = {
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
playMethod: 'DirectPlay' | 'Transcode';
loadedMediaPath?: string | null;
stopReportsAfterMs?: number;
};
type JellyfinSession = {
@@ -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']);
});
@@ -71,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),
});
}
@@ -123,9 +123,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',
@@ -141,13 +193,96 @@ 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 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, false);
});
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);
});
+77 -9
View File
@@ -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 {
@@ -44,6 +47,45 @@ function isMpvPauseEnabled(value: unknown): boolean {
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 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;
@@ -60,29 +102,41 @@ 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;
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 positionSeconds = await readMpvPositionSeconds(mpvClient);
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),
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);
@@ -94,6 +148,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;
};
@@ -101,15 +158,26 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
return async (): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
if (playback.loadedMediaPath === null) 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()) {
deps.clearActivePlayback();
return;
}
try {
const positionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
await session.reportStopped({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
failed: false,
playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex,
@@ -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({
+18 -1
View File
@@ -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(),