From c91d4aecf898e84b584e0457a7be7c91b06e1b16 Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sat, 12 Sep 2026 23:26:34 -0400 Subject: [PATCH] fix(server): load embedded Plex subtitles --- apps/frontend/src/api/cliparrClient.test.ts | 60 ++- apps/frontend/src/api/cliparrClient.ts | 20 +- .../src/components/editor/useSubtitleCues.ts | 12 +- apps/server/src/providers/plex/playback.ts | 234 +++++++--- .../server/src/providers/plexPlayback.test.ts | 415 ++++++++++++++++-- .../server/src/providers/shared/mediaProxy.ts | 6 +- apps/server/src/providers/types.ts | 1 + 7 files changed, 658 insertions(+), 90 deletions(-) diff --git a/apps/frontend/src/api/cliparrClient.test.ts b/apps/frontend/src/api/cliparrClient.test.ts index 854d9116..2b5fcceb 100644 --- a/apps/frontend/src/api/cliparrClient.test.ts +++ b/apps/frontend/src/api/cliparrClient.test.ts @@ -2,7 +2,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { cliparrClient, subscribeToAuthFailure } from "@/api/cliparrClient"; +import { + cliparrClient, + readResponseErrorDetails, + subscribeToAuthFailure, +} from "@/api/cliparrClient"; function jsonResponse(value: unknown, init: ResponseInit = {}) { return Response.json(value, { @@ -221,3 +225,57 @@ void test("follows app auth redirects with the current browser location", async } } }); + +void test("preserves actionable subtitle errors and tolerates non-JSON or malformed error bodies", async () => { + const message = + "The selected Plex subtitle changed. Refresh the editor and try again."; + assert.deepEqual( + await readResponseErrorDetails( + jsonResponse( + { + error: { + code: "plex_subtitle_selection_changed", + message, + }, + }, + { status: 409 }, + ), + ), + { code: "plex_subtitle_selection_changed", message }, + ); + + for (const body of ["not JSON", "null", '{"error":42}']) { + assert.deepEqual( + await readResponseErrorDetails( + new Response(body, { + status: 502, + headers: { "content-type": "application/json" }, + }), + ), + {}, + ); + } + assert.deepEqual( + await readResponseErrorDetails( + new Response("Bad gateway", { + status: 502, + headers: { "content-type": "text/html" }, + }), + ), + {}, + ); +}); + +void test("propagates cancellation while reading an API error body", async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.error(new DOMException("Download cancelled", "AbortError")); + }, + }), + { status: 409, headers: { "content-type": "application/json" } }, + ); + await assert.rejects(readResponseErrorDetails(response), { + name: "AbortError", + }); +}); diff --git a/apps/frontend/src/api/cliparrClient.ts b/apps/frontend/src/api/cliparrClient.ts index 57273aa2..b90837f0 100644 --- a/apps/frontend/src/api/cliparrClient.ts +++ b/apps/frontend/src/api/cliparrClient.ts @@ -87,6 +87,21 @@ function responseErrorDetails(payload: unknown): ResponseErrorDetails { }; } +export async function readResponseErrorDetails( + response: Response, +): Promise { + if (!response.headers.get("content-type")?.includes("application/json")) { + return {}; + } + + const text = await response.text(); + try { + return responseErrorDetails(JSON.parse(text)); + } catch { + return {}; + } +} + function queueAuthFailureNotification() { if (authFailureQueued) { return; @@ -144,10 +159,7 @@ async function request(path: string, init: RequestInit = {}): Promise { throw buildUnexpectedApiResponseError(); } - const data: unknown = contentType.includes("application/json") - ? await response.json().catch((): unknown => null) - : null; - const error = responseErrorDetails(data); + const error = await readResponseErrorDetails(response); if (response.status === 401 && error.code === "not_authenticated") { queueAuthFailureNotification(); diff --git a/apps/frontend/src/components/editor/useSubtitleCues.ts b/apps/frontend/src/components/editor/useSubtitleCues.ts index 3ee2a8d1..f9e6783a 100644 --- a/apps/frontend/src/components/editor/useSubtitleCues.ts +++ b/apps/frontend/src/components/editor/useSubtitleCues.ts @@ -6,6 +6,7 @@ import { logEventFields, } from "@cliparr/shared/logging"; import type { PlaybackSubtitleTrack } from "@/providers/types"; +import { readResponseErrorDetails } from "@/api/cliparrClient"; import { subtitleTrackKey, subtitleTrackSupportsBurnIn, @@ -39,10 +40,13 @@ type SubtitleDownloadResult = failure: SubtitleDownloadFailure; }; -function subtitleDownloadFailure(status: number): SubtitleDownloadFailure { +async function subtitleDownloadFailure( + response: Response, +): Promise { + const error = await readResponseErrorDetails(response); return { - status, - message: `Could not load subtitles (${status}).`, + status: response.status, + message: error.message ?? `Could not load subtitles (${response.status}).`, }; } @@ -70,7 +74,7 @@ async function downloadSubtitleCues( if (!response.ok) { return { ok: false, - failure: subtitleDownloadFailure(response.status), + failure: await subtitleDownloadFailure(response), }; } diff --git a/apps/server/src/providers/plex/playback.ts b/apps/server/src/providers/plex/playback.ts index 67f7ed37..22871993 100644 --- a/apps/server/src/providers/plex/playback.ts +++ b/apps/server/src/providers/plex/playback.ts @@ -13,9 +13,11 @@ import { import { createApiError, isApiError } from "@/http/errors"; import { getServerLogger } from "@/logging"; import type { ProviderSessionRecord } from "@/session/store"; +import type { MediaContainerWithDecision } from "@/providers/plex/generated/types.gen"; import type { CurrentlyPlayingEntry, MediaExportMetadata, + MediaHandle, PlaybackAudioSelection, PlaybackExportEstimateMetadata, PlaybackSubtitleSelection, @@ -74,7 +76,11 @@ function createMediaHandle( session: ProviderSessionRecord, context: PlexSourceContext, path: string, - options: { basePath?: string; playbackSessionId?: string } = {}, + options: { + basePath?: string; + playbackSessionId?: string; + subtitleStreamId?: string; + } = {}, ) { return createProviderMediaHandle( session, @@ -89,6 +95,7 @@ function createMediaHandle( : { plex: { playbackSessionId: options.playbackSessionId, + subtitleStreamId: options.subtitleStreamId, }, }, }, @@ -880,7 +887,7 @@ function plexDirectSubtitleContentFormat(codec: unknown) { function buildSelectedPlexSubtitleTranscodePath( item: PlexMetadataItem, - playbackSessionId: string, + subtitleSessionId: string, selection: PlexMediaSelection | undefined, stream: PlexStream, ) { @@ -893,19 +900,28 @@ function buildSelectedPlexSubtitleTranscodePath( const resolvedSelection = resolveSelectedPart(item, selection); const params = new URLSearchParams({ path, - transcodeSessionId: playbackSessionId, + session: subtitleSessionId, + protocol: "http", + directPlay: "1", + hasMDE: "1", mediaIndex: String(resolvedSelection?.mediaIndex ?? 0), partIndex: String(resolvedSelection?.partIndex ?? 0), subtitles: "sidecar", advancedSubtitles: "text", autoAdjustSubtitle: "0", + offset: "0", + copyts: "1", }); - return `/video/:/transcode/universal/subtitles?${params.toString()}`; + return `/subtitles/:/transcode/universal/start?${params.toString()}`; } function canTranscodeSelectedPlexSubtitle(codec: unknown, stream: PlexStream) { - return isSelectedEntry(stream) && isTextSubtitleCodec(codec); + return ( + isSelectedEntry(stream) && + isTextSubtitleCodec(codec) && + Boolean(idValue(stream.id)) + ); } function plexSubtitleContentFormat( @@ -932,20 +948,28 @@ function plexSubtitleTrack( selection: PlexMediaSelection | undefined, stream: PlexStream, ): PlaybackSubtitleTrack { + const streamId = idValue(stream.id); const codec = normalizeSubtitleCodec(stream?.codec); const directSubtitlePath = buildPlexSubtitlePath(stream); const isText = isTextSubtitleCodec(codec); const transcodeSubtitleAvailable = Boolean(metadataPath(item)) && canTranscodeSelectedPlexSubtitle(codec, stream); - const transcodeSubtitlePath = directSubtitlePath - ? undefined - : buildSelectedPlexSubtitleTranscodePath( + const subtitleSessionId = + transcodeSubtitleAvailable && !directSubtitlePath + ? createCliparrPlexTranscodeSessionId( + context.sourceId, + `${session.id}:${playbackSessionId}:subtitles:${streamId}`, + ) + : undefined; + const transcodeSubtitlePath = subtitleSessionId + ? buildSelectedPlexSubtitleTranscodePath( item, - playbackSessionId, + subtitleSessionId, selection, stream, - ); + ) + : undefined; const contentFormat = plexSubtitleContentFormat( codec, directSubtitlePath, @@ -954,7 +978,7 @@ function plexSubtitleTrack( const contentPath = directSubtitlePath ?? transcodeSubtitlePath; return { - streamId: idValue(stream?.id), + streamId, index: numberValue(stream?.index) ?? numberValue(stream?.streamIdentifier), languageCode: stringValue(stream?.languageCode) ?? stringValue(stream?.languageTag), @@ -969,8 +993,9 @@ function plexSubtitleTrack( contentUrl: contentPath ? createMediaHandle(session, context, contentPath, { playbackSessionId: transcodeSubtitlePath - ? playbackSessionId + ? subtitleSessionId : undefined, + subtitleStreamId: transcodeSubtitlePath ? streamId : undefined, }) : undefined, }; @@ -1443,6 +1468,73 @@ export async function listCurrentlyPlaying( return normalizeCurrentPlayback(session, source, context, data); } +async function preparePlexSubtitleTranscode( + handle: MediaHandle, + headers: Headers, + subtitleStreamId: string, + signal: AbortSignal, +) { + // Plex authorizes subtitle extraction through a preceding video decision. + // Direct-play capability here avoids starting a video transcode; only the + // subtitle request is subsequently downloaded, in its own session. + const decisionUrl = new URL(handle.path, handle.baseUrl); + decisionUrl.pathname = "/video/:/transcode/universal/decision"; + const decisionHeaders = new Headers(headers); + decisionHeaders.set("Accept", "application/json"); + decisionHeaders.delete("Range"); + const response = await fetchMediaHandleRequest( + { ...handle, path: `${decisionUrl.pathname}${decisionUrl.search}` }, + { + headers: decisionHeaders, + signal, + timeoutMs: CURRENT_PLAYBACK_REQUEST_TIMEOUT_MS, + retryAttempts: 1, + }, + ); + if (!response.ok) { + await response.body?.cancel(); + throw createApiError( + response.status, + "plex_subtitle_decision_failed", + "Plex could not prepare the embedded subtitle track.", + ); + } + + let decision: PlexMetadataData & MediaContainerWithDecision; + try { + decision = (await response.json()) as typeof decision; + } catch { + signal.throwIfAborted(); + throw createApiError( + 502, + "plex_subtitle_decision_failed", + "Plex returned an invalid subtitle playback decision.", + ); + } + const container = decision?.MediaContainer; + const decisionCode = numberValue( + container?.mdeDecisionCode ?? container?.generalDecisionCode, + ); + if (!container || (decisionCode !== undefined && decisionCode >= 2000)) { + throw createApiError( + 502, + "plex_subtitle_decision_failed", + "Plex could not prepare the embedded subtitle track.", + ); + } + const decisionItem = container.Metadata?.[0]; + const selectedTrack = decisionItem + ? deriveSelectedSubtitleTrack(decisionItem) + : undefined; + if (selectedTrack?.streamId !== subtitleStreamId) { + throw createApiError( + 409, + "plex_subtitle_selection_changed", + "The selected Plex subtitle changed. Refresh the editor and try again.", + ); + } +} + export async function proxyMedia( session: ProviderSessionRecord, handleId: string, @@ -1485,6 +1577,19 @@ export async function proxyMedia( if (playbackSessionId) { headers.set("X-Plex-Session-Identifier", playbackSessionId); } + const subtitleStreamId = useProviderAuth + ? handle.providerMetadata?.plex?.subtitleStreamId + : undefined; + const subtitleRequest = subtitleStreamId + ? { streamId: subtitleStreamId, controller: new AbortController() } + : undefined; + if (subtitleRequest) { + headers.set("X-Plex-Client-Profile-Name", "Generic"); + headers.set( + "X-Plex-Client-Profile-Extra", + "add-transcode-target(type=subtitleProfile&protocol=http&context=all&subtitleCodec=srt&container=srt)", + ); + } logger.trace("Fetching Plex media.", { "media.handle.id": handle.id, @@ -1497,43 +1602,72 @@ export async function proxyMedia( "plex.playback_session.id": playbackSessionId, }); - await proxyProviderMediaResponse( - session, - handle, - { - accept: accept ?? undefined, - range: range ?? undefined, - }, - async () => { - const upstream = await fetchMediaHandleRequest(handle, { headers }); - if (!upstream.ok && upstream.status !== 206) { - const body = await upstream.text(); - const detail = body.slice(0, 400).replaceAll(/\s+/g, " ").trim(); - logger.warn("Plex media request failed.", { - ...logEventFields("media.proxy.upstream", "failure"), - "media.handle.id": handle.id, - "session.id": session.id, - "source.id": handle.sourceId, - "upstream.url": sanitizeLoggedMediaPath(url.toString()), - "upstream.status_code": upstream.status, - "upstream.detail": detail, - "provider.auth.attached": useProviderAuth, - "media.range.present": Boolean(range), - "http.accept": accept, - "plex.playback_session.id": playbackSessionId, - ...mediaHandleHlsLogFields(handle), - }); - throw createApiError( - upstream.status, - "plex_media_failed", - detail - ? `Plex media request failed: ${detail}` - : "Plex media request failed", - ); - } + const abortSubtitleRequest = () => subtitleRequest?.controller.abort(); + if (subtitleRequest) { + res.once("close", abortSubtitleRequest); + if (res.destroyed) { + abortSubtitleRequest(); + } + } - return upstream; - }, - res, - ); + try { + await proxyProviderMediaResponse( + session, + handle, + { + accept: accept ?? undefined, + range: range ?? undefined, + }, + async () => { + if (subtitleRequest) { + await preparePlexSubtitleTranscode( + handle, + headers, + subtitleRequest.streamId, + subtitleRequest.controller.signal, + ); + } + const upstream = await fetchMediaHandleRequest(handle, { + headers, + signal: subtitleRequest?.controller.signal, + }); + if (!upstream.ok && upstream.status !== 206) { + const body = await upstream.text(); + const detail = body.slice(0, 400).replaceAll(/\s+/g, " ").trim(); + logger.warn("Plex media request failed.", { + ...logEventFields("media.proxy.upstream", "failure"), + "media.handle.id": handle.id, + "session.id": session.id, + "source.id": handle.sourceId, + "upstream.url": sanitizeLoggedMediaPath(url.toString()), + "upstream.status_code": upstream.status, + "upstream.detail": detail, + "provider.auth.attached": useProviderAuth, + "media.range.present": Boolean(range), + "http.accept": accept, + "plex.playback_session.id": playbackSessionId, + ...mediaHandleHlsLogFields(handle), + }); + throw createApiError( + upstream.status, + "plex_media_failed", + detail + ? `Plex media request failed: ${detail}` + : "Plex media request failed", + ); + } + + return upstream; + }, + res, + ); + } catch (error) { + if (!subtitleRequest?.controller.signal.aborted || !res.destroyed) { + throw error; + } + } finally { + if (subtitleRequest) { + res.off("close", abortSubtitleRequest); + } + } } diff --git a/apps/server/src/providers/plexPlayback.test.ts b/apps/server/src/providers/plexPlayback.test.ts index f0e64481..318f0901 100644 --- a/apps/server/src/providers/plexPlayback.test.ts +++ b/apps/server/src/providers/plexPlayback.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { once } from "node:events"; +import { PassThrough } from "node:stream"; import type { Request as ExpressRequest, Response as ExpressResponse, @@ -125,11 +127,12 @@ function createRequest(headers: Record = {}) { } function createResponseRecorder() { - const recorder = { + const stream = new PassThrough(); + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + const recorder = Object.assign(stream, { statusCode: 200, headers: new Map(), - body: Buffer.alloc(0), - ended: false, status(code: number) { recorder.statusCode = code; return recorder; @@ -138,18 +141,12 @@ function createResponseRecorder() { recorder.headers.set(name.toLowerCase(), String(value)); return recorder; }, - end(chunk?: string | Uint8Array) { - if (typeof chunk === "string") { - recorder.body = Buffer.from(chunk); - } else if (chunk) { - recorder.body = Buffer.from(chunk); - } - recorder.ended = true; - return recorder; - }, - }; + }); - return recorder; + const response = Object.assign(recorder, { + getBody: () => Buffer.concat(chunks).toString(), + }); + return response as typeof response & ExpressResponse; } function onlyMediaHandle(session: ProviderSessionRecord) { @@ -214,7 +211,7 @@ void test("creates a Cliparr-owned Plex transcode session id", () => { ); }); -void test("keeps Plex subtitle extraction on the real playback session id", () => { +void test("isolates Plex subtitle extraction from the viewer and HLS preview sessions", () => { const session = createSession(); const context = createContext(); const plexPlaybackSessionId = "254"; @@ -271,14 +268,14 @@ void test("keeps Plex subtitle extraction on the real playback session id", () = assert.equal(tracks[0]?.streamId, "101151"); assert.equal( handle.providerMetadata?.plex?.playbackSessionId, - plexPlaybackSessionId, + subtitleUrl.searchParams.get("session"), ); - assert.equal( - subtitleUrl.searchParams.get("transcodeSessionId"), + assert.notEqual( + subtitleUrl.searchParams.get("session"), plexPlaybackSessionId, ); assert.notEqual( - subtitleUrl.searchParams.get("transcodeSessionId"), + subtitleUrl.searchParams.get("session"), cliparrPreviewTranscodeSessionId, ); assert.equal(subtitleUrl.searchParams.get("path"), "/library/metadata/14447"); @@ -352,7 +349,7 @@ void test("sends the real Plex playback session header for synthetic HLS preview session, handleId, createRequest({ accept: "*/*" }) as ExpressRequest, - response as unknown as ExpressResponse, + response, ); const upstreamUrl = new URL(requestUrls[0] ?? ""); @@ -375,7 +372,7 @@ void test("sends the real Plex playback session header for synthetic HLS preview } }); -void test("builds Plex HLS preview and selected embedded SRT subtitles with separate session ids", async () => { +void test("builds Plex HLS preview and embedded SRT extraction with independent sessions", async () => { const session = createSession(); const source = createSource(); const plexPlaybackSessionId = "254"; @@ -500,12 +497,12 @@ void test("builds Plex HLS preview and selected embedded SRT subtitles with sepa subtitleTrack.contentUrl, ); const subtitleUrl = new URL(subtitleHandle.path, "http://cliparr.local"); - assert.equal( - subtitleUrl.searchParams.get("transcodeSessionId"), + assert.notEqual( + subtitleUrl.searchParams.get("session"), plexPlaybackSessionId, ); assert.notEqual( - subtitleUrl.searchParams.get("transcodeSessionId"), + subtitleUrl.searchParams.get("session"), cliparrPreviewTranscodeSessionId, ); assert.equal(subtitleUrl.searchParams.get("subtitles"), "sidecar"); @@ -515,7 +512,7 @@ void test("builds Plex HLS preview and selected embedded SRT subtitles with sepa ); assert.equal( subtitleHandle.providerMetadata?.plex?.playbackSessionId, - plexPlaybackSessionId, + subtitleUrl.searchParams.get("session"), ); }, ); @@ -687,17 +684,22 @@ void test("creates a subtitle transcode content URL for the selected embedded Pl assert.equal(tracks[0]?.contentUrl, `/api/media/${handle.id}`); assert.equal(tracks[0]?.contentFormat, "srt"); assert.equal( - handle.path.startsWith("/video/:/transcode/universal/subtitles?"), + handle.path.startsWith("/subtitles/:/transcode/universal/start?"), true, ); assert.equal( transcodeUrl.searchParams.get("path"), "/library/metadata/12345", ); - assert.equal( - transcodeUrl.searchParams.get("transcodeSessionId"), - "plex-session-1", - ); + assert.ok(transcodeUrl.searchParams.get("session")); + assert.notEqual(transcodeUrl.searchParams.get("session"), "plex-session-1"); + assert.equal(transcodeUrl.searchParams.has("transcodeSessionId"), false); + assert.equal(handle.providerMetadata?.plex?.subtitleStreamId, "201"); + assert.equal(transcodeUrl.searchParams.get("protocol"), "http"); + assert.equal(transcodeUrl.searchParams.get("directPlay"), "1"); + assert.equal(transcodeUrl.searchParams.get("hasMDE"), "1"); + assert.equal(transcodeUrl.searchParams.get("offset"), "0"); + assert.equal(transcodeUrl.searchParams.get("copyts"), "1"); assert.equal(transcodeUrl.searchParams.get("mediaIndex"), "0"); assert.equal(transcodeUrl.searchParams.get("partIndex"), "0"); assert.equal(transcodeUrl.searchParams.get("subtitles"), "sidecar"); @@ -882,3 +884,356 @@ void test("leaves Plex image subtitle streams unsupported for burn-in", () => { assert.equal(tracks[0]?.contentUrl, undefined); assert.equal(session.mediaHandles.size, 0); }); + +function embeddedSubtitleItem(codec = "srt") { + return { + ratingKey: "12345", + Media: [ + { + Part: [ + { + Stream: [{ id: "201", streamType: 3, codec, selected: true }], + }, + ], + }, + ], + }; +} + +void test("prepares a Plex direct-play decision before downloading embedded subtitle text", async () => { + for (const codec of ["srt", "ass"]) { + const session = createSession(); + const context = createContext(); + const item = embeddedSubtitleItem(codec); + deriveSubtitleTracks(session, context, item, "viewer-session"); + const handle = onlyMediaHandle(session); + const contentUrl = new URL(handle.path, context.baseUrl); + const requests: string[] = []; + const subtitleText = + "1\n00:00:01,023 --> 00:00:03,023\nEmbedded English cue\n"; + + await withMockFetch( + (request) => { + const url = new URL(request.url); + requests.push(url.pathname); + assert.equal(request.method, "GET"); + assert.equal(request.headers.get("x-plex-token"), context.token); + assert.equal( + request.headers.get("x-plex-session-identifier"), + contentUrl.searchParams.get("session"), + ); + assert.equal( + request.headers.get("x-plex-client-profile-name"), + "Generic", + ); + assert.match( + request.headers.get("x-plex-client-profile-extra") ?? "", + /subtitleCodec=srt&container=srt/, + ); + assert.equal(url.search, contentUrl.search); + + if (url.pathname === "/video/:/transcode/universal/decision") { + assert.equal(requests.length, 1); + assert.equal(request.headers.get("accept"), "application/json"); + return jsonResponse({ MediaContainer: { Metadata: [item] } }); + } + assert.equal(url.pathname, "/subtitles/:/transcode/universal/start"); + assert.equal(requests.length, 2); + return new globalThis.Response(subtitleText, { + headers: { "content-type": "application/octet-stream" }, + }); + }, + async () => { + const response = createResponseRecorder(); + await proxyMedia( + session, + handle.id, + createRequest() as ExpressRequest, + response, + ); + assert.equal(response.statusCode, 200); + assert.equal(response.getBody(), subtitleText); + assert.equal(requests.length, 2); + }, + ); + } +}); + +void test("does not download embedded subtitles when Plex refuses or changes the selection", async () => { + for (const failure of ["denied", "different-track", "disabled"] as const) { + const session = createSession(); + const item = embeddedSubtitleItem(); + deriveSubtitleTracks(session, createContext(), item, "viewer-session"); + const handle = onlyMediaHandle(session); + let requests = 0; + + await withMockFetch( + (request) => { + requests += 1; + assert.equal( + new URL(request.url).pathname, + "/video/:/transcode/universal/decision", + ); + if (failure === "denied") { + return new globalThis.Response(null, { status: 403 }); + } + return jsonResponse({ + MediaContainer: { + Metadata: [ + { + Media: [ + { + Part: [ + { + Stream: [ + { + id: failure === "different-track" ? "202" : "201", + streamType: 3, + selected: failure !== "disabled", + }, + ], + }, + ], + }, + ], + }, + ], + }, + }); + }, + async () => { + await assert.rejects( + proxyMedia( + session, + handle.id, + createRequest() as ExpressRequest, + createResponseRecorder(), + ), + { + code: + failure === "denied" + ? "plex_subtitle_decision_failed" + : "plex_subtitle_selection_changed", + status: failure === "denied" ? 403 : 409, + }, + ); + assert.equal(requests, 1); + }, + ); + } +}); + +void test("downloads Plex sidecar subtitles without a transcode decision", async () => { + const session = createSession(); + const item = embeddedSubtitleItem(); + const stream = item.Media[0]?.Part[0]?.Stream[0]; + assert.ok(stream); + const sidecarItem = { + ...item, + Media: [ + { Part: [{ Stream: [{ ...stream, key: "/library/streams/201" }] }] }, + ], + }; + deriveSubtitleTracks(session, createContext(), sidecarItem, "viewer-session"); + const handle = onlyMediaHandle(session); + let requests = 0; + await withMockFetch( + (request) => { + requests += 1; + assert.equal(new URL(request.url).pathname, "/library/streams/201.srt"); + assert.equal(request.headers.has("x-plex-session-identifier"), false); + return new globalThis.Response("Sidecar subtitle text"); + }, + async () => { + const response = createResponseRecorder(); + await proxyMedia( + session, + handle.id, + createRequest() as ExpressRequest, + response, + ); + assert.equal(response.getBody(), "Sidecar subtitle text"); + assert.equal(requests, 1); + }, + ); +}); + +void test("preserves the selected Plex media version and part for embedded subtitles", () => { + const session = createSession(); + const context = createContext(); + const item = embeddedSubtitleItem(); + const part = item.Media[0]?.Part[0]; + assert.ok(part); + const versionedItem = { + ...item, + Media: [{ Part: [] }, { Part: [{ Stream: [] }, part] }], + }; + const selection = { mediaIndex: 1, partIndex: 1 }; + const tracks = deriveSubtitleTracks( + session, + context, + versionedItem, + "viewer-session", + selection, + ); + const handle = onlyMediaHandle(session); + const url = new URL(handle.path, context.baseUrl); + assert.equal(url.searchParams.get("mediaIndex"), "1"); + assert.equal(url.searchParams.get("partIndex"), "1"); + assert.equal( + tracks[0]?.contentUrl, + deriveSubtitleTracks( + session, + context, + versionedItem, + "viewer-session", + selection, + )[0]?.contentUrl, + ); + assert.equal(session.mediaHandles.size, 1); +}); + +void test("checks subtitles on the media version and part selected by the Plex decision", async () => { + const session = createSession(); + const item = embeddedSubtitleItem(); + deriveSubtitleTracks(session, createContext(), item, "viewer-session"); + const handle = onlyMediaHandle(session); + const wrongPart = { Stream: [{ id: "202", streamType: 3, selected: true }] }; + const decisionItem = { + ...item, + Media: [ + { Part: [wrongPart] }, + { + selected: true, + Part: [wrongPart, { ...item.Media[0]?.Part[0], selected: true }], + }, + ], + }; + await withMockFetch( + (request) => { + if (new URL(request.url).pathname.endsWith("/decision")) { + return jsonResponse({ MediaContainer: { Metadata: [decisionItem] } }); + } + return new globalThis.Response("Selected version subtitle"); + }, + async () => { + const response = createResponseRecorder(); + await proxyMedia( + session, + handle.id, + createRequest() as ExpressRequest, + response, + ); + assert.equal(response.getBody(), "Selected version subtitle"); + }, + ); +}); + +void test("cancels Plex subtitle preparation and extraction when the browser disconnects", async () => { + for (const phase of ["decision", "start"]) { + const session = createSession(); + const item = embeddedSubtitleItem(); + deriveSubtitleTracks(session, createContext(), item, "viewer-session"); + const handle = onlyMediaHandle(session); + let reachRequest: ((signal: AbortSignal) => void) | undefined; + const reached = new Promise((resolve) => { + reachRequest = resolve; + }); + let rejectPending: ((reason: Error) => void) | undefined; + const pending = new Promise((_resolve, reject) => { + rejectPending = reject; + }); + const response = createResponseRecorder(); + const initialCloseListeners = response.listenerCount("close"); + let requests = 0; + await withMockFetch( + (request) => { + requests += 1; + if (!new URL(request.url).pathname.endsWith(`/${phase}`)) { + return jsonResponse({ MediaContainer: { Metadata: [item] } }); + } + reachRequest?.(request.signal); + request.signal.addEventListener( + "abort", + () => { + rejectPending?.( + new DOMException("Browser disconnected", "AbortError"), + ); + }, + { once: true }, + ); + return pending; + }, + async () => { + const load = proxyMedia( + session, + handle.id, + createRequest() as ExpressRequest, + response, + ); + // Observe rejection immediately so an abort cannot become unhandled. + const outcome = Promise.allSettled([load]); + const signal = await reached; + try { + const closed = once(response, "close"); + response.destroy(); + await closed; + assert.equal(signal.aborted, true); + const results = await outcome; + assert.equal(results[0]?.status, "fulfilled"); + assert.equal(response.listenerCount("close"), initialCloseListeners); + assert.equal(requests, phase === "decision" ? 1 : 2); + } finally { + rejectPending?.(new DOMException("Test cleanup", "AbortError")); + await outcome; + } + }, + ); + } +}); + +void test("rejects failed or malformed Plex decisions before starting subtitle extraction", async () => { + const item = embeddedSubtitleItem(); + const failedDecisions = [ + JSON.stringify({ + MediaContainer: { mdeDecisionCode: 2000, Metadata: [item] }, + }), + JSON.stringify({ + MediaContainer: { generalDecisionCode: 2001, Metadata: [item] }, + }), + "not JSON", + "null", + ]; + for (const decision of failedDecisions) { + const session = createSession(); + deriveSubtitleTracks(session, createContext(), item, "viewer-session"); + const handle = onlyMediaHandle(session); + let requests = 0; + await withMockFetch( + () => { + requests += 1; + return new globalThis.Response(decision, { + headers: { "content-type": "application/json" }, + }); + }, + async () => { + const response = createResponseRecorder(); + const initialCloseListeners = response.listenerCount("close"); + await assert.rejects( + proxyMedia( + session, + handle.id, + createRequest() as ExpressRequest, + response, + ), + { + status: 502, + code: "plex_subtitle_decision_failed", + }, + ); + assert.equal(requests, 1); + assert.equal(response.listenerCount("close"), initialCloseListeners); + }, + ); + } +}); diff --git a/apps/server/src/providers/shared/mediaProxy.ts b/apps/server/src/providers/shared/mediaProxy.ts index 2127d0dd..00e7b92f 100644 --- a/apps/server/src/providers/shared/mediaProxy.ts +++ b/apps/server/src/providers/shared/mediaProxy.ts @@ -103,9 +103,13 @@ function normalizeProviderMetadata( ): MediaHandle["providerMetadata"] { const normalized: NonNullable = {}; - if (metadata?.plex?.playbackSessionId !== undefined) { + if ( + metadata?.plex?.playbackSessionId !== undefined || + metadata?.plex?.subtitleStreamId !== undefined + ) { normalized.plex = { playbackSessionId: metadata.plex.playbackSessionId, + subtitleStreamId: metadata.plex.subtitleStreamId, }; } diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts index 5af41c1e..cf23d289 100644 --- a/apps/server/src/providers/types.ts +++ b/apps/server/src/providers/types.ts @@ -64,6 +64,7 @@ export interface CurrentlyPlayingEntry { interface MediaHandleProviderMetadata { plex?: { playbackSessionId?: string; + subtitleStreamId?: string; }; jellyfin?: { deviceId?: string;