diff --git a/changes/fix-jellyfin-modern-auth.md b/changes/fix-jellyfin-modern-auth.md new file mode 100644 index 00000000..4eb756a0 --- /dev/null +++ b/changes/fix-jellyfin-modern-auth.md @@ -0,0 +1,4 @@ +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. 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 543ddb6e..dc432282 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 1cdc84d7..decde1fd 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -369,6 +369,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..7877cadd 100644 --- a/src/core/services/jellyfin-remote.test.ts +++ b/src/core/services/jellyfin-remote.test.ts @@ -58,7 +58,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 +99,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', () => { diff --git a/src/core/services/jellyfin-remote.ts b/src/core/services/jellyfin-remote.ts index ef0c301b..cd0ce605 100644 --- a/src/core/services/jellyfin-remote.ts +++ b/src/core/services/jellyfin-remote.ts @@ -342,7 +342,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 +350,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 +373,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,8 +394,6 @@ 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), }); 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',