fix(plex): do not cache invalid Discover watchlist responses (#3406)

This commit is contained in:
0xsysr3ll
2026-10-05 21:10:02 +02:00
committed by GitHub
parent e00d685620
commit 9a5c89fb96
2 changed files with 298 additions and 16 deletions
+245 -3
View File
@@ -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<T>(
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<PlexWatchlistCache>('token-stale', {
etag: '"good"',
response: {
MediaContainer: { totalSize: 4, Metadata: [] },
},
});
mock.method(getAxios(api), 'get', async () => ({
status: 200,
headers: { etag: '"poison"' },
data: '<html>error</html>',
}));
const result = await api.getWatchlist();
assert.equal(result.totalSize, 4);
assert.equal(
watchlistCache().get<PlexWatchlistCache>('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<PlexWatchlistCache>('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',
},
],
});
});
});
+53 -13
View File
@@ -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<PlexWatchlistCache>(
this.authToken
);
try {
const watchlistCache = cacheManager.getCache('plexwatchlist');
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
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<WatchlistResponse>(
'/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)
);