From c0c1987162d505304ad2aac05eb2bba24a3b5477 Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sat, 18 Apr 2026 14:49:43 -0400 Subject: [PATCH 1/3] fix: implement session-scoped media source access and add Plex playback metadata deduplication --- apps/server/src/db/mediaSourcesRepository.ts | 2 +- apps/server/src/providers/plex/playback.ts | 58 ++++++++++++++++++-- apps/server/src/routes/media.ts | 1 + apps/server/src/routes/sources.ts | 38 +++++++------ 4 files changed, 75 insertions(+), 24 deletions(-) diff --git a/apps/server/src/db/mediaSourcesRepository.ts b/apps/server/src/db/mediaSourcesRepository.ts index 185b7c7..d460830 100644 --- a/apps/server/src/db/mediaSourcesRepository.ts +++ b/apps/server/src/db/mediaSourcesRepository.ts @@ -130,7 +130,7 @@ export function getMediaSource(id: string) { return getMediaSourceWhere(eq(mediaSources.id, id)); } -function getMediaSourceForAccount(id: string, providerAccountId: string) { +export function getMediaSourceForAccount(id: string, providerAccountId: string) { const where = and( eq(mediaSources.id, id), eq(mediaSources.providerAccountId, providerAccountId) diff --git a/apps/server/src/providers/plex/playback.ts b/apps/server/src/providers/plex/playback.ts index 96ec919..b16e06d 100644 --- a/apps/server/src/providers/plex/playback.ts +++ b/apps/server/src/providers/plex/playback.ts @@ -259,6 +259,53 @@ async function fetchCurrentlyPlayingData(source: MediaSource) { ); } +function playbackFallbackIdentity(item: any) { + const userId = idValue(item?.User?.id); + const playerId = stringValue(item?.Player?.machineIdentifier) ?? stringValue(item?.Player?.title); + const itemId = idValue(item?.ratingKey) ?? stringValue(item?.key) ?? metadataPath(item); + const parts = [userId, playerId, itemId].filter((value): value is string => Boolean(value)); + + return parts.length > 0 ? parts.join(":") : undefined; +} + +function playbackDeduplicationKey(item: any) { + const sessionKey = idValue(item?.sessionKey); + if (sessionKey) { + return `session:${sessionKey}`; + } + + const sessionId = idValue(item?.Session?.id); + if (sessionId) { + return `session-id:${sessionId}`; + } + + const fallbackIdentity = playbackFallbackIdentity(item); + if (fallbackIdentity) { + return `fallback:${fallbackIdentity}`; + } + + return undefined; +} + +function dedupeCurrentlyPlayingMetadata(metadata: any[]) { + // Plex can emit duplicate rows for one live session, especially from web clients. + const seen = new Set(); + + return metadata.filter((item) => { + const dedupeKey = playbackDeduplicationKey(item); + if (!dedupeKey) { + return true; + } + + if (seen.has(dedupeKey)) { + return false; + } + + seen.add(dedupeKey); + return true; + }); +} + function tagValues(value: unknown) { return uniqueStrings( asArray(value as any).map((entry: any) => { @@ -451,10 +498,9 @@ async function resolveMediaPath( function playbackSessionIdentity(item: any) { return String( - item?.Session?.id - ?? item?.ratingKey - ?? item?.key - ?? item?.Player?.machineIdentifier + item?.sessionKey + ?? item?.Session?.id + ?? playbackFallbackIdentity(item) ?? randomUUID() ); } @@ -481,7 +527,9 @@ async function normalizeCurrentPlayback( return []; } - return Promise.all(metadata.map(async (item: any) => { + const uniqueMetadata = dedupeCurrentlyPlayingMetadata(metadata); + + return Promise.all(uniqueMetadata.map(async (item: any) => { const mediaSelection = deriveMediaSelection(item); const enrichedItem = await enrichMetadataItem(context, item); const mediaPath = await resolveMediaPath(context, item, enrichedItem, mediaSelection); diff --git a/apps/server/src/routes/media.ts b/apps/server/src/routes/media.ts index 1e62e47..0b99de0 100644 --- a/apps/server/src/routes/media.ts +++ b/apps/server/src/routes/media.ts @@ -61,6 +61,7 @@ mediaRouter.get( const sourceErrors: SourcePlaybackError[] = []; const sources = listMediaSources({ enabledOnly: true, + providerAccountId: session.providerAccountId, }) .flatMap((source) => { const provider = getProvider(source.providerId); diff --git a/apps/server/src/routes/sources.ts b/apps/server/src/routes/sources.ts index a77dc13..b2ea850 100644 --- a/apps/server/src/routes/sources.ts +++ b/apps/server/src/routes/sources.ts @@ -1,12 +1,12 @@ import { Router } from "express"; import { listMediaSources, - getMediaSource, deleteMediaSourceForAccount, - updateMediaSourceForAccount, + getMediaSourceForAccount, updateMediaSourceHealthForAccount, type MediaSource, type UpdateMediaSourceInput, + updateMediaSourceForAccount, } from "../db/mediaSourcesRepository.js"; import { ApiError, asyncHandler } from "../http/errors.js"; import { getProvider } from "../providers/registry.js"; @@ -29,8 +29,8 @@ function serializeSource(source: MediaSource) { }; } -function requireMediaSource(sourceId: string) { - const source = getMediaSource(sourceId); +function requireMediaSource(sourceId: string, providerAccountId: string) { + const source = getMediaSourceForAccount(sourceId, providerAccountId); if (!source) { throw new ApiError(404, "source_not_found", "Source was not found"); } @@ -104,10 +104,12 @@ function parseSourceUpdate(body: unknown): UpdateMediaSourceInput { sourcesRouter.get( "/", asyncHandler(async (req, res) => { - requireAccountSession(req); + const session = requireAccountSession(req); setNoStore(res); res.json({ - sources: listMediaSources().map(serializeSource), + sources: listMediaSources({ + providerAccountId: session.providerAccountId, + }).map(serializeSource), }); }) ); @@ -115,9 +117,9 @@ sourcesRouter.get( sourcesRouter.get( "/:id", asyncHandler(async (req, res) => { - requireAccountSession(req); + const session = requireAccountSession(req); setNoStore(res); - const source = requireMediaSource(req.params.id as string); + const source = requireMediaSource(req.params.id as string, session.providerAccountId); res.json({ source: serializeSource(source) }); }) ); @@ -125,12 +127,12 @@ sourcesRouter.get( sourcesRouter.patch( "/:id", asyncHandler(async (req, res) => { - requireAccountSession(req); + const session = requireAccountSession(req); setNoStore(res); - const existingSource = requireMediaSource(req.params.id as string); + requireMediaSource(req.params.id as string, session.providerAccountId); const source = updateMediaSourceForAccount( req.params.id as string, - existingSource.providerAccountId, + session.providerAccountId, parseSourceUpdate(req.body) ); if (!source) { @@ -143,11 +145,11 @@ sourcesRouter.patch( sourcesRouter.delete( "/:id", asyncHandler(async (req, res) => { - requireAccountSession(req); + const session = requireAccountSession(req); setNoStore(res); - const source = requireMediaSource(req.params.id as string); + const source = requireMediaSource(req.params.id as string, session.providerAccountId); - const deleted = deleteMediaSourceForAccount(source.id, source.providerAccountId); + const deleted = deleteMediaSourceForAccount(source.id, session.providerAccountId); if (!deleted) { throw new ApiError(404, "source_not_found", "Source was not found"); } @@ -159,10 +161,10 @@ sourcesRouter.delete( sourcesRouter.post( "/:id/check", asyncHandler(async (req, res) => { - requireAccountSession(req); + const session = requireAccountSession(req); setNoStore(res); - const source = requireMediaSource(req.params.id as string); + const source = requireMediaSource(req.params.id as string, session.providerAccountId); const provider = getProvider(source.providerId); if (!provider) { throw new ApiError(500, "provider_not_registered", "Source provider is not registered"); @@ -172,7 +174,7 @@ sourcesRouter.post( const result = await provider.checkSource(source); if (!result.ok) { - const updatedSource = updateMediaSourceHealthForAccount(source.id, source.providerAccountId, { + const updatedSource = updateMediaSourceHealthForAccount(source.id, session.providerAccountId, { lastCheckedAt: checkedAt, lastError: result.message, }); @@ -191,7 +193,7 @@ sourcesRouter.post( return; } - const updatedSource = updateMediaSourceForAccount(source.id, source.providerAccountId, { + const updatedSource = updateMediaSourceForAccount(source.id, session.providerAccountId, { ...(result.name !== undefined ? { name: result.name } : {}), ...(result.baseUrl !== undefined ? { baseUrl: result.baseUrl } : {}), ...(result.connection !== undefined ? { connection: result.connection } : {}), From bd0f7fd6c4e1722b87bd8385a610b6d18ee43e81 Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sat, 18 Apr 2026 14:52:20 -0400 Subject: [PATCH 2/3] refactor: remove export from getMediaSource in mediaSourcesRepository --- apps/server/src/db/mediaSourcesRepository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/db/mediaSourcesRepository.ts b/apps/server/src/db/mediaSourcesRepository.ts index d460830..9daf8d4 100644 --- a/apps/server/src/db/mediaSourcesRepository.ts +++ b/apps/server/src/db/mediaSourcesRepository.ts @@ -126,7 +126,7 @@ export function updateMediaSource(id: string, input: UpdateMediaSourceInput) { return getMediaSource(id); } -export function getMediaSource(id: string) { +function getMediaSource(id: string) { return getMediaSourceWhere(eq(mediaSources.id, id)); } From bd597f8427ecef53d98df75c7f18bab7cf731902 Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sat, 18 Apr 2026 14:58:32 -0400 Subject: [PATCH 3/3] refactor: remove redundant source lookup before deletion in media source route --- apps/server/src/routes/sources.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/routes/sources.ts b/apps/server/src/routes/sources.ts index b2ea850..8394903 100644 --- a/apps/server/src/routes/sources.ts +++ b/apps/server/src/routes/sources.ts @@ -147,9 +147,7 @@ sourcesRouter.delete( asyncHandler(async (req, res) => { const session = requireAccountSession(req); setNoStore(res); - const source = requireMediaSource(req.params.id as string, session.providerAccountId); - - const deleted = deleteMediaSourceForAccount(source.id, session.providerAccountId); + const deleted = deleteMediaSourceForAccount(req.params.id as string, session.providerAccountId); if (!deleted) { throw new ApiError(404, "source_not_found", "Source was not found"); }