diff --git a/apps/server/src/db/mediaSourcesRepository.ts b/apps/server/src/db/mediaSourcesRepository.ts index 185b7c7..9daf8d4 100644 --- a/apps/server/src/db/mediaSourcesRepository.ts +++ b/apps/server/src/db/mediaSourcesRepository.ts @@ -126,11 +126,11 @@ 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)); } -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..8394903 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,9 @@ 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 deleted = deleteMediaSourceForAccount(source.id, source.providerAccountId); + const deleted = deleteMediaSourceForAccount(req.params.id as string, session.providerAccountId); if (!deleted) { throw new ApiError(404, "source_not_found", "Source was not found"); } @@ -159,10 +159,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 +172,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 +191,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 } : {}),