mirror of
https://github.com/seerr-team/seerr.git
synced 2026-10-12 11:29:55 +00:00
433 lines
11 KiB
TypeScript
433 lines
11 KiB
TypeScript
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, mock } from 'node:test';
|
|
|
|
class TestPlexTvAPI extends PlexTvAPI {
|
|
public installAdapter(adapter: AxiosAdapter): void {
|
|
this.axios.defaults.adapter = adapter;
|
|
}
|
|
}
|
|
|
|
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,
|
|
headers: Record<string, string> = {}
|
|
): AxiosResponse<T> {
|
|
return {
|
|
data,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers,
|
|
config,
|
|
request: {},
|
|
};
|
|
}
|
|
|
|
function watchlistResponse(count: number) {
|
|
return {
|
|
MediaContainer: {
|
|
totalSize: count,
|
|
Metadata: Array.from({ length: count }, (_, index) => ({
|
|
ratingKey: `rk-${index + 1}`,
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
|
|
function metadataResponse(ratingKey: string) {
|
|
const id = ratingKey.replace('rk-', '');
|
|
|
|
return {
|
|
MediaContainer: {
|
|
Metadata: [
|
|
{
|
|
ratingKey,
|
|
type: 'movie' as const,
|
|
title: `Movie ${id}`,
|
|
Guid: [{ id: `tmdb://${id}` as const }],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
function isMetadataRequest(url: string | undefined): boolean {
|
|
return !!url?.includes('/library/metadata/');
|
|
}
|
|
|
|
function isWatchlistRequest(url: string | undefined): boolean {
|
|
return !!url?.includes('/library/sections/watchlist/all');
|
|
}
|
|
|
|
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;
|
|
let maxInFlight = 0;
|
|
|
|
const api = new TestPlexTvAPI('test-token');
|
|
api.installAdapter(async (config) => {
|
|
const url = config.url;
|
|
|
|
if (isWatchlistRequest(url)) {
|
|
return axiosResponse(config, watchlistResponse(itemCount), {
|
|
etag: 'test-etag',
|
|
});
|
|
}
|
|
|
|
if (isMetadataRequest(url)) {
|
|
inFlight++;
|
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
inFlight--;
|
|
|
|
const ratingKey = url?.split('/library/metadata/')[1] ?? '';
|
|
return axiosResponse(config, metadataResponse(ratingKey));
|
|
}
|
|
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
});
|
|
|
|
const result = await api.getWatchlist({ size: itemCount });
|
|
|
|
assert.strictEqual(result.items.length, itemCount);
|
|
assert.ok(
|
|
maxInFlight <= WATCHLIST_METADATA_CONCURRENCY,
|
|
`expected at most ${WATCHLIST_METADATA_CONCURRENCY} concurrent metadata requests, saw ${maxInFlight}`
|
|
);
|
|
});
|
|
|
|
it('returns partial results when individual metadata requests fail', async () => {
|
|
const itemCount = 8;
|
|
|
|
const api = new TestPlexTvAPI('test-token');
|
|
api.installAdapter(async (config) => {
|
|
const url = config.url;
|
|
|
|
if (isWatchlistRequest(url)) {
|
|
return axiosResponse(config, watchlistResponse(itemCount), {
|
|
etag: 'test-etag',
|
|
});
|
|
}
|
|
|
|
if (isMetadataRequest(url)) {
|
|
const ratingKey = url?.split('/library/metadata/')[1] ?? '';
|
|
|
|
if (Number(ratingKey.replace('rk-', '')) % 2 === 0) {
|
|
const error = new Error('connect ETIMEDOUT') as Error & {
|
|
code: string;
|
|
};
|
|
error.code = 'ETIMEDOUT';
|
|
throw error;
|
|
}
|
|
|
|
return axiosResponse(config, metadataResponse(ratingKey));
|
|
}
|
|
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
});
|
|
|
|
const result = await api.getWatchlist({ size: itemCount });
|
|
|
|
assert.strictEqual(result.items.length, 4);
|
|
assert.ok(
|
|
result.items.every((item) => Number(item.tmdbId) % 2 === 1),
|
|
'only odd-numbered watchlist items should be returned'
|
|
);
|
|
});
|
|
|
|
it('fails the sync when metadata requests return auth errors', async () => {
|
|
const itemCount = 3;
|
|
|
|
const api = new TestPlexTvAPI('test-token');
|
|
api.installAdapter(async (config) => {
|
|
const url = config.url;
|
|
|
|
if (isWatchlistRequest(url)) {
|
|
return axiosResponse(config, watchlistResponse(itemCount), {
|
|
etag: 'test-etag',
|
|
});
|
|
}
|
|
|
|
if (isMetadataRequest(url)) {
|
|
const error = new Error(
|
|
'Request failed with status code 401'
|
|
) as Error & {
|
|
response: { status: number };
|
|
};
|
|
error.response = { status: 401 };
|
|
throw error;
|
|
}
|
|
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
});
|
|
|
|
const result = await api.getWatchlist({ size: itemCount });
|
|
|
|
assert.deepStrictEqual(result, {
|
|
offset: 0,
|
|
size: itemCount,
|
|
totalSize: 0,
|
|
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',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|