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
+15 -12
View File
@@ -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<T>(
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',