mirror of
https://github.com/seerr-team/seerr.git
synced 2026-10-12 02:22:17 +00:00
fix(plex): do not cache invalid Discover watchlist responses (#3406)
This commit is contained in:
+245
-3
@@ -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
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user