diff --git a/changes/fix-jellyfin-remote-progress-sync.md b/changes/fix-jellyfin-remote-progress-sync.md new file mode 100644 index 00000000..143c8e39 --- /dev/null +++ b/changes/fix-jellyfin-remote-progress-sync.md @@ -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. diff --git a/src/core/services/jellyfin-remote.test.ts b/src/core/services/jellyfin-remote.test.ts index a8899933..22b2e6b6 100644 --- a/src/core/services/jellyfin-remote.test.ts +++ b/src/core/services/jellyfin-remote.test.ts @@ -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[] = []; diff --git a/src/core/services/jellyfin-remote.ts b/src/core/services/jellyfin-remote.ts index 8f720508..ef0c301b 100644 --- a/src/core/services/jellyfin-remote.ts +++ b/src/core/services/jellyfin-remote.ts @@ -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 { - return this.postTimeline('/Sessions/Playing', { - ...buildJellyfinTimelinePayload(state), - EventName: state.eventName || 'start', - }); + return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state)); } public async reportProgress(state: JellyfinTimelinePlaybackState): Promise { @@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService { public async reportStopped(state: JellyfinTimelinePlaybackState): Promise { return this.postTimeline('/Sessions/Playing/Stopped', { ...buildJellyfinTimelinePayload(state), - EventName: state.eventName || 'stop', + Failed: state.failed === true, }); } diff --git a/src/main.ts b/src/main.ts index c4656afe..17f2d49b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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; diff --git a/src/main/runtime/composers/jellyfin-remote-composer.ts b/src/main/runtime/composers/jellyfin-remote-composer.ts index 75b68376..2de85547 100644 --- a/src/main/runtime/composers/jellyfin-remote-composer.ts +++ b/src/main/runtime/composers/jellyfin-remote-composer.ts @@ -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( diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index 3529ed72..c78e8d57 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -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> = []; const handler = createPlayJellyfinItemInMpvHandler({ diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index 0ab8f50b..bc68471b 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -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', diff --git a/src/main/runtime/jellyfin-remote-commands.ts b/src/main/runtime/jellyfin-remote-commands.ts index 650e2184..09889876 100644 --- a/src/main/runtime/jellyfin-remote-commands.ts +++ b/src/main/runtime/jellyfin-remote-commands.ts @@ -4,6 +4,8 @@ export type ActiveJellyfinRemotePlaybackState = { audioStreamIndex?: number | null; subtitleStreamIndex?: number | null; playMethod: 'DirectPlay' | 'Transcode'; + loadedMediaPath?: string | null; + stopReportsAfterMs?: number; }; type JellyfinSession = { diff --git a/src/main/runtime/jellyfin-remote-main-deps.test.ts b/src/main/runtime/jellyfin-remote-main-deps.test.ts index e8c6730f..b7178795 100644 --- a/src/main/runtime/jellyfin-remote-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-main-deps.test.ts @@ -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']); }); diff --git a/src/main/runtime/jellyfin-remote-main-deps.ts b/src/main/runtime/jellyfin-remote-main-deps.ts index 8de2bd80..d5b4dd20 100644 --- a/src/main/runtime/jellyfin-remote-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-main-deps.ts @@ -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), }); } diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts index 742f92d5..58c91c0a 100644 --- a/src/main/runtime/jellyfin-remote-playback.test.ts +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -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); +}); diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index 6d54af8c..6e24b31e 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -10,11 +10,13 @@ type JellyfinRemoteSessionLike = { playMethod: 'DirectPlay' | 'Transcode'; audioStreamIndex?: number | null; subtitleStreamIndex?: number | null; - eventName: 'timeupdate'; + eventName: 'TimeUpdate'; }) => Promise; 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; + currentTimePos?: number; + requestProperty?: (name: string) => Promise; }; 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 { + 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 { + 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 => { 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 => { 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, diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index 9066bedf..727a00c2 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -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[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({ diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index d255cf12..dca7a765 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -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); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 65c59fb5..b3ddef03 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -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(), }); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 6515025f..5219e300 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -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(),