From d42f3aa1e3034f3538b77a090723d790cbddc3ab Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 17 Aug 2026 22:15:40 +0200 Subject: [PATCH] fix(plex): do not cache invalid Discover watchlist responses A 2xx body without MediaContainer was stored and then pinned with If-None-Match, so every user's watchlist kept failing after a Discover blip. --- server/api/plextv.test.ts | 278 ++++++++++++++++++++++++++++++++++++++ server/api/plextv.ts | 192 +++++++++++++++----------- 2 files changed, 392 insertions(+), 78 deletions(-) create mode 100644 server/api/plextv.test.ts diff --git a/server/api/plextv.test.ts b/server/api/plextv.test.ts new file mode 100644 index 0000000000..fd4a633a18 --- /dev/null +++ b/server/api/plextv.test.ts @@ -0,0 +1,278 @@ +import PlexTvAPI, { type PlexWatchlistCache } from '@server/api/plextv'; +import cacheManager from '@server/lib/cache'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import assert from 'node:assert/strict'; +import { afterEach, describe, it, mock } from 'node:test'; + +function getAxios(api: PlexTvAPI): AxiosInstance { + return (api as unknown as { axios: AxiosInstance }).axios; +} + +function watchlistCache() { + return cacheManager.getCache('plexwatchlist').data; +} + +describe('PlexTvAPI getWatchlist', () => { + afterEach(() => { + mock.restoreAll(); + cacheManager.getCache('plexwatchlist').flush(); + cacheManager.getCache('plextv').flush(); + }); + + it('does not cache or crash on a 2xx body without MediaContainer', async () => { + const api = new PlexTvAPI('token-empty'); + mock.method(getAxios(api), 'get', async () => ({ + status: 200, + headers: { etag: '"poison"' }, + data: { errors: [{ code: 1001, message: 'Unavailable' }] }, + })); + + const result = await api.getWatchlist(); + + assert.deepEqual(result, { + offset: 0, + size: 20, + totalSize: 0, + items: [], + }); + assert.equal(watchlistCache().get('token-empty'), undefined); + }); + + it('keeps a valid cached watchlist when Discover returns a 2xx without MediaContainer', async () => { + const api = new PlexTvAPI('token-stale'); + watchlistCache().set('token-stale', { + etag: '"good"', + response: { + MediaContainer: { totalSize: 4, Metadata: [] }, + }, + }); + mock.method(getAxios(api), 'get', async () => ({ + status: 200, + headers: { etag: '"poison"' }, + data: 'error', + })); + + const result = await api.getWatchlist(); + + assert.equal(result.totalSize, 4); + assert.equal( + watchlistCache().get('token-stale')?.etag, + '"good"' + ); + }); + + it('drops a poisoned cache entry and does not send If-None-Match', async () => { + const api = new PlexTvAPI('token-poisoned'); + watchlistCache().set('token-poisoned', { + etag: '"bad"', + response: { errors: ['nope'] }, + }); + + let requestConfig: AxiosRequestConfig | undefined; + mock.method( + getAxios(api), + 'get', + async (_url: string, config?: AxiosRequestConfig) => { + requestConfig = config; + return { + status: 200, + headers: { etag: '"fresh"' }, + data: { MediaContainer: { totalSize: 0 } }, + }; + } + ); + + const result = await api.getWatchlist(); + + assert.equal(result.totalSize, 0); + assert.equal(requestConfig?.headers?.['If-None-Match'], undefined); + assert.equal( + watchlistCache().get('token-poisoned')?.etag, + '"fresh"' + ); + }); + + it('does not cache a 2xx body whose MediaContainer is an array', async () => { + const api = new PlexTvAPI('token-array'); + mock.method(getAxios(api), 'get', async () => ({ + status: 200, + headers: { etag: '"array"' }, + data: { MediaContainer: [] }, + })); + + const result = await api.getWatchlist(); + + assert.deepEqual(result, { + offset: 0, + size: 20, + totalSize: 0, + items: [], + }); + assert.equal(watchlistCache().get('token-array'), undefined); + }); + + it('does not cache a 2xx body whose totalSize is not a number', async () => { + const api = new PlexTvAPI('token-bad-size'); + mock.method(getAxios(api), 'get', async () => ({ + status: 200, + headers: { etag: '"bad-size"' }, + data: { MediaContainer: { totalSize: 'invalid' } }, + })); + + const result = await api.getWatchlist(); + + assert.deepEqual(result, { + offset: 0, + size: 20, + totalSize: 0, + items: [], + }); + assert.equal(watchlistCache().get('token-bad-size'), undefined); + }); + + it('returns an empty watchlist when the watchlist fetch fails', async () => { + const api = new PlexTvAPI('token-fetch-error'); + mock.method(getAxios(api), 'get', async () => { + throw new Error('network down'); + }); + + const result = await api.getWatchlist(); + + assert.deepEqual(result, { + offset: 0, + size: 20, + totalSize: 0, + items: [], + }); + }); + + it('propagates cache manager failures instead of returning an empty watchlist', async () => { + const api = new PlexTvAPI('token-cache-error'); + const cacheError = new Error('cache unavailable'); + mock.method(cacheManager, 'getCache', () => { + throw cacheError; + }); + + await assert.rejects(() => api.getWatchlist(), cacheError); + }); + + it('skips items whose metadata request returns 404', async () => { + const api = new PlexTvAPI('token-404'); + mock.method(getAxios(api), 'get', async (url: string) => { + if (url === '/library/sections/watchlist/all') { + return { + status: 200, + headers: { etag: '"fresh"' }, + data: { + MediaContainer: { totalSize: 1, Metadata: [{ ratingKey: 'abc' }] }, + }, + }; + } + + if (url === '/library/metadata/abc') { + throw Object.assign(new Error('not found'), { + response: { status: 404 }, + }); + } + + throw new Error(`unexpected url: ${url}`); + }); + + const result = await api.getWatchlist(); + + assert.deepEqual(result, { + offset: 0, + size: 20, + totalSize: 1, + items: [], + }); + }); + + it('skips items whose metadata response contains no metadata', async () => { + const api = new PlexTvAPI('token-no-meta'); + mock.method(getAxios(api), 'get', async (url: string) => { + if (url === '/library/sections/watchlist/all') { + return { + status: 200, + headers: { etag: '"fresh"' }, + data: { + MediaContainer: { + totalSize: 2, + Metadata: [{ ratingKey: 'empty' }, { ratingKey: 'good' }], + }, + }, + }; + } + + if (url === '/library/metadata/empty') { + return { status: 200, data: { MediaContainer: {} } }; + } + + if (url === '/library/metadata/good') { + return { + status: 200, + data: { + MediaContainer: { + Metadata: [ + { + ratingKey: 'good', + type: 'movie', + title: 'Good Movie', + Guid: [{ id: 'tmdb://550' }], + }, + ], + }, + }, + }; + } + + throw new Error(`unexpected url: ${url}`); + }); + + const result = await api.getWatchlist(); + + assert.deepEqual(result, { + offset: 0, + size: 20, + totalSize: 2, + items: [ + { + ratingKey: 'good', + tmdbId: 550, + tvdbId: undefined, + title: 'Good Movie', + type: 'movie', + }, + ], + }); + }); + + it('propagates non-404 errors from metadata resolution', async () => { + const api = new PlexTvAPI('token-500'); + mock.method(getAxios(api), 'get', async (url: string) => { + if (url === '/library/sections/watchlist/all') { + return { + status: 200, + headers: { etag: '"fresh"' }, + data: { + MediaContainer: { totalSize: 1, Metadata: [{ ratingKey: 'abc' }] }, + }, + }; + } + + if (url === '/library/metadata/abc') { + throw Object.assign(new Error('upstream'), { + response: { status: 500 }, + }); + } + + throw new Error(`unexpected url: ${url}`); + }); + + await assert.rejects( + () => api.getWatchlist(), + (e: unknown) => + (e as { response?: { status?: number } }).response?.status === 500 + ); + }); +}); diff --git a/server/api/plextv.ts b/server/api/plextv.ts index b8f3c9a47b..953541ec10 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -104,9 +104,30 @@ interface WatchlistResponse { Metadata?: { ratingKey: string; }[]; + Video?: { + ratingKey: string; + }[]; }; } +function isWatchlistResponse(data: unknown): data is WatchlistResponse { + if ( + typeof data !== 'object' || + data === null || + !('MediaContainer' in data) + ) { + return false; + } + + const { MediaContainer: container } = data as WatchlistResponse; + return ( + typeof container === 'object' && + container !== null && + !Array.isArray(container) && + typeof container.totalSize === 'number' + ); +} + type PlexMetadataItem = { ratingKey: string; type: 'movie' | 'show'; @@ -116,7 +137,7 @@ type PlexMetadataItem = { }[]; }; interface MetadataResponse { - MediaContainer: { + MediaContainer?: { Metadata?: PlexMetadataItem[]; Video?: PlexMetadataItem[]; }; @@ -131,7 +152,7 @@ export interface PlexWatchlistItem { } export interface PlexWatchlistCache { - etag: string; + etag?: string; response: WatchlistResponse; } @@ -277,11 +298,18 @@ class PlexTvAPI extends ExternalAPI { totalSize: number; items: PlexWatchlistItem[]; }> { + const watchlistCache = cacheManager.getCache('plexwatchlist'); + let cachedWatchlist = watchlistCache.data.get( + this.authToken + ); + try { - const watchlistCache = cacheManager.getCache('plexwatchlist'); - let cachedWatchlist = watchlistCache.data.get( - this.authToken - ); + // Drop poisoned entries so we don't send If-None-Match for a body + // without MediaContainer (which would 304 and keep failing). + if (cachedWatchlist && !isWatchlistResponse(cachedWatchlist.response)) { + watchlistCache.data.del(this.authToken); + cachedWatchlist = undefined; + } const response = await this.axios.get( '/library/sections/watchlist/all', @@ -290,16 +318,20 @@ class PlexTvAPI extends ExternalAPI { 'X-Plex-Container-Start': offset, 'X-Plex-Container-Size': size, }, - headers: { - 'If-None-Match': cachedWatchlist?.etag, - }, + headers: cachedWatchlist?.etag + ? { 'If-None-Match': cachedWatchlist.etag } + : undefined, baseURL: 'https://discover.provider.plex.tv', validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error } ); // If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache. - if (response.status >= 200 && response.status <= 299) { + if ( + response.status >= 200 && + response.status <= 299 && + isWatchlistResponse(response.data) + ) { cachedWatchlist = { etag: response.headers.etag, response: response.data, @@ -310,74 +342,6 @@ class PlexTvAPI extends ExternalAPI { cachedWatchlist ); } - - const watchlistDetails = await Promise.all( - (cachedWatchlist?.response.MediaContainer.Metadata ?? []).map( - async (watchlistItem) => { - let detailedResponse: MetadataResponse; - try { - detailedResponse = await this.getRolling( - `/library/metadata/${watchlistItem.ratingKey}`, - { - baseURL: 'https://discover.provider.plex.tv', - } - ); - } catch (e) { - if (e.response?.status === 404) { - logger.warn( - `Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`, - { label: 'Plex.TV Metadata API' } - ); - return null; - } else { - throw e; - } - } - - const metadata = - detailedResponse.MediaContainer.Metadata?.[0] ?? - detailedResponse.MediaContainer.Video?.[0]; - - if (!metadata) { - logger.warn( - `Item with ratingKey ${watchlistItem.ratingKey} returned no metadata, skipping.`, - { label: 'Plex.TV Metadata API' } - ); - return null; - } - - const tmdbString = metadata.Guid?.find((guid) => - guid.id.startsWith('tmdb') - ); - const tvdbString = metadata.Guid?.find((guid) => - guid.id.startsWith('tvdb') - ); - - return { - ratingKey: metadata.ratingKey, - // This should always be set? But I guess it also cannot be? - // We will filter out the 0's afterwards - tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, - tvdbId: tvdbString - ? Number(tvdbString.id.split('//')[1]) - : undefined, - title: metadata.title, - type: metadata.type, - }; - } - ) - ); - - const filteredList = watchlistDetails.filter( - (detail) => detail?.tmdbId - ) as PlexWatchlistItem[]; - - return { - offset, - size, - totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0, - items: filteredList, - }; } catch (e) { logger.error('Failed to retrieve watchlist items', { label: 'Plex.TV Metadata API', @@ -390,6 +354,78 @@ class PlexTvAPI extends ExternalAPI { items: [], }; } + + const metadata = cachedWatchlist?.response.MediaContainer.Metadata; + const video = cachedWatchlist?.response.MediaContainer.Video; + const watchlistItems = Array.isArray(metadata) + ? metadata + : Array.isArray(video) + ? video + : []; + + const watchlistDetails = await Promise.all( + watchlistItems.map(async (watchlistItem) => { + let detailedResponse: MetadataResponse; + try { + detailedResponse = await this.getRolling( + `/library/metadata/${watchlistItem.ratingKey}`, + { + baseURL: 'https://discover.provider.plex.tv', + } + ); + } catch (e) { + if (e.response?.status === 404) { + logger.warn( + `Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`, + { label: 'Plex.TV Metadata API' } + ); + return null; + } else { + throw e; + } + } + + const metadata = + detailedResponse.MediaContainer?.Metadata?.[0] ?? + detailedResponse.MediaContainer?.Video?.[0]; + + if (!metadata) { + logger.debug( + `Item with ratingKey ${watchlistItem.ratingKey} returned no metadata, skipping.`, + { label: 'Plex.TV Metadata API' } + ); + return null; + } + + const tmdbString = metadata.Guid?.find((guid) => + guid.id.startsWith('tmdb') + ); + const tvdbString = metadata.Guid?.find((guid) => + guid.id.startsWith('tvdb') + ); + + return { + ratingKey: metadata.ratingKey, + // This should always be set? But I guess it also cannot be? + // We will filter out the 0's afterwards + tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, + tvdbId: tvdbString ? Number(tvdbString.id.split('//')[1]) : undefined, + title: metadata.title, + type: metadata.type, + }; + }) + ); + + const filteredList = watchlistDetails.filter( + (detail) => detail?.tmdbId + ) as PlexWatchlistItem[]; + + return { + offset, + size, + totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0, + items: filteredList, + }; } public async pingToken() {