From 2443b89b46f88fddbf41f0382a9f3bc5469c6fa8 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 26 Jun 2026 05:42:36 -0700 Subject: [PATCH] fix(plex): use series title for media releases instead of season label (#6083) --- .../integrations/src/plex/plex-integration.ts | 13 +- .../src/plex/test/plex-integration.spec.ts | 140 ++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 packages/integrations/src/plex/test/plex-integration.spec.ts diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 9c2cb8a4f..a031343c0 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -127,11 +127,18 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat const media = data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => { + const title = + item.type === "episode" + ? (item.grandparentTitle ?? item.title) + : item.type === "season" + ? (item.parentTitle ?? item.title) + : item.title; + return { id: item.Media?.at(0)?.id.toString() ?? item.key, type: mapType(item.type), - title: item.title, - subtitle: item.tagline, + title, + subtitle: title === item.title ? item.tagline : item.title, description: item.summary, releaseDate: item.originallyAvailableAt ? new Date(item.originallyAvailableAt) @@ -222,6 +229,8 @@ const recentlyAddedSchema = z.object({ studio: z.string().optional(), type: z.string(), // For example "movie", "album" title: z.string(), + parentTitle: z.string().optional(), + grandparentTitle: z.string().optional(), summary: z.string().optional(), duration: z.number().optional(), addedAt: z.number(), diff --git a/packages/integrations/src/plex/test/plex-integration.spec.ts b/packages/integrations/src/plex/test/plex-integration.spec.ts new file mode 100644 index 000000000..64e046256 --- /dev/null +++ b/packages/integrations/src/plex/test/plex-integration.spec.ts @@ -0,0 +1,140 @@ +// @vitest-environment node +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../base/types"; +import { PlexIntegration } from "../plex-integration"; + +const imageProxyMocks = vi.hoisted(() => ({ + createImageAsync: vi.fn((url: string) => Promise.resolve(`proxied:${url}`)), +})); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +vi.mock("@homarr/image-proxy", () => ({ + ImageProxy: class { + createImageAsync = imageProxyMocks.createImageAsync; + }, +})); + +const TEST_URL = "https://plex.example.com"; +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "plex-token" }]; + +const createIntegration = () => + new PlexIntegration({ + id: "test-plex", + name: "Test Plex", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: secrets, + }); + +const createMetadataItem = (key: string, overrides: Record) => ({ + key: `/library/metadata/${key}`, + type: "movie", + title: "Movie", + addedAt: 1_735_689_600, + Image: [ + { + type: "coverPoster", + url: `/library/metadata/${key}/thumb`, + }, + ], + ...overrides, +}); + +beforeEach(() => { + mockFetch.mockReset(); + imageProxyMocks.createImageAsync.mockClear(); +}); + +describe("PlexIntegration.getMediaReleasesAsync", () => { + test("uses the series title for TV seasons and episodes", async () => { + mockFetch.mockImplementation((url) => { + const urlString = String(url); + + if (urlString.endsWith("/identity")) { + return Promise.resolve( + new Response(JSON.stringify({ MediaContainer: { machineIdentifier: "server-id" } }), { status: 200 }), + ) as unknown as ReturnType; + } + + if (urlString.endsWith("/library/recentlyAdded")) { + return Promise.resolve( + new Response( + JSON.stringify({ + MediaContainer: { + Metadata: [ + createMetadataItem("season", { + type: "season", + title: "Season 1", + parentTitle: "Breaking Bad", + }), + createMetadataItem("episode", { + type: "episode", + title: "Review", + parentTitle: "Season 1", + grandparentTitle: "The Bear", + }), + createMetadataItem("movie", { + type: "movie", + title: "Inception", + tagline: "Dreams feel real", + }), + createMetadataItem("show", { + type: "show", + title: "The Last of Us", + tagline: "A series tagline", + }), + createMetadataItem("missing-parent", { + type: "season", + title: "Season 3", + }), + ], + }, + }), + { status: 200 }, + ), + ) as unknown as ReturnType; + } + + throw new Error(`Unexpected Plex request: ${urlString}`); + }); + + const releases = await createIntegration().getMediaReleasesAsync(); + + expect(releases).toMatchObject([ + { + title: "Breaking Bad", + subtitle: "Season 1", + type: "tv", + }, + { + title: "The Bear", + subtitle: "Review", + type: "tv", + }, + { + title: "Inception", + subtitle: "Dreams feel real", + type: "movie", + }, + { + title: "The Last of Us", + subtitle: "A series tagline", + type: "tv", + }, + { + title: "Season 3", + subtitle: undefined, + type: "tv", + }, + ]); + }); +}); \ No newline at end of file