fix(jellyfin): support modern authentication

- Use `ApiKey` query parameters and MediaBrowser authorization
- Remove legacy Emby headers and repair leaked media links
This commit is contained in:
2026-09-20 23:49:53 -07:00
parent 383aed8bad
commit c316ae08c2
13 changed files with 133 additions and 44 deletions
+4
View File
@@ -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.
+1 -1
View File
@@ -12,7 +12,7 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio
## Requirements ## 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)) - SubMiner installed and running (see [Installation](/installation))
- On Linux, the session token is stored with `gnome-libsecret` by default - On Linux, the session token is stored with `gnome-libsecret` by default
+1 -2
View File
@@ -89,7 +89,6 @@ export async function jellyfinApiRequest<T>(
const url = `${session.serverUrl}${requestPath}`; const url = `${session.serverUrl}${requestPath}`;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'X-Emby-Token': session.accessToken,
Authorization: `MediaBrowser Token="${session.accessToken}"`, Authorization: `MediaBrowser Token="${session.accessToken}"`,
}, },
}); });
@@ -103,7 +102,7 @@ export async function jellyfinApiRequest<T>(
} }
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string { 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 { function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
+3 -3
View File
@@ -228,7 +228,7 @@ export function pickLibrary(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} 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 curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
@@ -266,7 +266,7 @@ export function pickItem(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} 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 curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
@@ -304,7 +304,7 @@ export function pickGroup(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} 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 curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
+22
View File
@@ -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); const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)'); 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);
});
+1 -1
View File
@@ -185,7 +185,7 @@ function extractFilenameFromMediaPath(rawPath: string): string {
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean { function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
const loweredPath = rawPath.toLowerCase(); const loweredPath = rawPath.toLowerCase();
const loweredFilename = filename.toLowerCase(); const loweredFilename = filename.toLowerCase();
if (loweredPath.includes('api_key=')) { if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) {
return true; return true;
} }
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) { if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
@@ -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: '/STREAM?API_KEY=secret', leaked: true }, { filename: '/STREAM?API_KEY=secret', leaked: true },
{ filename: '/Videos/item?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: 'MediaSourceId=item api key secret', leaked: true },
{ filename: 'An API Key Story', leaked: false }, { filename: 'An API Key Story', leaked: false },
{ filename: 'api_key=ordinary-metadata', leaked: false }, { filename: 'api_key=ordinary-metadata', leaked: false },
@@ -369,6 +369,7 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [ const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
'api_key', 'api_key',
'ApiKey',
'StartTimeTicks', 'StartTimeTicks',
'AudioStreamIndex', 'AudioStreamIndex',
'SubtitleStreamIndex', 'SubtitleStreamIndex',
@@ -82,7 +82,7 @@ function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
) { ) {
return null; return null;
} }
if (!url.searchParams.has('api_key')) { if (!url.searchParams.has('api_key') && !url.searchParams.has('ApiKey')) {
return null; return null;
} }
return url; return url;
@@ -130,13 +130,13 @@ function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: str
SELECT v.canonical_title SELECT v.canonical_title
FROM imm_videos v FROM imm_videos v
WHERE v.anime_id = a.anime_id 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%' AND lower(v.canonical_title) NOT LIKE '%api key%'
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
LIMIT 1 LIMIT 1
) AS linked_video_title ) AS linked_video_title
FROM imm_anime a 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.canonical_title) LIKE '%api key%'
OR lower(a.normalized_title_key) LIKE '%api key%' OR lower(a.normalized_title_key) LIKE '%api key%'
`, `,
@@ -244,11 +244,11 @@ function repairLeakedJellyfinVideoParseMetadata(
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE source_type = 2 WHERE source_type = 2
AND ( AND (
parsed_basename LIKE '%api_key=%' parsed_basename LIKE '%api_key=%' OR parsed_basename LIKE '%ApiKey=%'
OR lower(parsed_basename) LIKE '%api key%' 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 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%' OR lower(parse_metadata_json) LIKE '%api key%'
) )
`, `,
@@ -267,7 +267,7 @@ function repairLeakedJellyfinAnimeParseMetadata(
UPDATE imm_anime UPDATE imm_anime
SET metadata_json = NULL, LAST_UPDATE_DATE = ? SET metadata_json = NULL, LAST_UPDATE_DATE = ?
WHERE ( WHERE (
metadata_json LIKE '%api_key=%' metadata_json LIKE '%api_key=%' OR metadata_json LIKE '%ApiKey=%'
OR lower(metadata_json) LIKE '%api key%' OR lower(metadata_json) LIKE '%api key%'
) AND ( ) AND (
lower(metadata_json) LIKE '%stream?%' lower(metadata_json) LIKE '%stream?%'
@@ -295,11 +295,11 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
FROM imm_videos FROM imm_videos
WHERE source_type = 2 WHERE source_type = 2
AND ( AND (
video_key LIKE '%api_key=%' video_key LIKE '%api_key=%' OR video_key LIKE '%ApiKey=%'
OR lower(video_key) LIKE '%api key%' 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 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%' OR lower(canonical_title) LIKE '%api key%'
) )
`, `,
+3 -2
View File
@@ -58,7 +58,7 @@ test('start posts capabilities on socket connect', async () => {
accessToken: 'token-1', accessToken: 'token-1',
deviceId: 'device-1', deviceId: 'device-1',
webSocketFactory: (url) => { 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(); const socket = new FakeWebSocket();
sockets.push(socket); sockets.push(socket);
return socket as unknown as any; return socket as unknown as any;
@@ -99,7 +99,8 @@ test('socket headers include jellyfin authorization metadata', () => {
assert.equal(seenHeaders.length, 1); assert.equal(seenHeaders.length, 1);
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"')); assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"')); 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', () => { test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
+1 -7
View File
@@ -342,7 +342,7 @@ export class JellyfinRemoteSessionService {
const baseUrl = new URL(`${this.serverUrl}/`); const baseUrl = new URL(`${this.serverUrl}/`);
const socketUrl = new URL('/socket', baseUrl); const socketUrl = new URL('/socket', baseUrl);
socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:'; 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); socketUrl.searchParams.set('deviceId', this.deviceId);
return socketUrl.toString(); return socketUrl.toString();
} }
@@ -350,8 +350,6 @@ export class JellyfinRemoteSessionService {
private createSocket(url: string): JellyfinRemoteSocket { private createSocket(url: string): JellyfinRemoteSocket {
const headers: JellyfinRemoteSocketHeaders = { const headers: JellyfinRemoteSocketHeaders = {
Authorization: this.authHeader, Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
}; };
if (this.socketHeadersFactory) { if (this.socketHeadersFactory) {
return this.socketHeadersFactory(url, headers); return this.socketHeadersFactory(url, headers);
@@ -375,8 +373,6 @@ export class JellyfinRemoteSessionService {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: this.authHeader, Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
}, },
}); });
if (!response.ok) return false; if (!response.ok) return false;
@@ -398,8 +394,6 @@ export class JellyfinRemoteSessionService {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: this.authHeader, Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
+70 -6
View File
@@ -279,7 +279,7 @@ test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled
assert.equal(plan.mode, 'transcode'); assert.equal(plan.mode, 'transcode');
const url = new URL(plan.url); const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/); 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('AudioStreamIndex'), '4');
assert.equal(url.searchParams.get('StartTimeTicks'), '10000000'); assert.equal(url.searchParams.get('StartTimeTicks'), '10000000');
} finally { } finally {
@@ -365,7 +365,7 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
IsForced: true, IsForced: true,
IsExternal: true, IsExternal: true,
DeliveryMethod: 'External', 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, IsExternalUrl: false,
}, },
{ {
@@ -402,11 +402,11 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
); );
assert.equal( assert.equal(
tracks[0]!.deliveryUrl, 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( assert.equal(
tracks[1]!.deliveryUrl, 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'); assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
} finally { } finally {
@@ -505,7 +505,7 @@ test('resolvePlaybackPlan reuses server transcoding url and appends missing para
const url = new URL(plan.url); const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/); assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
assert.equal(url.searchParams.get('VideoCodec'), 'hevc'); 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('AudioStreamIndex'), '3');
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8'); assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8');
assert.equal(url.searchParams.get('StartTimeTicks'), '50000000'); 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]!.index, 11);
assert.equal( assert.equal(
tracks[0]!.deliveryUrl, 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 { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
@@ -789,3 +789,67 @@ test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', asy
globalThis.fetch = originalFetch; 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;
}
});
+15 -12
View File
@@ -136,6 +136,16 @@ function getErrorMessage(error: unknown): string {
return String(error || 'unknown error'); 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( function resolveDeliveryUrl(
session: JellyfinAuthSession, session: JellyfinAuthSession,
stream: JellyfinMediaStream, stream: JellyfinMediaStream,
@@ -146,9 +156,7 @@ function resolveDeliveryUrl(
if (deliveryUrl) { if (deliveryUrl) {
if (stream.IsExternalUrl === true) return deliveryUrl; if (stream.IsExternalUrl === true) return deliveryUrl;
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`); const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
if (!resolved.searchParams.has('api_key')) { setApiKeyParam(resolved, session.accessToken);
resolved.searchParams.set('api_key', session.accessToken);
}
return resolved.toString(); return resolved.toString();
} }
@@ -171,9 +179,7 @@ function resolveDeliveryUrl(
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`, `/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
`${session.serverUrl}/`, `${session.serverUrl}/`,
); );
if (!fallback.searchParams.has('api_key')) { setApiKeyParam(fallback, session.accessToken);
fallback.searchParams.set('api_key', session.accessToken);
}
return fallback.toString(); return fallback.toString();
} }
@@ -197,7 +203,6 @@ async function jellyfinRequestJson<T>(
const headers = new Headers(init.headers ?? {}); const headers = new Headers(init.headers ?? {});
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
headers.set('Authorization', createAuthorizationHeader(client, session.accessToken)); headers.set('Authorization', createAuthorizationHeader(client, session.accessToken));
headers.set('X-Emby-Token', session.accessToken);
const response = await fetch(`${session.serverUrl}${path}`, { const response = await fetch(`${session.serverUrl}${path}`, {
...init, ...init,
@@ -221,7 +226,7 @@ function createDirectPlayUrl(
): string { ): string {
const query = new URLSearchParams({ const query = new URLSearchParams({
static: 'true', static: 'true',
api_key: session.accessToken, ApiKey: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id), MediaSourceId: ensureString(mediaSource.Id),
}); });
if (mediaSource.LiveStreamId) { if (mediaSource.LiveStreamId) {
@@ -245,9 +250,7 @@ function createTranscodeUrl(
): string { ): string {
if (mediaSource.TranscodingUrl) { if (mediaSource.TranscodingUrl) {
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`); const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
if (!url.searchParams.has('api_key')) { setApiKeyParam(url, session.accessToken);
url.searchParams.set('api_key', session.accessToken);
}
if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) { if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) {
url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex)); url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex));
} }
@@ -261,7 +264,7 @@ function createTranscodeUrl(
} }
const query = new URLSearchParams({ const query = new URLSearchParams({
api_key: session.accessToken, ApiKey: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id), MediaSourceId: ensureString(mediaSource.Id),
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'), VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
TranscodingContainer: 'ts', TranscodingContainer: 'ts',