diff --git a/changes/fix-jellyfin-modern-auth.md b/changes/fix-jellyfin-modern-auth.md new file mode 100644 index 00000000..b2de29b2 --- /dev/null +++ b/changes/fix-jellyfin-modern-auth.md @@ -0,0 +1,8 @@ +type: fixed +area: jellyfin + +- Authenticate Jellyfin playback, subtitle, artwork, and remote-control socket URLs with the `ApiKey` query parameter and stop sending the legacy `X-Emby-Token` and `X-Emby-Authorization` headers, so the integration keeps working on Jellyfin 12 where legacy authorization is disabled by default. +- Keep the cast-target websocket alive by answering Jellyfin keep-alive requests and reconnect when the server stops replying, so "Play on SubMiner" keeps working on Jellyfin 12 instead of silently dying about a minute after connecting. Failed playback progress and stop reports are now logged as warnings. + +- Send the playback stop report only after any in-flight progress report has finished and stop reporting progress the moment playback ends, so the Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode. +- Anki cards mined from Jellyfin playback get the episode title in the misc info field again instead of "Unknown media"; the title mpv was given before loading was being discarded when the stream path changed. diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 2d7704fd..b2f90e5a 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -12,7 +12,7 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio ## Requirements -- A Jellyfin server plus your username and password +- A Jellyfin server plus your username and password (Jellyfin 12, which disables legacy authorization by default, is supported) - SubMiner installed and running (see [Installation](/installation)) - On Linux, the session token is stored with `gnome-libsecret` by default diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts index 742e4ffb..15cc1b26 100644 --- a/launcher/jellyfin.ts +++ b/launcher/jellyfin.ts @@ -89,7 +89,6 @@ export async function jellyfinApiRequest( const url = `${session.serverUrl}${requestPath}`; const response = await fetch(url, { headers: { - 'X-Emby-Token': session.accessToken, Authorization: `MediaBrowser Token="${session.accessToken}"`, }, }); @@ -103,7 +102,7 @@ export async function jellyfinApiRequest( } function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string { - return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`; + return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&ApiKey=${encodeURIComponent(session.accessToken)}`; } function jellyfinIconCacheDir(session: JellyfinSessionConfig): string { diff --git a/launcher/picker.ts b/launcher/picker.ts index 110a791a..98947048 100644 --- a/launcher/picker.ts +++ b/launcher/picker.ts @@ -228,7 +228,7 @@ export function pickLibrary( commandExists('chafa') && commandExists('curl') ? ` id={1} -url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} +url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)} curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null `.trim() : 'echo "Install curl + chafa for image preview"'; @@ -266,7 +266,7 @@ export function pickItem( commandExists('chafa') && commandExists('curl') ? ` id={1} -url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} +url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)} curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null `.trim() : 'echo "Install curl + chafa for image preview"'; @@ -304,7 +304,7 @@ export function pickGroup( commandExists('chafa') && commandExists('curl') ? ` id={1} -url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} +url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)} curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null `.trim() : 'echo "Install curl + chafa for image preview"'; diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 897e3897..4370446d 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -1545,3 +1545,25 @@ test('Anki metadata rejects a credential-bearing media title before metadata arr const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426); assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)'); }); + +test('AnkiIntegration.formatMiscInfoPattern treats ApiKey stream paths like legacy api_key ones', () => { + const integration = new AnkiIntegration( + { metadata: { pattern: '[SubMiner] %f (%t)' } } as never, + {} as never, + { + currentSubText: '', + currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1', + currentTimePos: 426, + currentSubStart: 426, + currentSubEnd: 428, + currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02', + send: () => true, + } as unknown as never, + ); + const privateApi = integration as unknown as { + formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + }; + const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426); + assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)'); + assert.equal(result.includes('ApiKey='), false); +}); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index b2efb88c..eb1a3bd3 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -185,7 +185,7 @@ function extractFilenameFromMediaPath(rawPath: string): string { function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean { const loweredPath = rawPath.toLowerCase(); const loweredFilename = filename.toLowerCase(); - if (loweredPath.includes('api_key=')) { + if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) { return true; } if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) { diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 6e21246d..0e30740f 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -3174,6 +3174,7 @@ test('Jellyfin metadata cleanup requires both an API key and a stream marker', a { filename: 'stream?api_key=secret', leaked: true }, { filename: '/STREAM?API_KEY=secret', leaked: true }, { filename: '/Videos/item?api_key=secret', leaked: true }, + { filename: '/Videos/item?ApiKey=secret', leaked: true }, { filename: 'MediaSourceId=item api key secret', leaked: true }, { filename: 'An API Key Story', leaked: false }, { filename: 'api_key=ordinary-metadata', leaked: false }, diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 8a286bc3..98f44b0b 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -376,6 +376,7 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [ 'api_key', + 'ApiKey', 'StartTimeTicks', 'AudioStreamIndex', 'SubtitleStreamIndex', diff --git a/src/core/services/immersion-tracker/jellyfin-link-repair.ts b/src/core/services/immersion-tracker/jellyfin-link-repair.ts index 4b499734..95d341c4 100644 --- a/src/core/services/immersion-tracker/jellyfin-link-repair.ts +++ b/src/core/services/immersion-tracker/jellyfin-link-repair.ts @@ -82,7 +82,7 @@ function parseLegacyJellyfinStreamUrl(value: string | null): URL | null { ) { return null; } - if (!url.searchParams.has('api_key')) { + if (!url.searchParams.has('api_key') && !url.searchParams.has('ApiKey')) { return null; } return url; @@ -130,13 +130,13 @@ function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: str SELECT v.canonical_title FROM imm_videos v WHERE v.anime_id = a.anime_id - AND v.canonical_title NOT LIKE '%api_key=%' + AND v.canonical_title NOT LIKE '%api_key=%' AND v.canonical_title NOT LIKE '%ApiKey=%' AND lower(v.canonical_title) NOT LIKE '%api key%' ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC LIMIT 1 ) AS linked_video_title FROM imm_anime a - WHERE a.canonical_title LIKE '%api_key=%' + WHERE a.canonical_title LIKE '%api_key=%' OR a.canonical_title LIKE '%ApiKey=%' OR lower(a.canonical_title) LIKE '%api key%' OR lower(a.normalized_title_key) LIKE '%api key%' `, @@ -244,11 +244,11 @@ function repairLeakedJellyfinVideoParseMetadata( LAST_UPDATE_DATE = ? WHERE source_type = 2 AND ( - parsed_basename LIKE '%api_key=%' + parsed_basename LIKE '%api_key=%' OR parsed_basename LIKE '%ApiKey=%' OR lower(parsed_basename) LIKE '%api key%' - OR parsed_title LIKE '%api_key=%' + OR parsed_title LIKE '%api_key=%' OR parsed_title LIKE '%ApiKey=%' OR lower(parsed_title) LIKE '%api key%' - OR parse_metadata_json LIKE '%api_key=%' + OR parse_metadata_json LIKE '%api_key=%' OR parse_metadata_json LIKE '%ApiKey=%' OR lower(parse_metadata_json) LIKE '%api key%' ) `, @@ -267,7 +267,7 @@ function repairLeakedJellyfinAnimeParseMetadata( UPDATE imm_anime SET metadata_json = NULL, LAST_UPDATE_DATE = ? WHERE ( - metadata_json LIKE '%api_key=%' + metadata_json LIKE '%api_key=%' OR metadata_json LIKE '%ApiKey=%' OR lower(metadata_json) LIKE '%api key%' ) AND ( lower(metadata_json) LIKE '%stream?%' @@ -295,11 +295,11 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe FROM imm_videos WHERE source_type = 2 AND ( - video_key LIKE '%api_key=%' + video_key LIKE '%api_key=%' OR video_key LIKE '%ApiKey=%' OR lower(video_key) LIKE '%api key%' - OR source_url LIKE '%api_key=%' + OR source_url LIKE '%api_key=%' OR source_url LIKE '%ApiKey=%' OR lower(source_url) LIKE '%api key%' - OR canonical_title LIKE '%api_key=%' + OR canonical_title LIKE '%api_key=%' OR canonical_title LIKE '%ApiKey=%' OR lower(canonical_title) LIKE '%api key%' ) `, diff --git a/src/core/services/jellyfin-remote.test.ts b/src/core/services/jellyfin-remote.test.ts index 22b2e6b6..e3433976 100644 --- a/src/core/services/jellyfin-remote.test.ts +++ b/src/core/services/jellyfin-remote.test.ts @@ -4,6 +4,17 @@ import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './je class FakeWebSocket { private listeners: Record void>> = {}; + sent: string[] = []; + terminated = false; + + send(data: string): void { + this.sent.push(data); + } + + terminate(): void { + this.terminated = true; + this.emit('close'); + } on(event: string, listener: (...args: unknown[]) => void): this { if (!this.listeners[event]) { @@ -58,7 +69,7 @@ test('start posts capabilities on socket connect', async () => { accessToken: 'token-1', deviceId: 'device-1', webSocketFactory: (url) => { - assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1'); + assert.equal(url, 'ws://jellyfin.local:8096/socket?ApiKey=token-1&deviceId=device-1'); const socket = new FakeWebSocket(); sockets.push(socket); return socket as unknown as any; @@ -99,7 +110,8 @@ test('socket headers include jellyfin authorization metadata', () => { assert.equal(seenHeaders.length, 1); assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"')); assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"')); - assert.ok(seenHeaders[0]!['X-Emby-Authorization']); + assert.equal('X-Emby-Authorization' in seenHeaders[0]!, false); + assert.equal('X-Emby-Token' in seenHeaders[0]!, false); }); test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => { @@ -355,3 +367,149 @@ test('advertiseNow validates server registration using Sessions endpoint', async assert.equal(ok, true); assert.ok(calls.some((url) => url.endsWith('/Sessions'))); }); + +test('answers ForceKeepAlive with KeepAlive messages on the advertised cadence', () => { + const sockets: FakeWebSocket[] = []; + const timers: Array<{ handler: () => void; delay: number }> = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-ka', + deviceId: 'device-ka', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch, + setTimer: ((handler: () => void, delay?: number) => { + timers.push({ handler, delay: Number(delay) }); + return timers.length as unknown as ReturnType; + }) as typeof setTimeout, + clearTimer: (() => undefined) as typeof clearTimeout, + }); + + service.start(); + sockets[0]!.emit('open'); + assert.deepEqual(sockets[0]!.sent, ['{"MessageType":"KeepAlive"}']); + assert.equal(timers[0]!.delay, 30_000); + + sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 20 })); + assert.equal(sockets[0]!.sent.length, 2); + assert.equal(timers.at(-1)!.delay, 10_000); + + timers.at(-1)!.handler(); + assert.equal(sockets[0]!.sent.length, 3); +}); + +test('reconnects when the server stops answering keep-alives', () => { + let now = 1_000_000; + const sockets: FakeWebSocket[] = []; + const timers: Array<() => void> = []; + const warnings: string[] = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-lost', + deviceId: 'device-lost', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch, + getNow: () => now, + logWarn: (message) => { + warnings.push(message); + }, + reconnectBaseDelayMs: 100, + setTimer: ((handler: () => void) => { + timers.push(handler); + return timers.length as unknown as ReturnType; + }) as typeof setTimeout, + clearTimer: (() => undefined) as typeof clearTimeout, + }); + + service.start(); + sockets[0]!.emit('open'); + + // Two silent ticks are still within the 90s tolerance; the third marks the socket lost. + now += 30_000; + timers.shift()!(); + now += 30_000; + timers.shift()!(); + assert.equal(sockets[0]!.sent.length, 3); + assert.equal(sockets[0]!.terminated, false); + + now += 30_000; + timers.shift()!(); + assert.equal(sockets[0]!.terminated, true); + assert.equal(service.isConnected(), false); + assert.equal(warnings.length, 1); + + timers.shift()!(); + assert.equal(sockets.length, 2); +}); + +test('warns once per failing timeline endpoint until it recovers', async () => { + const warnings: string[] = []; + let status = 400; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-warn', + deviceId: 'device-warn', + webSocketFactory: () => new FakeWebSocket() as unknown as any, + fetchImpl: (async () => new Response(null, { status })) as typeof fetch, + logWarn: (message) => { + warnings.push(message); + }, + }); + const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' }; + + assert.equal(await service.reportStopped(state), false); + assert.equal(await service.reportStopped(state), false); + assert.equal(warnings.length, 1); + assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/); + + status = 200; + assert.equal(await service.reportStopped(state), true); + status = 500; + assert.equal(await service.reportStopped(state), false); + assert.equal(warnings.length, 2); +}); + +test('ignores messages from a superseded socket', () => { + const sockets: FakeWebSocket[] = []; + const playPayloads: unknown[] = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-stale', + deviceId: 'device-stale', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch, + onPlay: (payload) => { + playPayloads.push(payload); + }, + setTimer: (() => 1 as unknown as ReturnType) as unknown as typeof setTimeout, + clearTimer: (() => undefined) as typeof clearTimeout, + }); + + service.start(); + service.stop(); + service.start(); + sockets[1]!.emit('open'); + assert.equal(sockets.length, 2); + + sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 10 })); + sockets[0]!.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemIds: ['x'] } })); + + assert.deepEqual(sockets[0]!.sent, []); + assert.deepEqual(playPayloads, []); + assert.deepEqual(sockets[1]!.sent, ['{"MessageType":"KeepAlive"}']); +}); diff --git a/src/core/services/jellyfin-remote.ts b/src/core/services/jellyfin-remote.ts index ef0c301b..649f5a48 100644 --- a/src/core/services/jellyfin-remote.ts +++ b/src/core/services/jellyfin-remote.ts @@ -45,9 +45,22 @@ interface JellyfinRemoteSocket { on(event: 'close', listener: () => void): this; on(event: 'error', listener: (error: Error) => void): this; on(event: 'message', listener: (data: unknown) => void): this; + send(data: string): void; + terminate?(): void; close(): void; } +// Jellyfin advertises its keep-alive timeout in the ForceKeepAlive message (60s by default), +// drops sockets that stay silent past it, and since 12.0 also detaches the session's remote +// controller when that happens. The drop never reaches the client as a close frame, so the +// client has to keep sending KeepAlive and treat missing replies as a dead connection. +const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 60_000; +const KEEP_ALIVE_LOST_FACTOR = 1.5; + +function unrefTimer(timer: ReturnType): void { + (timer as unknown as { unref?: () => void }).unref?.(); +} + type JellyfinRemoteSocketHeaders = Record; export interface JellyfinRemoteSessionServiceOptions { @@ -77,6 +90,9 @@ export interface JellyfinRemoteSessionServiceOptions { deviceName?: string; onConnected?: () => void; onDisconnected?: () => void; + logWarn?: (message: string, details?: unknown) => void; + keepAliveTimeoutMs?: number; + getNow?: () => number; } function normalizeServerUrl(serverUrl: string): string { @@ -196,6 +212,12 @@ export class JellyfinRemoteSessionService { private readonly authHeader: string; private readonly onConnected?: () => void; private readonly onDisconnected?: () => void; + private readonly logWarn?: (message: string, details?: unknown) => void; + private readonly now: () => number; + private keepAliveTimeoutMs: number; + private keepAliveTimer: ReturnType | null = null; + private lastInboundAtMs = 0; + private readonly failedRequestPaths = new Set(); private readonly reconnectBaseDelayMs: number; private readonly reconnectMaxDelayMs: number; @@ -233,6 +255,12 @@ export class JellyfinRemoteSessionService { }); this.onConnected = options.onConnected; this.onDisconnected = options.onDisconnected; + this.logWarn = options.logWarn; + this.now = options.getNow ?? Date.now; + this.keepAliveTimeoutMs = Math.max( + 1000, + options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS, + ); this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500); this.reconnectMaxDelayMs = Math.max( this.reconnectBaseDelayMs, @@ -250,6 +278,7 @@ export class JellyfinRemoteSessionService { public stop(): void { this.running = false; this.connected = false; + this.stopKeepAlive(); if (this.reconnectTimer) { this.clearTimer(this.reconnectTimer); this.reconnectTimer = null; @@ -298,12 +327,16 @@ export class JellyfinRemoteSessionService { if (this.socket !== socket || !this.running) return; this.connected = true; this.reconnectAttempt = 0; + this.lastInboundAtMs = this.now(); + this.startKeepAlive(socket, this.keepAliveTimeoutMs); this.onConnected?.(); void this.postCapabilities(); }); socket.on('message', (rawData) => { - this.handleInboundMessage(rawData); + if (this.socket !== socket || !this.running) return; + this.lastInboundAtMs = this.now(); + this.handleInboundMessage(socket, rawData); }); const handleDisconnect = () => { @@ -311,6 +344,7 @@ export class JellyfinRemoteSessionService { disconnected = true; if (this.socket === socket) { this.socket = null; + this.stopKeepAlive(); } this.connected = false; this.onDisconnected?.(); @@ -323,6 +357,51 @@ export class JellyfinRemoteSessionService { socket.on('error', handleDisconnect); } + private startKeepAlive(socket: JellyfinRemoteSocket, timeoutMs: number): void { + this.stopKeepAlive(); + this.keepAliveTimeoutMs = timeoutMs; + this.sendKeepAlive(socket); + this.scheduleKeepAliveTick(socket); + } + + private scheduleKeepAliveTick(socket: JellyfinRemoteSocket): void { + const intervalMs = Math.max(1000, Math.floor(this.keepAliveTimeoutMs / 2)); + const timer = this.setTimer(() => { + this.keepAliveTimer = null; + if (this.socket !== socket || !this.running) return; + const silentForMs = this.now() - this.lastInboundAtMs; + if (silentForMs >= this.keepAliveTimeoutMs * KEEP_ALIVE_LOST_FACTOR) { + this.logWarn?.('Jellyfin remote websocket stopped answering keep-alives; reconnecting.'); + // Dropping the socket raises 'close', which schedules the reconnect. + if (socket.terminate) { + socket.terminate(); + } else { + socket.close(); + } + return; + } + this.sendKeepAlive(socket); + this.scheduleKeepAliveTick(socket); + }, intervalMs); + unrefTimer(timer); + this.keepAliveTimer = timer; + } + + private stopKeepAlive(): void { + if (this.keepAliveTimer) { + this.clearTimer(this.keepAliveTimer); + this.keepAliveTimer = null; + } + } + + private sendKeepAlive(socket: JellyfinRemoteSocket): void { + try { + socket.send(JSON.stringify({ MessageType: 'KeepAlive' })); + } catch (error) { + this.logWarn?.('Failed to send Jellyfin remote keep-alive.', error); + } + } + private scheduleReconnect(): void { const delay = Math.min( this.reconnectMaxDelayMs, @@ -342,7 +421,7 @@ export class JellyfinRemoteSessionService { const baseUrl = new URL(`${this.serverUrl}/`); const socketUrl = new URL('/socket', baseUrl); socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - socketUrl.searchParams.set('api_key', this.accessToken); + socketUrl.searchParams.set('ApiKey', this.accessToken); socketUrl.searchParams.set('deviceId', this.deviceId); return socketUrl.toString(); } @@ -350,8 +429,6 @@ export class JellyfinRemoteSessionService { private createSocket(url: string): JellyfinRemoteSocket { const headers: JellyfinRemoteSocketHeaders = { Authorization: this.authHeader, - 'X-Emby-Authorization': this.authHeader, - 'X-Emby-Token': this.accessToken, }; if (this.socketHeadersFactory) { return this.socketHeadersFactory(url, headers); @@ -375,8 +452,6 @@ export class JellyfinRemoteSessionService { method: 'GET', headers: { Authorization: this.authHeader, - 'X-Emby-Authorization': this.authHeader, - 'X-Emby-Token': this.accessToken, }, }); if (!response.ok) return false; @@ -398,21 +473,41 @@ export class JellyfinRemoteSessionService { headers: { 'Content-Type': 'application/json', Authorization: this.authHeader, - 'X-Emby-Authorization': this.authHeader, - 'X-Emby-Token': this.accessToken, }, body: JSON.stringify(payload), }); + this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`); return response.ok; - } catch { + } catch (error) { + this.noteRequestOutcome(path, error); return false; } } - private handleInboundMessage(rawData: unknown): void { + // Warn once per path while it keeps failing so a rejected stop report is visible in the + // log without a warning per progress tick. + private noteRequestOutcome(path: string, failure: unknown): void { + if (failure === null) { + this.failedRequestPaths.delete(path); + return; + } + if (this.failedRequestPaths.has(path)) return; + this.failedRequestPaths.add(path); + this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure); + } + + private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void { const message = parseInboundMessage(rawData); if (!message) return; const messageType = message.MessageType; + if (messageType === 'ForceKeepAlive') { + const seconds = Number(message.Data); + const timeoutMs = + Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : this.keepAliveTimeoutMs; + this.startKeepAlive(socket, timeoutMs); + return; + } + if (messageType === 'KeepAlive') return; const payload = parseMessageData(message.Data); if (messageType === 'Play') { this.onPlay?.(payload); diff --git a/src/core/services/jellyfin.test.ts b/src/core/services/jellyfin.test.ts index 19e71a4d..c6340c29 100644 --- a/src/core/services/jellyfin.test.ts +++ b/src/core/services/jellyfin.test.ts @@ -279,7 +279,7 @@ test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled assert.equal(plan.mode, 'transcode'); const url = new URL(plan.url); assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/); - assert.equal(url.searchParams.get('api_key'), 'token'); + assert.equal(url.searchParams.get('ApiKey'), 'token'); assert.equal(url.searchParams.get('AudioStreamIndex'), '4'); assert.equal(url.searchParams.get('StartTimeTicks'), '10000000'); } finally { @@ -365,7 +365,7 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async IsForced: true, IsExternal: true, DeliveryMethod: 'External', - DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt', + DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=server-token', IsExternalUrl: false, }, { @@ -402,11 +402,11 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async ); assert.equal( tracks[0]!.deliveryUrl, - 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token', + 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?ApiKey=token', ); assert.equal( tracks[1]!.deliveryUrl, - 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token', + 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token', ); assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt'); } finally { @@ -505,7 +505,7 @@ test('resolvePlaybackPlan reuses server transcoding url and appends missing para const url = new URL(plan.url); assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/); assert.equal(url.searchParams.get('VideoCodec'), 'hevc'); - assert.equal(url.searchParams.get('api_key'), 'token'); + assert.equal(url.searchParams.get('ApiKey'), 'token'); assert.equal(url.searchParams.get('AudioStreamIndex'), '3'); assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8'); assert.equal(url.searchParams.get('StartTimeTicks'), '50000000'); @@ -626,7 +626,7 @@ test('listSubtitleTracks falls back from PlaybackInfo to item media sources', as assert.equal(tracks[0]!.index, 11); assert.equal( tracks[0]!.deliveryUrl, - 'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token', + 'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?ApiKey=token', ); } finally { globalThis.fetch = originalFetch; @@ -789,3 +789,67 @@ test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', asy globalThis.fetch = originalFetch; } }); + +test('API requests authenticate with the MediaBrowser header only (no legacy X-Emby-Token)', async () => { + const originalFetch = globalThis.fetch; + const seenHeaders: Headers[] = []; + globalThis.fetch = (async (_input, init) => { + seenHeaders.push(new Headers(init?.headers)); + return new Response(JSON.stringify({ Items: [] }), { status: 200 }); + }) as typeof fetch; + + try { + await listLibraries( + { serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' }, + clientInfo, + ); + assert.equal(seenHeaders.length, 1); + const headers = seenHeaders[0]!; + const authorization = headers.get('authorization') ?? ''; + assert.match(authorization, /^MediaBrowser /); + assert.match(authorization, /Token="token"/); + assert.match(authorization, /DeviceId="subminer-test"/); + assert.equal(headers.has('x-emby-token'), false); + assert.equal(headers.has('x-emby-authorization'), false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolvePlaybackPlan replaces a legacy api_key on the server transcoding url with ApiKey', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-legacy', + Name: 'Movie Legacy', + MediaSources: [ + { + Id: 'ms-legacy', + Container: 'mkv', + SupportsDirectStream: false, + SupportsTranscoding: true, + TranscodingUrl: '/Videos/movie-legacy/master.m3u8?VideoCodec=hevc&api_key=server-token', + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const plan = await resolvePlaybackPlan( + { serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' }, + clientInfo, + { enabled: true, directPlayPreferred: true }, + { itemId: 'movie-legacy' }, + ); + + assert.equal(plan.mode, 'transcode'); + const url = new URL(plan.url); + assert.equal(url.searchParams.get('ApiKey'), 'token'); + assert.equal(url.searchParams.has('api_key'), false); + assert.equal(url.searchParams.get('VideoCodec'), 'hevc'); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/core/services/jellyfin.ts b/src/core/services/jellyfin.ts index a09577e6..e960604c 100644 --- a/src/core/services/jellyfin.ts +++ b/src/core/services/jellyfin.ts @@ -136,6 +136,16 @@ function getErrorMessage(error: unknown): string { return String(error || 'unknown error'); } +// Jellyfin reads query keys case-insensitively and older servers embed the token as +// `api_key` in the URLs they hand back, so drop every spelling before setting the one +// form Jellyfin 12 still accepts with legacy authorization disabled. +function setApiKeyParam(url: URL, accessToken: string): void { + for (const key of [...url.searchParams.keys()]) { + if (/^api_?key$/i.test(key)) url.searchParams.delete(key); + } + url.searchParams.set('ApiKey', accessToken); +} + function resolveDeliveryUrl( session: JellyfinAuthSession, stream: JellyfinMediaStream, @@ -146,9 +156,7 @@ function resolveDeliveryUrl( if (deliveryUrl) { if (stream.IsExternalUrl === true) return deliveryUrl; const resolved = new URL(deliveryUrl, `${session.serverUrl}/`); - if (!resolved.searchParams.has('api_key')) { - resolved.searchParams.set('api_key', session.accessToken); - } + setApiKeyParam(resolved, session.accessToken); return resolved.toString(); } @@ -171,9 +179,7 @@ function resolveDeliveryUrl( `/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`, `${session.serverUrl}/`, ); - if (!fallback.searchParams.has('api_key')) { - fallback.searchParams.set('api_key', session.accessToken); - } + setApiKeyParam(fallback, session.accessToken); return fallback.toString(); } @@ -197,7 +203,6 @@ async function jellyfinRequestJson( const headers = new Headers(init.headers ?? {}); headers.set('Content-Type', 'application/json'); headers.set('Authorization', createAuthorizationHeader(client, session.accessToken)); - headers.set('X-Emby-Token', session.accessToken); const response = await fetch(`${session.serverUrl}${path}`, { ...init, @@ -221,7 +226,7 @@ function createDirectPlayUrl( ): string { const query = new URLSearchParams({ static: 'true', - api_key: session.accessToken, + ApiKey: session.accessToken, MediaSourceId: ensureString(mediaSource.Id), }); if (mediaSource.LiveStreamId) { @@ -245,9 +250,7 @@ function createTranscodeUrl( ): string { if (mediaSource.TranscodingUrl) { const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`); - if (!url.searchParams.has('api_key')) { - url.searchParams.set('api_key', session.accessToken); - } + setApiKeyParam(url, session.accessToken); if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) { url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex)); } @@ -261,7 +264,7 @@ function createTranscodeUrl( } const query = new URLSearchParams({ - api_key: session.accessToken, + ApiKey: session.accessToken, MediaSourceId: ensureString(mediaSource.Id), VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'), TranscodingContainer: 'ts', diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index ae7f2b78..6891872e 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -1,5 +1,6 @@ import { MPV_REQUEST_ID_AID, + MPV_REQUEST_ID_MEDIA_TITLE, MPV_REQUEST_ID_OSD_DIMENSIONS, MPV_REQUEST_ID_OSD_HEIGHT, MPV_REQUEST_ID_PATH, @@ -85,6 +86,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array = [ }, { command: ['get_property', 'media-title'], + request_id: MPV_REQUEST_ID_MEDIA_TITLE, }, { command: ['get_property', 'pause'], diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index c4c66a90..da3b1c9a 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -35,6 +35,7 @@ export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; export const MPV_REQUEST_ID_PAUSE = 123; export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; +export const MPV_REQUEST_ID_MEDIA_TITLE = 202; export type MpvMessageParser = (message: MpvMessage) => void; export type MpvParseErrorHandler = (line: string, error: unknown) => void; @@ -335,14 +336,18 @@ export async function dispatchMpvProtocolMessage( } else if (msg.name === 'fullscreen') { deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) }); } else if (msg.name === 'media-title') { - const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null; - if (typeof msg.data === 'string' && msg.data.trim() && !title) return; - deps.emitMediaTitleChange({ - title, - }); + applyMediaTitle(deps, msg.data); } else if (msg.name === 'path') { const path = (msg.data as string) || ''; deps.setCurrentVideoPath(path); + // A forced title set before loadfile arrives ahead of the path change that clears the + // cached title and never fires again, so read it back once the new path is known. + if (path) { + deps.sendCommand({ + command: ['get_property', 'media-title'], + request_id: MPV_REQUEST_ID_MEDIA_TITLE, + }); + } deps.emitMediaPathChange({ path }); deps.autoLoadSecondarySubTrack(path); deps.syncCurrentAudioStreamIndex(); @@ -467,6 +472,8 @@ export async function dispatchMpvProtocolMessage( deps.emitSubtitleAssChange({ text: (msg.data as string) || '' }); } else if (msg.request_id === MPV_REQUEST_ID_PATH) { deps.emitMediaPathChange({ path: (msg.data as string) || '' }); + } else if (msg.request_id === MPV_REQUEST_ID_MEDIA_TITLE) { + applyMediaTitle(deps, msg.data); } else if (msg.request_id === MPV_REQUEST_ID_AID) { deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null); deps.syncCurrentAudioStreamIndex(); @@ -557,6 +564,17 @@ export function asFiniteNumber(value: unknown, fallback: number): number { return Number.isFinite(nextValue) ? nextValue : fallback; } +// URL-derived titles (mpv falls back to the basename of a query-bearing stream URL) must not +// replace known metadata, so they are dropped instead of cached. +function applyMediaTitle( + deps: Pick, + data: unknown, +): void { + const title = typeof data === 'string' ? sanitizeMediaTitle(data) : null; + if (typeof data === 'string' && data.trim() && !title) return; + deps.emitMediaTitleChange({ title }); +} + export function parseVisibilityProperty(value: unknown): boolean | null { if (typeof value === 'boolean') return value; if (typeof value !== 'string') return null; diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index be86a33a..5b14f061 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -9,6 +9,7 @@ import { } from './mpv'; import { MPV_REQUEST_ID_TRACK_LIST_AUDIO, + MPV_REQUEST_ID_MEDIA_TITLE, MPV_REQUEST_ID_TRACK_LIST_SECONDARY, } from './mpv-protocol'; @@ -135,9 +136,15 @@ test('MpvIpcClient ignores URL-derived titles without replacing known metadata', assert.deepEqual(titles, ['My Anime S01E02']); }); -test('MpvIpcClient clears cached media title when media path changes', async () => { +test('MpvIpcClient clears cached media title when media path changes and reads it back', async () => { const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + const commands: Array<{ command?: unknown[]; request_id?: number }> = []; + (client as any).send = (command: { command?: unknown[]; request_id?: number }) => { + commands.push(command); + return true; + }; + // A forced title (Jellyfin sets force-media-title before loadfile) arrives before the path. await invokeHandleMessage(client, { event: 'property-change', name: 'media-title', @@ -148,11 +155,33 @@ test('MpvIpcClient clears cached media title when media path changes', async () await invokeHandleMessage(client, { event: 'property-change', name: 'path', - data: '/tmp/new-episode.mkv', + data: 'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret', }); - assert.equal(client.currentVideoPath, '/tmp/new-episode.mkv'); + assert.equal( + client.currentVideoPath, + 'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret', + ); assert.equal(client.currentMediaTitle, null); + const titleRequest = commands.find( + (command) => command.command?.[0] === 'get_property' && command.command?.[1] === 'media-title', + ); + assert.equal(titleRequest?.request_id, MPV_REQUEST_ID_MEDIA_TITLE); + + await invokeHandleMessage(client, { + request_id: MPV_REQUEST_ID_MEDIA_TITLE, + error: 'success', + data: '[Jellyfin/direct] Episode 1', + }); + assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1'); + + // A URL-derived read-back must not poison the cache. + await invokeHandleMessage(client, { + request_id: MPV_REQUEST_ID_MEDIA_TITLE, + error: 'success', + data: 'stream?static=true&ApiKey=secret', + }); + assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1'); }); test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => { diff --git a/src/main/runtime/composers/jellyfin-remote-composer.ts b/src/main/runtime/composers/jellyfin-remote-composer.ts index 2de85547..f16fe964 100644 --- a/src/main/runtime/composers/jellyfin-remote-composer.ts +++ b/src/main/runtime/composers/jellyfin-remote-composer.ts @@ -7,6 +7,7 @@ import { createHandleJellyfinRemoteGeneralCommand, createHandleJellyfinRemotePlay, createHandleJellyfinRemotePlaystate, + createJellyfinRemoteReportTracker, createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, } from '../domains/jellyfin'; @@ -91,13 +92,17 @@ export function composeJellyfinRemoteHandlers( getNow: options.getNow, ticksPerSecond: options.ticksPerSecond, logDebug: options.logDebug, + logWarn: options.logWarn, }); - const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( - buildReportJellyfinRemoteProgressMainDepsHandler(), - ); - const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler( - buildReportJellyfinRemoteStoppedMainDepsHandler(), - ); + const reportTracker = createJellyfinRemoteReportTracker(); + const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({ + ...buildReportJellyfinRemoteProgressMainDepsHandler(), + reportTracker, + }); + const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({ + ...buildReportJellyfinRemoteStoppedMainDepsHandler(), + reportTracker, + }); const buildHandleJellyfinRemotePlayMainDepsHandler = createBuildHandleJellyfinRemotePlayMainDepsHandler({ diff --git a/src/main/runtime/jellyfin-remote-main-deps.ts b/src/main/runtime/jellyfin-remote-main-deps.ts index d5b4dd20..ed3ca45d 100644 --- a/src/main/runtime/jellyfin-remote-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-main-deps.ts @@ -75,5 +75,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler( getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined, ticksPerSecond: deps.ticksPerSecond, logDebug: (message: string, error: unknown) => deps.logDebug(message, error), + ...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}), }); } diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts index b227f5aa..919250e5 100644 --- a/src/main/runtime/jellyfin-remote-playback.test.ts +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { markJellyfinRemotePlaybackLoaded, + createJellyfinRemoteReportTracker, createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, secondsToJellyfinTicks, @@ -528,3 +529,70 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before assert.equal(stopped, false); assert.equal(cleared, false); }); + +test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => { + const tracker = createJellyfinRemoteReportTracker(); + let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = { + itemId: 'item-1', + playMethod: 'DirectPlay', + loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream', + }; + const calls: string[] = []; + let releaseProgress: () => void = () => undefined; + const progressGate = new Promise((resolve) => { + releaseProgress = resolve; + }); + const session = { + isConnected: () => true, + reportProgress: async ({ eventName }: { eventName: string }) => { + calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`); + if (calls.length === 1) await progressGate; + return true; + }, + reportStopped: async () => { + calls.push(`stopped:${playback ? 'active' : 'cleared'}`); + return true; + }, + }; + const shared = { + getActivePlayback: () => playback, + clearActivePlayback: () => { + playback = null; + }, + getSession: () => session, + getMpvClient: () => ({ currentTimePos: 42 }), + ticksPerSecond: 10_000_000, + logDebug: () => undefined, + reportTracker: tracker, + }; + const reportProgress = createReportJellyfinRemoteProgressHandler({ + ...shared, + getNow: () => 10_000, + getLastProgressAtMs: () => 0, + setLastProgressAtMs: () => undefined, + progressIntervalMs: 3000, + }); + const reportStopped = createReportJellyfinRemoteStoppedHandler(shared); + + // A periodic tick is mid-request when the stop starts. + const tick = reportProgress(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, ['progress:TimeUpdate:active']); + const stop = reportStopped(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(playback, null); + assert.deepEqual(calls, ['progress:TimeUpdate:active']); + + // A tick fired after the stop began must not report anything. + await reportProgress(true); + assert.deepEqual(calls, ['progress:TimeUpdate:active']); + + releaseProgress(); + await tick; + await stop; + assert.deepEqual(calls, [ + 'progress:TimeUpdate:active', + 'progress:TimeUpdate:cleared', + 'stopped:cleared', + ]); +}); diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index 8ea0c6c9..3e49e452 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -134,6 +134,29 @@ function isSeekLikePositionJump( return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds; } +// Jellyfin re-creates a session's NowPlayingItem from any progress report, so a progress +// tick that lands after the stop report leaves the server showing playback forever. The +// tracker lets the stop handler wait for reports that are already in flight. +export type JellyfinRemoteReportTracker = { + track: (report: Promise) => void; + settled: () => Promise; +}; + +export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker { + const active = new Set>(); + return { + track: (report) => { + active.add(report); + void report.finally(() => active.delete(report)); + }, + settled: async () => { + while (active.size > 0) { + await Promise.allSettled([...active]); + } + }, + }; +} + export type JellyfinRemoteProgressReporterDeps = { getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; clearActivePlayback: () => void; @@ -145,6 +168,7 @@ export type JellyfinRemoteProgressReporterDeps = { progressIntervalMs: number; ticksPerSecond: number; logDebug: (message: string, error: unknown) => void; + reportTracker?: JellyfinRemoteReportTracker; }; export function createReportJellyfinRemoteProgressHandler( @@ -152,7 +176,7 @@ export function createReportJellyfinRemoteProgressHandler( ) { let lastReportedPositionSeconds: number | null = null; - return async (force = false): Promise => { + const report = async (force: boolean): Promise => { const playback = deps.getActivePlayback(); if (!playback) return; const session = deps.getSession(); @@ -193,6 +217,12 @@ export function createReportJellyfinRemoteProgressHandler( deps.logDebug('Failed to report Jellyfin remote progress', error); } }; + + return async (force = false): Promise => { + const pending = report(force); + deps.reportTracker?.track(pending); + await pending; + }; } export type JellyfinRemoteStoppedReporterDeps = { @@ -203,6 +233,8 @@ export type JellyfinRemoteStoppedReporterDeps = { getNow?: () => number; ticksPerSecond: number; logDebug: (message: string, error: unknown) => void; + logWarn?: (message: string) => void; + reportTracker?: JellyfinRemoteReportTracker; }; export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) { @@ -226,6 +258,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto deps.clearActivePlayback(); return; } + // Clear before any network call so progress ticks fired during the stop find nothing to + // report, then let reports already in flight finish so none can arrive after the stop. + deps.clearActivePlayback(); + await deps.reportTracker?.settled(); try { const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient()); const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds); @@ -244,7 +280,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto } catch (error) { deps.logDebug('Failed to report Jellyfin remote final progress', error); } - await session.reportStopped({ + const reported = await session.reportStopped({ itemId: playback.itemId, mediaSourceId: playback.mediaSourceId, positionTicks, @@ -254,10 +290,13 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto subtitleStreamIndex: playback.subtitleStreamIndex, eventName: 'stop', }); + if (reported === false) { + deps.logWarn?.( + `Jellyfin did not accept the playback stop report for item ${playback.itemId}; the server may keep showing it as playing.`, + ); + } } catch (error) { deps.logDebug('Failed to report Jellyfin remote stop', error); - } finally { - deps.clearActivePlayback(); } }; } diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.ts index 233f94af..a7b5759c 100644 --- a/src/main/runtime/jellyfin-remote-session-lifecycle.ts +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.ts @@ -38,6 +38,7 @@ type JellyfinRemoteServiceOptions = { }; onConnected: () => void; onDisconnected: () => void; + logWarn?: (message: string, details?: unknown) => void; onPlay: (payload: JellyfinRemoteEventPayload) => void; onPlaystate: (payload: JellyfinRemoteEventPayload) => void; onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void; @@ -110,6 +111,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: { onDisconnected: () => { deps.logWarn('Jellyfin remote websocket disconnected; retrying.'); }, + logWarn: (message, details) => deps.logWarn(message, details), onPlay: (payload) => { void deps.handlePlay(payload).catch((error) => { deps.logWarn('Failed handling Jellyfin remote Play event', error); diff --git a/vendor/subminer-yomitan b/vendor/subminer-yomitan index 57516d3b..99d6bf85 160000 --- a/vendor/subminer-yomitan +++ b/vendor/subminer-yomitan @@ -1 +1 @@ -Subproject commit 57516d3b7f3bffa604f575026cd39390067137ce +Subproject commit 99d6bf853ccf94f10114df5834d5abc68bc8ab55