diff --git a/server/api/plextv.test.ts b/server/api/plextv.test.ts index 4bfcf80a6..246ab7094 100644 --- a/server/api/plextv.test.ts +++ b/server/api/plextv.test.ts @@ -1,12 +1,17 @@ -import PlexTvAPI, { WATCHLIST_METADATA_CONCURRENCY } from '@server/api/plextv'; +import PlexTvAPI, { + WATCHLIST_METADATA_CONCURRENCY, + type PlexWatchlistCache, +} from '@server/api/plextv'; import cacheManager from '@server/lib/cache'; import type { AxiosAdapter, + AxiosInstance, + AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, } from 'axios'; import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it } from 'node:test'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; class TestPlexTvAPI extends PlexTvAPI { public installAdapter(adapter: AxiosAdapter): void { @@ -14,6 +19,14 @@ class TestPlexTvAPI extends PlexTvAPI { } } +function getAxios(api: PlexTvAPI): AxiosInstance { + return (api as unknown as { axios: AxiosInstance }).axios; +} + +function watchlistCache() { + return cacheManager.getCache('plexwatchlist').data; +} + function axiosResponse( config: InternalAxiosRequestConfig, data: T, @@ -65,17 +78,155 @@ function isWatchlistRequest(url: string | undefined): boolean { return !!url?.includes('/library/sections/watchlist/all'); } -describe('PlexTvAPI.getWatchlist metadata fetching', () => { +describe('PlexTvAPI.getWatchlist', () => { beforeEach(() => { cacheManager.getCache('plexwatchlist').flush(); cacheManager.getCache('plextv').flush(); }); 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('limits concurrent metadata requests', async () => { const itemCount = 12; let inFlight = 0; @@ -187,4 +338,95 @@ describe('PlexTvAPI.getWatchlist metadata fetching', () => { items: [], }); }); + + 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', + }, + ], + }); + }); }); diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 20df0a50b..b2f0365c6 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; } @@ -335,8 +356,8 @@ class PlexTvAPI extends ExternalAPI { } const metadata = - detailedResponse.MediaContainer.Metadata?.[0] ?? - detailedResponse.MediaContainer.Video?.[0]; + detailedResponse.MediaContainer?.Metadata?.[0] ?? + detailedResponse.MediaContainer?.Video?.[0]; if (!metadata) { logger.warn( @@ -373,11 +394,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', @@ -386,16 +414,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, @@ -407,8 +439,16 @@ class PlexTvAPI extends ExternalAPI { ); } + 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 mapWithConcurrency( - cachedWatchlist?.response.MediaContainer.Metadata ?? [], + watchlistItems, WATCHLIST_METADATA_CONCURRENCY, (watchlistItem) => this.fetchWatchlistItemMetadata(watchlistItem) );