diff --git a/seerr-api.yml b/seerr-api.yml index 4b33bf7892..89058d0ca7 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6257,6 +6257,94 @@ paths: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/PersonResult' + /discover/list/{listId}: + get: + summary: Get the contents of a TMDB list + description: >- + Returns a page of the movies and series contained in a public TMDB list, + in the order defined by the list owner, in a JSON object. Only the + requested page is fetched upstream; totalPages and totalResults describe + the whole list. The language is resolved server-side from the requesting + user's locale and cannot be overridden. When the list has been deleted or + is no longer public, the response is a 200 with an empty results array + and list.unavailable set to true. + tags: + - search + parameters: + - in: path + name: listId + required: true + description: Numeric ID of a public TMDB list. + schema: + type: string + pattern: '^[1-9]\d{0,11}$' + example: '8542986' + - in: query + name: page + schema: + type: integer + minimum: 1 + example: 1 + default: 1 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + required: + - page + - totalPages + - totalResults + - list + - results + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + list: + type: object + required: + - providerId + - listId + properties: + providerId: + type: string + example: tmdb + listId: + type: string + example: '8542986' + name: + type: string + example: My Watchlist + description: + type: string + example: Movies and series I want to watch + unavailable: + type: boolean + description: >- + Present and true when the list itself could not be + read, which is distinct from a list with no entries. + example: true + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + '400': + description: Invalid list ID or page + '403': + description: Unauthenticated + '500': + description: Unable to retrieve the list from the upstream provider /discover/keyword/{keywordId}/movies: get: summary: Get movies from keyword diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 305d6048d7..df10e669d8 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -12,6 +12,7 @@ import type { TmdbKeyword, TmdbKeywordSearchResponse, TmdbLanguage, + TmdbListResponse, TmdbMovieDetails, TmdbNetwork, TmdbPersonCombinedCredits, @@ -1063,6 +1064,40 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } } + /** + * Fetches a single page of a TMDB list. + * + * The v3 `/list/{listId}` endpoint reads both classic v3 lists and lists + * created through the v4 API (as long as they are public), so no v4 read + * token is required. Returns `null` when the list does not exist or is not + * publicly readable. + */ + public async getList({ + listId, + page = 1, + language = this.locale, + }: { + listId: number; + page?: number; + language?: string; + }): Promise { + try { + const data = await this.get(`/list/${listId}`, { + params: { page, language }, + }); + + return data; + } catch (e) { + // 401 is returned for private lists, 404 for lists that do not exist + if (e.response?.status === 401 || e.response?.status === 404) { + return null; + } + throw new Error(`[TMDB] Failed to fetch list: ${e.message}`, { + cause: e, + }); + } + } + public async getMovieGenres({ language = this.locale, }: { diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index a4c49e9ab0..eb0b445077 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -87,6 +87,33 @@ export interface TmdbExternalIdResponse { person_results: TmdbPersonResult[]; } +/** + * A single entry of a TMDB list. + * + * v4 lists can hold both movies and series, so their items carry their own + * `media_type`. Classic v3 lists are movie-only and omit the field entirely, + * which is why it is optional here: a missing `media_type` means a movie. + */ +export type TmdbListItem = + | TmdbTvResult + | (Omit & { media_type?: 'movie' }); + +/** + * Response shape of `GET /3/list/{listId}`. + * + * The v3 endpoint serves both classic v3 lists and the newer v4 lists. + */ +export interface TmdbListResponse extends TmdbPaginatedResponse { + id: number; + name: string; + description?: string; + created_by?: string; + iso_639_1?: string; + poster_path?: string; + item_count: number; + items: TmdbListItem[]; +} + export interface TmdbCreditCast { cast_id: number; character: string; diff --git a/server/constants/discover.ts b/server/constants/discover.ts index fda0682243..8a14c1677f 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -1,5 +1,9 @@ import type DiscoverSlider from '@server/entity/DiscoverSlider'; +/** + * These values are persisted in the database. Only ever append new members to + * this enum; never reorder or remove existing ones. + */ export enum DiscoverSliderType { RECENTLY_ADDED = 1, RECENT_REQUESTS, @@ -22,6 +26,7 @@ export enum DiscoverSliderType { TMDB_NETWORK, TMDB_MOVIE_STREAMING_SERVICES, TMDB_TV_STREAMING_SERVICES, + TMDB_LIST, } export const defaultSliders: Partial[] = [ diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 64b5c79ee8..ee555e9b11 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -10,7 +10,8 @@ export type AvailableCacheIds = | 'plexguid' | 'plextv' | 'plexwatchlist' - | 'tvdb'; + | 'tvdb' + | 'medialist'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -75,6 +76,14 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), + // Holds one entry per list page. Entries stay resident for 48 hours so that + // a stale page can still be served when the upstream provider is + // unreachable. Freshness, single-flight and the entry ceiling are handled + // by the media list service. + medialist: new Cache('medialist', 'Media Lists', { + stdTtl: 86400 * 2, + checkPeriod: 60 * 30, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/medialists/index.ts b/server/lib/medialists/index.ts new file mode 100644 index 0000000000..adf6d6feda --- /dev/null +++ b/server/lib/medialists/index.ts @@ -0,0 +1,321 @@ +import TheMovieDb from '@server/api/themoviedb'; +import Media from '@server/entity/Media'; +import type { User } from '@server/entity/User'; +import cacheManager from '@server/lib/cache'; +import { TmdbListProvider } from '@server/lib/medialists/providers/tmdb'; +import type { + HydratedMediaListItem, + HydratedMediaListPage, + MediaListItem, + MediaListProvider, + MediaListResponse, +} from '@server/lib/medialists/types'; +import logger from '@server/logger'; +import { + mapMovieDetailsToResult, + mapMovieResult, + mapTvDetailsToResult, + mapTvResult, +} from '@server/models/Search'; + +/** Serve the cached page without refetching for this long. */ +const FRESH_TTL_MS = 6 * 60 * 60 * 1000; + +/** How many missing TMDB payloads we resolve concurrently during hydration. */ +const HYDRATION_CONCURRENCY = 10; + +/** + * Upper bound on the number of cached list pages. The cache is keyed partly by + * request-controlled values (list id, page), so it needs a ceiling; the oldest + * entries are dropped first. + */ +export const MAX_MEDIA_LIST_CACHE_ENTRIES = 500; + +/** Well-formed BCP-47-ish tags, which is all TMDB accepts. */ +const LANGUAGE_REGEX = /^([a-z]{2,3})(?:-([a-z]{2}))?$/i; + +const providers: Record = { + tmdb: new TmdbListProvider(), +}; + +export const getMediaListProvider = ( + providerId: string +): MediaListProvider | undefined => providers[providerId]; + +/** + * Reduces a locale to a canonical form so that the cache key can only ever take + * one of a small number of values. Anything unrecognised collapses to the empty + * string, which lets the TMDB client fall back to its configured locale. + */ +export const normalizeMediaListLanguage = (language?: string): string => { + const match = LANGUAGE_REGEX.exec((language ?? '').trim()); + + if (!match) { + return ''; + } + + return match[2] + ? `${match[1].toLowerCase()}-${match[2].toUpperCase()}` + : match[1].toLowerCase(); +}; + +export const getMediaListCacheKey = ( + providerId: string, + listId: string, + language: string, + page: number +): string => `${providerId}:${listId}:${language}:${page}`; + +interface CachedMediaListPage { + /** `null` records a list that is missing or private, so we stop hammering. */ + page: HydratedMediaListPage | null; + fetchedAt: number; + /** Set once we have logged the unavailability, to keep the log to one line. */ + loggedUnavailable?: boolean; +} + +const inFlight = new Map>(); + +/** + * Fills in the TMDB payload for any item a provider could only resolve to an + * id. Providers backed by TMDB's own lists return fully populated items, so + * this is a no-op for them. Items that cannot be resolved are dropped rather + * than rendered as blanks. + */ +const hydrateItems = async ( + items: MediaListItem[], + language?: string +): Promise => { + const missing = items.filter((item) => !item.tmdbResult); + + if (missing.length === 0) { + return items as HydratedMediaListItem[]; + } + + const tmdb = new TheMovieDb(); + const resolved = new Map(); + + for (let i = 0; i < missing.length; i += HYDRATION_CONCURRENCY) { + const chunk = missing.slice(i, i + HYDRATION_CONCURRENCY); + + await Promise.all( + chunk.map(async (item) => { + try { + const hydrated: HydratedMediaListItem = + item.mediaType === 'movie' + ? { + mediaType: 'movie', + tmdbId: item.tmdbId, + tmdbResult: mapMovieDetailsToResult( + await tmdb.getMovie({ movieId: item.tmdbId, language }) + ), + } + : { + mediaType: 'tv', + tmdbId: item.tmdbId, + tmdbResult: mapTvDetailsToResult( + await tmdb.getTvShow({ tvId: item.tmdbId, language }) + ), + }; + + resolved.set(`${item.mediaType}:${item.tmdbId}`, hydrated); + } catch (e) { + logger.debug('Failed to hydrate media list item', { + label: 'Media List', + mediaType: item.mediaType, + tmdbId: item.tmdbId, + errorMessage: e.message, + }); + } + }) + ); + } + + return items.reduce((acc, item) => { + if (item.tmdbResult) { + acc.push(item as HydratedMediaListItem); + return acc; + } + + const hydrated = resolved.get(`${item.mediaType}:${item.tmdbId}`); + if (hydrated) { + acc.push(hydrated); + } + + return acc; + }, []); +}; + +/** + * Keeps the cache under `MAX_MEDIA_LIST_CACHE_ENTRIES` by dropping the least recently + * fetched pages, so that a burst of distinct list/page combinations cannot grow + * it without bound. + */ +const evictOverflow = (reservedKey: string): void => { + const cache = cacheManager.getCache('medialist').data; + const keys = cache.keys().filter((key) => key !== reservedKey); + + const overflow = keys.length - (MAX_MEDIA_LIST_CACHE_ENTRIES - 1); + + if (overflow <= 0) { + return; + } + + keys + .map((key) => ({ + key, + fetchedAt: cache.get(key)?.fetchedAt ?? 0, + })) + .sort((a, b) => a.fetchedAt - b.fetchedAt) + .slice(0, overflow) + .forEach(({ key }) => cache.del(key)); +}; + +const fetchAndCache = async ( + provider: MediaListProvider, + listId: string, + page: number, + language: string, + cacheKey: string, + stale: CachedMediaListPage | undefined +): Promise => { + const cache = cacheManager.getCache('medialist').data; + + try { + const listPage = await provider.fetchListPage(listId, { + page, + language: language || undefined, + }); + + const entry: CachedMediaListPage = { + page: listPage + ? { ...listPage, items: await hydrateItems(listPage.items, language) } + : null, + fetchedAt: Date.now(), + loggedUnavailable: stale?.loggedUnavailable, + }; + + if (!listPage && !entry.loggedUnavailable) { + logger.warn( + 'Media list is unavailable; it may have been deleted or made private. The slider will be hidden.', + { label: 'Media List', provider: provider.id, listId } + ); + entry.loggedUnavailable = true; + } + + evictOverflow(cacheKey); + cache.set(cacheKey, entry); + return entry; + } catch (e) { + // Serve the stale copy rather than failing the whole discover page. + if (stale) { + logger.debug('Failed to refresh media list; serving stale data', { + label: 'Media List', + provider: provider.id, + listId, + page, + errorMessage: e.message, + }); + return stale; + } + + throw e; + } +}; + +const getCachedListPage = async ( + provider: MediaListProvider, + listId: string, + page: number, + language: string +): Promise => { + const cacheKey = getMediaListCacheKey(provider.id, listId, language, page); + const cache = cacheManager.getCache('medialist').data; + const cached = cache.get(cacheKey); + + if (cached && Date.now() - cached.fetchedAt < FRESH_TTL_MS) { + return cached.page; + } + + // Single-flight: concurrent requests for the same page share one fetch. + const existing = inFlight.get(cacheKey); + if (existing) { + return (await existing).page; + } + + const pending = fetchAndCache( + provider, + listId, + page, + language, + cacheKey, + cached + ).finally(() => { + inFlight.delete(cacheKey); + }); + + inFlight.set(cacheKey, pending); + + return (await pending).page; +}; + +/** + * Resolves a single page of a provider list, mapped into the same shape the + * discover endpoints return so that `MediaSlider` can consume it directly. + * + * Only the requested page is fetched from, and cached for, the provider; the + * local availability of each title is resolved per request because it depends + * on the user. + */ +export const getMediaListPage = async ({ + provider, + listId, + page = 1, + language, + user, +}: { + provider: MediaListProvider; + listId: string; + page?: number; + language?: string; + user?: User; +}): Promise => { + const normalizedLanguage = normalizeMediaListLanguage(language); + const listPage = await getCachedListPage( + provider, + listId, + page, + normalizedLanguage + ); + const items = listPage?.items ?? []; + + const media = await Media.getRelatedMedia( + user, + items.map((item) => ({ + tmdbId: item.tmdbId, + mediaType: item.mediaType, + })) + ); + + return { + page: listPage?.page ?? page, + totalPages: Math.max(1, listPage?.totalPages ?? 1), + totalResults: listPage?.totalResults ?? 0, + list: { + providerId: provider.id, + listId, + name: listPage?.name, + description: listPage?.description, + ...(listPage ? {} : { unavailable: true }), + }, + results: items.map((item) => { + const relatedMedia = media.find( + (m) => m.tmdbId === item.tmdbId && m.mediaType === item.mediaType + ); + + return item.mediaType === 'movie' + ? mapMovieResult(item.tmdbResult, relatedMedia) + : mapTvResult(item.tmdbResult, relatedMedia); + }), + }; +}; diff --git a/server/lib/medialists/providers/tmdb.ts b/server/lib/medialists/providers/tmdb.ts new file mode 100644 index 0000000000..dc43e47f9e --- /dev/null +++ b/server/lib/medialists/providers/tmdb.ts @@ -0,0 +1,136 @@ +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbListResponse } from '@server/api/themoviedb/interfaces'; +import type { + MediaListItem, + MediaListPage, + MediaListProvider, +} from '@server/lib/medialists/types'; + +/** + * TMDB list ids are plain integers. Anything else is rejected before we make an + * outbound request, which keeps admin-supplied slider data from being used to + * build arbitrary URLs. + */ +const TMDB_LIST_ID_REGEX = /^[1-9]\d{0,11}$/; + +const isCount = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 0; + +const isTmdbId = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0; + +class MalformedListResponseError extends Error { + constructor(reason: string) { + super(`[TMDB] Malformed list response: ${reason}`); + this.name = 'MalformedListResponseError'; + } +} + +export class TmdbListProvider implements MediaListProvider { + public readonly id = 'tmdb'; + + public validateListId(listId: string): boolean { + return TMDB_LIST_ID_REGEX.test(listId); + } + + public async fetchListPage( + listId: string, + { page, language }: { page: number; language?: string } + ): Promise { + if (!this.validateListId(listId)) { + return null; + } + + const tmdb = new TheMovieDb(); + + const response = await tmdb.getList({ + listId: Number(listId), + page, + language, + }); + + if (!response) { + return null; + } + + return this.mapResponse(listId, response); + } + + /** + * Validates the upstream payload before it is allowed anywhere near the cache. + * A response that does not match the documented shape is treated as an + * upstream failure (thrown), never as an empty or partially usable list. + */ + private mapResponse( + listId: string, + response: TmdbListResponse + ): MediaListPage { + if (typeof response !== 'object') { + throw new MalformedListResponseError('response is not an object'); + } + + // `page` is 1-based; `total_pages`/`total_results` are 0 for an empty list. + if (!isTmdbId(response.page)) { + throw new MalformedListResponseError('page is not a positive integer'); + } + + if (!isCount(response.total_pages) || !isCount(response.total_results)) { + throw new MalformedListResponseError( + 'total_pages/total_results are not counts' + ); + } + + if (!Array.isArray(response.items)) { + throw new MalformedListResponseError('items is not an array'); + } + + const items = response.items.reduce((acc, item) => { + if (typeof item !== 'object' || item === null) { + throw new MalformedListResponseError('item is not an object'); + } + + if (!isTmdbId(item.id)) { + throw new MalformedListResponseError( + 'item id is not a positive integer' + ); + } + + if (item.media_type === 'tv') { + acc.push({ mediaType: 'tv', tmdbId: item.id, tmdbResult: item }); + } else if (item.media_type === 'movie' || item.media_type === undefined) { + // Classic v3 lists hold movies only and leave media_type out entirely, + // so an absent discriminator is a movie rather than a broken payload. + acc.push({ + mediaType: 'movie', + tmdbId: item.id, + tmdbResult: { ...item, media_type: 'movie' }, + }); + } else { + throw new MalformedListResponseError( + `unsupported item media_type "${ + (item as { media_type?: unknown }).media_type + }"` + ); + } + + return acc; + }, []); + + return { + providerId: this.id, + listId, + name: typeof response.name === 'string' ? response.name : undefined, + description: + typeof response.description === 'string' && response.description + ? response.description + : undefined, + page: response.page, + totalPages: response.total_pages, + totalResults: response.total_results, + // TMDB returns the items in the order defined by the list owner; we keep it. + items, + }; + } +} + +export default TmdbListProvider; diff --git a/server/lib/medialists/types.ts b/server/lib/medialists/types.ts new file mode 100644 index 0000000000..d4c41984d8 --- /dev/null +++ b/server/lib/medialists/types.ts @@ -0,0 +1,91 @@ +import type { + TmdbMovieResult, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; +import type { MovieResult, TvResult } from '@server/models/Search'; + +export type MediaListMediaType = 'movie' | 'tv'; + +/** + * A single entry of a media list. + * + * Providers are only required to resolve `mediaType` and `tmdbId`. When a + * provider already receives the full TMDB payload (as is the case for TMDB's + * own lists), it should pass it along as `tmdbResult` so that hydration does + * not have to issue an extra request per item. + */ +export type MediaListItem = + | { mediaType: 'movie'; tmdbId: number; tmdbResult?: TmdbMovieResult } + | { mediaType: 'tv'; tmdbId: number; tmdbResult?: TmdbTvResult }; + +/** A media list item whose TMDB payload has been resolved. */ +export type HydratedMediaListItem = + | { mediaType: 'movie'; tmdbId: number; tmdbResult: TmdbMovieResult } + | { mediaType: 'tv'; tmdbId: number; tmdbResult: TmdbTvResult }; + +/** + * One page of a provider list, in the order defined by the list owner. + * + * `totalPages` and `totalResults` describe the whole list and are reported by + * the provider; `items` only ever holds the entries of `page`. + */ +export interface MediaListPage { + providerId: string; + listId: string; + name?: string; + description?: string; + /** 1-based index of the page `items` was taken from. */ + page: number; + totalPages: number; + totalResults: number; + items: TItem[]; +} + +export type HydratedMediaListPage = MediaListPage; + +/** + * A source of media lists. Implement this interface to add support for other + * list sources (Trakt, MDBList, …). Providers must never accept arbitrary URLs: + * `validateListId` is the single place where the untrusted, admin-supplied + * slider data is checked before any outbound request is made. + */ +export interface MediaListProvider { + readonly id: string; + + /** Returns true when `listId` is a well-formed identifier for this provider. */ + validateListId(listId: string): boolean; + + /** + * Resolves a *single page* of the list, never the whole list: implementations + * must request only `page` upstream and report the list-wide `totalPages` and + * `totalResults` from the upstream response. + * + * Returns `null` when the list does not exist or is not publicly readable. + * Throws when the upstream request fails or answers with a payload that does + * not match the documented shape, so that callers can fall back to a stale + * copy instead of caching garbage. + */ + fetchListPage( + listId: string, + options: { page: number; language?: string } + ): Promise; +} + +/** A page of a media list, shaped for consumption by `MediaSlider`. */ +export interface MediaListResponse { + page: number; + totalPages: number; + totalResults: number; + list: { + providerId: string; + listId: string; + name?: string; + description?: string; + /** + * Set when the list itself could not be read (deleted, or no longer + * public). Distinguishes that case from a list that simply has no entries. + */ + unavailable?: boolean; + }; + results: (MovieResult | TvResult)[]; +} diff --git a/server/routes/discover.test.ts b/server/routes/discover.test.ts new file mode 100644 index 0000000000..b3274cac20 --- /dev/null +++ b/server/routes/discover.test.ts @@ -0,0 +1,751 @@ +import assert from 'node:assert/strict'; +import { before, beforeEach, describe, it, mock } from 'node:test'; + +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbListResponse } from '@server/api/themoviedb/interfaces'; +import { DiscoverSliderType } from '@server/constants/discover'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import cacheManager from '@server/lib/cache'; +import { + MAX_MEDIA_LIST_CACHE_ENTRIES, + getMediaListCacheKey, + getMediaListPage, + getMediaListProvider, + normalizeMediaListLanguage, +} from '@server/lib/medialists'; +import type { MediaListProvider } from '@server/lib/medialists/types'; +import { setupTestDb } from '@server/test/db'; +import type { Express } from 'express'; +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import authRoutes from './auth'; +import discoverRoutes from './discover'; +import discoverSettingRoutes from './settings/discover'; + +import { getSettings } from '@server/lib/settings'; +import { checkUser, isAuthenticated } from '@server/middleware/auth'; + +const MOVIE_ITEM = { + id: 603, + media_type: 'movie' as const, + title: 'The Matrix', + original_title: 'The Matrix', + release_date: '1999-03-30', + adult: false, + video: false, + popularity: 50, + poster_path: '/poster-movie.jpg', + backdrop_path: '/backdrop-movie.jpg', + vote_count: 100, + vote_average: 8.2, + genre_ids: [28], + overview: 'A hacker learns the truth.', + original_language: 'en', +}; + +const TV_ITEM = { + id: 14929, + media_type: 'tv' as const, + name: 'Heartland', + original_name: 'Heartland', + first_air_date: '2007-10-14', + origin_country: ['CA'], + popularity: 34, + poster_path: '/poster-tv.jpg', + backdrop_path: '/backdrop-tv.jpg', + vote_count: 580, + vote_average: 8.3, + genre_ids: [18], + overview: 'Life on a ranch.', + original_language: 'en', +}; + +const listResponse = ( + overrides: Partial = {} +): TmdbListResponse => ({ + id: 8542986, + name: 'My Mixed List', + description: 'A list with both movies and series', + item_count: 2, + page: 1, + total_pages: 1, + total_results: 2, + // TV first, so we can assert that the list order is preserved + items: [TV_ITEM, MOVIE_ITEM], + ...overrides, +}); + +const LIST_ID = '8542986'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + // Keep an unhandled rejection from taking the test process down before the + // assertion that awaits it runs. + promise.catch(() => undefined); + + return { promise, resolve, reject }; +}; + +const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)); + +const tmdbProvider = (): MediaListProvider => { + const provider = getMediaListProvider('tmdb'); + assert.ok(provider); + return provider; +}; + +const cachedListKeys = () => cacheManager.getCache('medialist').data.keys(); + +let getListMock: ReturnType['mock']; +let app: Express; + +function createApp(queryParser?: 'simple' | 'extended') { + const app = express(); + + if (queryParser) { + app.set('query parser', queryParser); + } + + app.use(express.json()); + app.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + }) + ); + app.use(checkUser); + app.use('/auth', authRoutes); + // Mirrors how the router mounts discover in production. + app.use('/discover', isAuthenticated(), discoverRoutes); + app.use('/settings/discover', discoverSettingRoutes); + app.use( + ( + err: { status?: number; message?: string }, + _req: express.Request, + res: express.Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _next: express.NextFunction + ) => { + res + .status(err.status ?? 500) + .json({ status: err.status ?? 500, message: err.message }); + } + ); + return app; +} + +before(async () => { + app = createApp(); + getListMock = mock.method(TheMovieDb.prototype, 'getList', async () => + listResponse() + ).mock; +}); + +beforeEach(() => { + getListMock.resetCalls(); + getListMock.mockImplementation(async () => listResponse()); + cacheManager.getCache('medialist').flush(); +}); + +setupTestDb(); + +async function loginAsAdmin(target: Express = app) { + const settings = getSettings(); + const priorLocalLogin = settings.main.localLogin; + settings.main.localLogin = true; + + try { + const agent = request.agent(target); + const res = await agent + .post('/auth/local') + .send({ email: 'admin@seerr.dev', password: 'test1234' }); + assert.strictEqual(res.status, 200); + return agent; + } finally { + settings.main.localLogin = priorLocalLogin; + } +} + +describe('GET /discover/list/:listId', () => { + it('rejects an unauthenticated request', async () => { + const res = await request(app).get(`/discover/list/${LIST_ID}`); + + assert.strictEqual(res.status, 403); + assert.strictEqual(getListMock.callCount(), 0); + }); + + it('returns mixed movie/tv results in list order', async () => { + const agent = await loginAsAdmin(); + const res = await agent.get(`/discover/list/${LIST_ID}`); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.totalResults, 2); + assert.strictEqual(res.body.totalPages, 1); + assert.strictEqual(res.body.list.providerId, 'tmdb'); + assert.strictEqual(res.body.list.listId, LIST_ID); + assert.strictEqual(res.body.list.name, 'My Mixed List'); + assert.strictEqual(res.body.list.unavailable, undefined); + + assert.deepStrictEqual( + res.body.results.map((r: { id: number; mediaType: string }) => [ + r.mediaType, + r.id, + ]), + [ + ['tv', 14929], + ['movie', 603], + ] + ); + }); + + it('reads a classic v3 list whose items carry no media_type', async () => { + const movieWithoutMediaType: Record = { ...MOVIE_ITEM }; + delete movieWithoutMediaType.media_type; + + getListMock.mockImplementation(async () => + listResponse({ + item_count: 1, + total_results: 1, + items: [movieWithoutMediaType], + } as unknown as Partial) + ); + + const agent = await loginAsAdmin(); + const res = await agent.get(`/discover/list/${LIST_ID}`); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.results.length, 1); + assert.strictEqual(res.body.results[0].mediaType, 'movie'); + assert.strictEqual(res.body.results[0].id, 603); + }); + + it('attaches local media info to results', async () => { + await getRepository(Media).save( + new Media({ + mediaType: MediaType.MOVIE, + tmdbId: 603, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }) + ); + + const agent = await loginAsAdmin(); + const res = await agent.get(`/discover/list/${LIST_ID}`); + + assert.strictEqual(res.status, 200); + const movie = res.body.results.find( + (r: { mediaType: string }) => r.mediaType === 'movie' + ); + assert.strictEqual(movie.mediaInfo.status, MediaStatus.AVAILABLE); + }); + + it('rejects a malformed list id without contacting the provider', async () => { + const agent = await loginAsAdmin(); + + for (const listId of [ + 'abc', + '..%2F..%2Fetc', + 'http:%2F%2Fevil.test', + '0', + '1a', + '1234567890123', + ]) { + const res = await agent.get(`/discover/list/${listId}`); + assert.strictEqual(res.status, 400, `expected 400 for "${listId}"`); + } + + assert.strictEqual(getListMock.callCount(), 0); + }); + + it('rejects a malformed page without contacting the provider', async () => { + const agent = await loginAsAdmin(); + + const queries = [ + 'page=0', + 'page=-1', + 'page=1.5', + 'page=Infinity', + 'page=abc', + 'page=', + 'page=99999999999999999999', + // Repeated keys make `req.query.page` an array under every query parser. + 'page=1&page=2', + ]; + + for (const query of queries) { + const res = await agent.get(`/discover/list/${LIST_ID}?${query}`); + assert.strictEqual(res.status, 400, `expected 400 for "${query}"`); + } + + assert.strictEqual(getListMock.callCount(), 0); + }); + + it('rejects array and object page values under an extended query parser', async () => { + const agent = await loginAsAdmin(createApp('extended')); + + for (const query of ['page[]=1', 'page[foo]=1']) { + const res = await agent.get(`/discover/list/${LIST_ID}?${query}`); + assert.strictEqual(res.status, 400, `expected 400 for "${query}"`); + } + + assert.strictEqual(getListMock.callCount(), 0); + }); + + it('fetches only the requested page and reports upstream totals', async () => { + getListMock.mockImplementation(async ({ page }: { page: number }) => + listResponse({ + page, + item_count: 812, + total_pages: 41, + total_results: 812, + items: [MOVIE_ITEM], + }) + ); + + const agent = await loginAsAdmin(); + const res = await agent.get(`/discover/list/${LIST_ID}?page=7`); + + assert.strictEqual(res.status, 200); + // Totals come from the upstream response, not from a truncated array. + assert.strictEqual(res.body.totalResults, 812); + assert.strictEqual(res.body.totalPages, 41); + assert.strictEqual(res.body.page, 7); + + assert.strictEqual(getListMock.callCount(), 1); + assert.strictEqual( + (getListMock.calls[0].arguments[0] as { page: number }).page, + 7 + ); + }); + + it('caches each page separately and serves repeats from the cache', async () => { + getListMock.mockImplementation(async ({ page }: { page: number }) => + listResponse({ page, total_pages: 3, total_results: 45 }) + ); + + const agent = await loginAsAdmin(); + + await agent.get(`/discover/list/${LIST_ID}?page=1`); + await agent.get(`/discover/list/${LIST_ID}?page=2`); + assert.strictEqual(getListMock.callCount(), 2); + + await agent.get(`/discover/list/${LIST_ID}?page=1`); + await agent.get(`/discover/list/${LIST_ID}?page=2`); + assert.strictEqual(getListMock.callCount(), 2); + assert.strictEqual(cachedListKeys().length, 2); + }); + + it('does not fetch the whole list to serve one page', async () => { + getListMock.mockImplementation(async ({ page }: { page: number }) => + listResponse({ page, total_pages: 100, total_results: 2000 }) + ); + + const agent = await loginAsAdmin(); + await agent.get(`/discover/list/${LIST_ID}?page=1`); + + assert.strictEqual(getListMock.callCount(), 1); + }); + + it('reports an unavailable list without failing the page', async () => { + getListMock.mockImplementation(async () => null); + + const agent = await loginAsAdmin(); + const res = await agent.get(`/discover/list/${LIST_ID}`); + + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(res.body.results, []); + assert.strictEqual(res.body.totalResults, 0); + assert.strictEqual(res.body.totalPages, 1); + assert.strictEqual(res.body.list.unavailable, true); + }); + + it('returns 500 when the provider fails and nothing is cached', async () => { + getListMock.mockImplementation(async () => { + throw new Error('TMDB is down'); + }); + + const agent = await loginAsAdmin(); + const res = await agent.get(`/discover/list/${LIST_ID}`); + + assert.strictEqual(res.status, 500); + }); +}); + +describe('media list caching', () => { + const ageCacheEntry = (ms: number) => { + const cache = cacheManager.getCache('medialist').data; + const [cacheKey] = cache.keys(); + const entry = cache.get<{ fetchedAt: number }>(cacheKey); + assert.ok(entry); + cache.set(cacheKey, { ...entry, fetchedAt: Date.now() - ms }); + return cacheKey; + }; + + it('collapses concurrent requests into a single upstream fetch', async () => { + const gate = createDeferred(); + getListMock.mockImplementation(async () => gate.promise); + + // Every caller is started before anything resolves, so the assertion below + // can only hold if they genuinely share one in-flight fetch. + const pending = [1, 2, 3].map(() => + getMediaListPage({ provider: tmdbProvider(), listId: LIST_ID }) + ); + + await flushMicrotasks(); + assert.strictEqual(getListMock.callCount(), 1); + + gate.resolve(listResponse()); + const results = await Promise.all(pending); + + assert.strictEqual(getListMock.callCount(), 1); + results.forEach((result) => assert.strictEqual(result.totalResults, 2)); + }); + + it('drops a rejected in-flight fetch so the next request retries', async () => { + const gate = createDeferred(); + getListMock.mockImplementation(async () => gate.promise); + + const failing = getMediaListPage({ + provider: tmdbProvider(), + listId: LIST_ID, + }); + + await flushMicrotasks(); + gate.reject(new Error('TMDB is down')); + await assert.rejects(failing); + + getListMock.mockImplementation(async () => listResponse()); + const retried = await getMediaListPage({ + provider: tmdbProvider(), + listId: LIST_ID, + }); + + assert.strictEqual(retried.totalResults, 2); + assert.strictEqual(getListMock.callCount(), 2); + }); + + it('refreshes and replaces the content once the entry is no longer fresh', async () => { + const agent = await loginAsAdmin(); + + const fresh = await agent.get(`/discover/list/${LIST_ID}`); + assert.strictEqual(fresh.body.list.name, 'My Mixed List'); + + ageCacheEntry(7 * 60 * 60 * 1000); + + getListMock.mockImplementation(async () => + listResponse({ + name: 'Renamed List', + item_count: 1, + total_results: 1, + items: [MOVIE_ITEM], + }) + ); + + const refreshed = await agent.get(`/discover/list/${LIST_ID}`); + assert.strictEqual(refreshed.body.list.name, 'Renamed List'); + assert.strictEqual(refreshed.body.totalResults, 1); + assert.strictEqual(getListMock.callCount(), 2); + }); + + it('serves stale data when the provider fails after the entry goes stale', async () => { + const agent = await loginAsAdmin(); + + const fresh = await agent.get(`/discover/list/${LIST_ID}`); + assert.strictEqual(fresh.body.totalResults, 2); + + ageCacheEntry(7 * 60 * 60 * 1000); + + getListMock.mockImplementation(async () => { + throw new Error('TMDB is down'); + }); + + const stale = await agent.get(`/discover/list/${LIST_ID}`); + assert.strictEqual(stale.status, 200); + assert.strictEqual(stale.body.totalResults, 2); + assert.strictEqual(getListMock.callCount(), 2); + }); + + it('does not serve an entry that has outlived its residency window', async () => { + const agent = await loginAsAdmin(); + await agent.get(`/discover/list/${LIST_ID}`); + + // Expire the entry the same way node-cache would once the 48h TTL elapses. + const cache = cacheManager.getCache('medialist').data; + const [cacheKey] = cache.keys(); + cache.set(cacheKey, cache.get(cacheKey), -1); + assert.strictEqual(cache.get(cacheKey), undefined); + + getListMock.mockImplementation(async () => { + throw new Error('TMDB is down'); + }); + + const res = await agent.get(`/discover/list/${LIST_ID}`); + assert.strictEqual(res.status, 500); + }); + + it('shares one failed refresh between concurrent stale callers', async () => { + await getMediaListPage({ provider: tmdbProvider(), listId: LIST_ID }); + assert.strictEqual(getListMock.callCount(), 1); + + ageCacheEntry(7 * 60 * 60 * 1000); + + const gate = createDeferred(); + getListMock.mockImplementation(async () => gate.promise); + + const pending = [1, 2, 3].map(() => + getMediaListPage({ provider: tmdbProvider(), listId: LIST_ID }) + ); + + await flushMicrotasks(); + assert.strictEqual(getListMock.callCount(), 2); + + gate.reject(new Error('TMDB is down')); + const results = await Promise.all(pending); + + // One shared refresh, and every caller gets the stale copy. + assert.strictEqual(getListMock.callCount(), 2); + results.forEach((result) => assert.strictEqual(result.totalResults, 2)); + }); +}); + +describe('media list cache bounds', () => { + it('canonicalises the language before it reaches the cache key', () => { + for (const [input, expected] of [ + ['en', 'en'], + ['EN', 'en'], + [' en ', 'en'], + ['fil', 'fil'], + ['en-us', 'en-US'], + ['EN-US', 'en-US'], + ['pt-BR', 'pt-BR'], + ] as [string, string][]) { + assert.strictEqual( + normalizeMediaListLanguage(input), + expected, + `expected "${input}" to normalize to "${expected}"` + ); + } + + // Anything unrecognised collapses to one key rather than minting its own. + for (const junk of [ + undefined, + '', + 'not-a-locale', + '../../etc', + 'en_US', + 'en-USA', + 'e'.repeat(200), + ]) { + assert.strictEqual( + normalizeMediaListLanguage(junk), + '', + `expected "${junk}" to normalize to ""` + ); + } + }); + + it('gives locales that differ only in case or padding one cache entry', async () => { + const provider = tmdbProvider(); + + for (const language of ['en', 'EN', ' en ']) { + await getMediaListPage({ provider, listId: LIST_ID, language }); + } + + assert.strictEqual(getListMock.callCount(), 1); + assert.deepStrictEqual(cachedListKeys(), [ + getMediaListCacheKey('tmdb', LIST_ID, 'en', 1), + ]); + + // A genuinely different locale is a different list, and an unusable one + // falls back to the single empty-language key. + await getMediaListPage({ provider, listId: LIST_ID, language: 'en-us' }); + await getMediaListPage({ provider, listId: LIST_ID, language: 'nonsense' }); + await getMediaListPage({ provider, listId: LIST_ID, language: '!!!' }); + + assert.strictEqual(getListMock.callCount(), 3); + assert.deepStrictEqual( + cachedListKeys().sort(), + [ + getMediaListCacheKey('tmdb', LIST_ID, '', 1), + getMediaListCacheKey('tmdb', LIST_ID, 'en', 1), + getMediaListCacheKey('tmdb', LIST_ID, 'en-US', 1), + ].sort() + ); + }); + + it('caps the cache and evicts the least recently fetched page', async () => { + const provider = tmdbProvider(); + getListMock.mockImplementation(async ({ page }: { page: number }) => + listResponse({ page, total_pages: 100000, total_results: 2000000 }) + ); + + for (let page = 1; page <= MAX_MEDIA_LIST_CACHE_ENTRIES; page++) { + await getMediaListPage({ provider, listId: LIST_ID, page }); + } + + const oldestKey = getMediaListCacheKey('tmdb', LIST_ID, '', 1); + assert.strictEqual( + cachedListKeys().length, + MAX_MEDIA_LIST_CACHE_ENTRIES, + 'the cache should have filled to exactly the cap' + ); + assert.ok(cachedListKeys().includes(oldestKey)); + + const overflowPage = MAX_MEDIA_LIST_CACHE_ENTRIES + 1; + await getMediaListPage({ provider, listId: LIST_ID, page: overflowPage }); + + assert.strictEqual( + cachedListKeys().length, + MAX_MEDIA_LIST_CACHE_ENTRIES, + 'the cap should hold once it is exceeded' + ); + assert.ok( + !cachedListKeys().includes(oldestKey), + 'the least recently fetched page should have been evicted' + ); + assert.ok( + cachedListKeys().includes( + getMediaListCacheKey('tmdb', LIST_ID, '', overflowPage) + ), + 'the page that overflowed the cache should be cached' + ); + }); +}); + +describe('media list payload validation', () => { + const malformedPayloads: [string, Partial][] = [ + ['a null item', { items: [null] } as unknown as Partial], + [ + 'a non-array items field', + { items: 'nope' } as unknown as Partial, + ], + [ + 'an unsupported media_type', + { + items: [{ ...MOVIE_ITEM, media_type: 'person' }], + } as unknown as Partial, + ], + [ + 'a non-numeric item id', + { + items: [{ ...MOVIE_ITEM, id: '603' }], + } as unknown as Partial, + ], + ['a missing page', { page: undefined }], + [ + 'non-numeric pagination fields', + { total_pages: 'many' } as unknown as Partial, + ], + ]; + + for (const [label, overrides] of malformedPayloads) { + it(`returns 500 and caches nothing for ${label}`, async () => { + getListMock.mockImplementation(async () => listResponse(overrides)); + + const agent = await loginAsAdmin(); + const res = await agent.get(`/discover/list/${LIST_ID}`); + + assert.strictEqual(res.status, 500); + assert.strictEqual(cachedListKeys().length, 0); + }); + } + + it('serves the stale copy rather than a malformed refresh', async () => { + const agent = await loginAsAdmin(); + + const fresh = await agent.get(`/discover/list/${LIST_ID}`); + assert.strictEqual(fresh.body.totalResults, 2); + + const cache = cacheManager.getCache('medialist').data; + const [cacheKey] = cache.keys(); + const entry = cache.get<{ fetchedAt: number }>(cacheKey); + assert.ok(entry); + cache.set(cacheKey, { + ...entry, + fetchedAt: Date.now() - 7 * 60 * 60 * 1000, + }); + + getListMock.mockImplementation(async () => + listResponse({ items: [null] } as unknown as Partial) + ); + + const stale = await agent.get(`/discover/list/${LIST_ID}`); + assert.strictEqual(stale.status, 200); + assert.strictEqual(stale.body.totalResults, 2); + assert.strictEqual(stale.body.results.length, 2); + }); +}); + +describe('TMDB list slider validation', () => { + it('rejects a slider whose data is not a numeric list id', async () => { + const agent = await loginAsAdmin(); + + const res = await agent.post('/settings/discover/add').send({ + type: DiscoverSliderType.TMDB_LIST, + title: 'Evil list', + data: 'https://evil.test/list', + }); + + assert.strictEqual(res.status, 400); + }); + + it('accepts a slider with a numeric list id', async () => { + const agent = await loginAsAdmin(); + + const res = await agent.post('/settings/discover/add').send({ + type: DiscoverSliderType.TMDB_LIST, + title: 'My list', + data: LIST_ID, + }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.data, LIST_ID); + }); + + it('rejects an invalid list id when updating an existing slider', async () => { + const agent = await loginAsAdmin(); + + const created = await agent.post('/settings/discover/add').send({ + type: DiscoverSliderType.TMDB_LIST, + title: 'My list', + data: LIST_ID, + }); + assert.strictEqual(created.status, 200); + + const res = await agent.put(`/settings/discover/${created.body.id}`).send({ + type: DiscoverSliderType.TMDB_LIST, + title: 'My list', + data: 'not-a-list', + }); + + assert.strictEqual(res.status, 400); + }); + + it('leaves other slider types untouched', async () => { + const agent = await loginAsAdmin(); + + const res = await agent.post('/settings/discover/add').send({ + type: DiscoverSliderType.TMDB_SEARCH, + title: 'Search', + data: 'the matrix', + }); + + assert.strictEqual(res.status, 200); + }); +}); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 7f250e6a6e..13c04917e2 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -11,6 +11,8 @@ import type { GenreSliderItem, WatchlistResponse, } from '@server/interfaces/api/discoverInterfaces'; +import { getMediaListPage, getMediaListProvider } from '@server/lib/medialists'; +import type { MediaListResponse } from '@server/lib/medialists/types'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { mapProductionCompany } from '@server/models/Movie'; @@ -783,6 +785,66 @@ discoverRoutes.get('/trending', async (req, res, next) => { } }); +discoverRoutes.get<{ listId: string }, MediaListResponse>( + '/list/:listId', + async (req, res, next) => { + // TMDB is currently the only supported list source. Additional providers + // can be registered in `server/lib/medialists`. + const provider = getMediaListProvider('tmdb'); + + if (!provider) { + return next({ status: 404, message: 'List provider not found.' }); + } + + if (!provider.validateListId(req.params.listId)) { + return next({ status: 400, message: 'Invalid list ID.' }); + } + + // Parsed once, and strictly: the page number is part of the cache key. + // The OpenAPI request validator may have already coerced the value to a + // number, so both string and number inputs are accepted here. + const rawPage = req.query.page; + + if ( + rawPage !== undefined && + typeof rawPage !== 'string' && + typeof rawPage !== 'number' + ) { + return next({ status: 400, message: 'Invalid page.' }); + } + + const page = rawPage === undefined ? 1 : Number(rawPage); + + if (!Number.isSafeInteger(page) || page < 1) { + return next({ status: 400, message: 'Invalid page.' }); + } + + try { + const data = await getMediaListPage({ + provider, + listId: req.params.listId, + page, + // Deliberately not read from the query string: the language is part of + // the cache key, so it is pinned to the locale resolved server-side. + language: req.locale, + user: req.user, + }); + + return res.status(200).json(data); + } catch (e) { + logger.debug('Something went wrong retrieving a media list', { + label: 'API', + errorMessage: e.message, + listId: req.params.listId, + }); + return next({ + status: 500, + message: 'Unable to retrieve media list.', + }); + } + } +); + discoverRoutes.get<{ keywordId: string }>( '/keyword/:keywordId/movies', async (req, res, next) => { diff --git a/server/routes/settings/discover.ts b/server/routes/settings/discover.ts index 344ce72bea..2484c850cc 100644 --- a/server/routes/settings/discover.ts +++ b/server/routes/settings/discover.ts @@ -1,10 +1,35 @@ +import { DiscoverSliderType } from '@server/constants/discover'; import { getRepository } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; +import { getMediaListProvider } from '@server/lib/medialists'; import logger from '@server/logger'; import { Router } from 'express'; const discoverSettingRoutes = Router(); +/** + * Validates the free-form `data` field of a slider. Returns an error message + * when the value is not usable, otherwise `null`. + * + * List sliders are the only type whose data is used to build an outbound + * request path, so it must be a well-formed provider list id and nothing else. + */ +export const validateSliderData = ( + slider: Pick +): string | null => { + if (Number(slider.type) !== DiscoverSliderType.TMDB_LIST) { + return null; + } + + const provider = getMediaListProvider('tmdb'); + + if (!slider.data || !provider?.validateListId(slider.data)) { + return 'Invalid TMDB list ID. Provide the numeric ID of a public TMDB list.'; + } + + return null; +}; + discoverSettingRoutes.post('/', async (req, res) => { const sliderRepository = getRepository(DiscoverSlider); @@ -14,6 +39,14 @@ discoverSettingRoutes.post('/', async (req, res) => { return res.status(400).json({ message: 'Invalid request body.' }); } + for (const slider of sliders) { + const validationError = validateSliderData(slider); + + if (validationError) { + return res.status(400).json({ message: validationError }); + } + } + for (let x = 0; x < sliders.length; x++) { const slider = sliders[x]; const existingSlider = await sliderRepository.findOne({ @@ -55,6 +88,12 @@ discoverSettingRoutes.post('/add', async (req, res) => { const slider = req.body as DiscoverSlider; + const validationError = validateSliderData(slider); + + if (validationError) { + return res.status(400).json({ message: validationError }); + } + const newSlider = new DiscoverSlider({ isBuiltIn: false, data: slider.data, @@ -82,6 +121,12 @@ discoverSettingRoutes.put('/:sliderId', async (req, res, next) => { const slider = req.body as DiscoverSlider; + const validationError = validateSliderData(slider); + + if (validationError) { + return res.status(400).json({ message: validationError }); + } + try { const existingSlider = await sliderRepository.findOneOrFail({ where: { diff --git a/src/components/Discover/CreateSlider/index.tsx b/src/components/Discover/CreateSlider/index.tsx index db803518fa..15efc1a576 100644 --- a/src/components/Discover/CreateSlider/index.tsx +++ b/src/components/Discover/CreateSlider/index.tsx @@ -3,6 +3,7 @@ import Tooltip from '@app/components/Common/Tooltip'; import { sliderTitles } from '@app/components/Discover/constants'; import MediaSlider from '@app/components/MediaSlider'; import { WatchProviderSelector } from '@app/components/Selector'; +import useDebouncedState from '@app/hooks/useDebouncedState'; import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import useToasts from '@app/hooks/useToasts'; import defineMessages from '@app/utils/defineMessages'; @@ -14,12 +15,14 @@ import type { import { DiscoverSliderType } from '@server/constants/discover'; import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; +import type { MediaListResponse } from '@server/lib/medialists/types'; import type { Keyword, ProductionCompany } from '@server/models/common'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import { useCallback, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import AsyncSelect from 'react-select/async'; +import useSWR from 'swr'; import * as Yup from 'yup'; const messages = defineMessages('components.Discover.CreateSlider', { @@ -31,6 +34,7 @@ const messages = defineMessages('components.Discover.CreateSlider', { providetmdbsearch: 'Provide a search query', providetmdbstudio: 'Provide TMDB Studio ID', providetmdbnetwork: 'Provide TMDB Network ID', + providetmdblist: 'Provide TMDB List ID', addsuccess: 'Created new slider and saved discover customization settings.', addfail: 'Failed to create new slider.', editsuccess: 'Edited slider and saved discover customization settings.', @@ -38,14 +42,95 @@ const messages = defineMessages('components.Discover.CreateSlider', { needresults: 'You need to have at least 1 result.', validationDatarequired: 'You must provide a data value.', validationTitlerequired: 'You must provide a title.', + validationListidnumeric: + 'The TMDB list ID must be a number of at most 12 digits.', addcustomslider: 'Create Custom Slider', searchKeywords: 'Search keywords…', searchGenres: 'Search genres…', searchStudios: 'Search studios…', starttyping: 'Starting typing to search.', nooptions: 'No results.', + helptmdblist: + 'The number at the end of the list URL, e.g. 8677608 for themoviedb.org/list/8677608.', + listpreviewfailed: + 'This list could not be loaded. Check that the ID is correct and that the list is public on TMDB.', }); +/** Mirrors the pattern the server validates list ids against. */ +const TMDB_LIST_ID_REGEX = /^[1-9]\d{0,11}$/; + +/** Long enough that typing an 8 digit id does not fire eight requests. */ +const LIST_PREVIEW_DEBOUNCE_MS = 400; + +/** + * Preview row for the TMDB list slider. Unlike the other slider types, an id + * typed by hand is regularly wrong, so the preview waits for the typing to + * settle, only asks for well-formed ids, and says so when the list cannot be + * read instead of silently leaving the Add button disabled. + */ +const TmdbListPreview = ({ + title, + listId, + onNewTitles, +}: { + title: string; + listId: string; + onNewTitles: (count: number) => void; +}) => { + const intl = useIntl(); + const [, debouncedListId, setListId] = useDebouncedState( + listId, + LIST_PREVIEW_DEBOUNCE_MS + ); + + useEffect(() => { + // Until the debounce settles the row below still shows the previous list, + // so the count it reported has to be dropped straight away: another + // well-formed id would otherwise keep the Add button enabled on the + // strength of a preview belonging to a different list. + onNewTitles(0); + setListId(listId); + }, [listId, onNewTitles, setListId]); + + const isValidListId = TMDB_LIST_ID_REGEX.test(debouncedListId); + + // Same key as the slider below, so SWR serves both from one request. + const { data, error } = useSWR( + isValidListId ? `/api/v1/discover/list/${debouncedListId}?page=1` : null + ); + + const hasFailed = !!error || !!data?.list.unavailable; + + useEffect(() => { + if (!isValidListId || hasFailed) { + onNewTitles(0); + } + }, [isValidListId, hasFailed, onNewTitles]); + + if (!isValidListId) { + return null; + } + + if (hasFailed) { + return ( +
+ {intl.formatMessage(messages.listpreviewfailed)} +
+ ); + } + + return ( + + ); +}; + type CreateSliderProps = { onCreate: () => void; slider?: Partial; @@ -158,9 +243,17 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { title: Yup.string().required( intl.formatMessage(messages.validationTitlerequired) ), - data: Yup.string().required( - intl.formatMessage(messages.validationDatarequired) - ), + data: Yup.string() + .required(intl.formatMessage(messages.validationDatarequired)) + .when('sliderType', { + is: (sliderType: DiscoverSliderType | string) => + Number(sliderType) === DiscoverSliderType.TMDB_LIST, + then: (schema) => + schema.matches( + TMDB_LIST_ID_REGEX, + intl.formatMessage(messages.validationListidnumeric) + ), + }), }); const updateResultCount = useCallback( @@ -273,6 +366,13 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder), dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork), }, + { + type: DiscoverSliderType.TMDB_LIST, + title: intl.formatMessage(sliderTitles.tmdblist), + dataUrl: '/api/v1/discover/list/$value', + titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder), + dataPlaceholderText: intl.formatMessage(messages.providetmdblist), + }, { type: DiscoverSliderType.TMDB_SEARCH, title: intl.formatMessage(sliderTitles.tmdbsearch), @@ -503,6 +603,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
{errors.title}
)} {dataInput} + {activeOption?.type === DiscoverSliderType.TMDB_LIST && ( +
+ {intl.formatMessage(messages.helptmdblist)} +
+ )} {errors.data && touched.data && typeof errors.data === 'string' && ( @@ -532,38 +637,55 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { )} - {activeOption && values.title && values.data && ( -
- + +
+ )} + + {activeOption && + activeOption.type !== DiscoverSliderType.TMDB_LIST && + values.title && + values.data && ( +
+ -
- )} + } + onNewTitles={updateResultCount} + // The Add button is gated on this row's result count. + lazy={false} + /> + + )} ); }} diff --git a/src/components/Discover/DiscoverList/index.tsx b/src/components/Discover/DiscoverList/index.tsx new file mode 100644 index 0000000000..bf2fa73310 --- /dev/null +++ b/src/components/Discover/DiscoverList/index.tsx @@ -0,0 +1,79 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import globalMessages from '@app/i18n/globalMessages'; +import ErrorPage from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import type { MediaListResponse } from '@server/lib/medialists/types'; +import type { MovieResult, TvResult } from '@server/models/Search'; +import { useRouter } from 'next/router'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Discover.DiscoverList', { + listTitle: 'TMDB List', + listunavailable: 'This list is no longer available', + listunavailabledescription: + 'It may have been deleted, or it may no longer be public on TMDB.', +}); + +const DiscoverList = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover>( + `/api/v1/discover/list/${router.query.listId}` + ); + + if (error) { + return ; + } + + // A list that could not be read at all is a different story from a list that + // is simply empty, and deserves more than the generic "No results." grid. + const isUnavailable = !!firstResultData?.list.unavailable; + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : (firstResultData?.list.name ?? intl.formatMessage(messages.listTitle)); + + return ( + <> + +
+
{title}
+
+ {isUnavailable ? ( +
+

+ {intl.formatMessage(messages.listunavailable)} +

+

+ {intl.formatMessage(messages.listunavailabledescription)} +

+
+ ) : ( + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + )} + + ); +}; + +export default DiscoverList; diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index c24a0591ad..6d54b24cd4 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -10,7 +10,10 @@ import KeywordTag from '@app/components/KeywordTag'; import useToasts from '@app/hooks/useToasts'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; -import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { + MagnifyingGlassIcon, + QueueListIcon, +} from '@heroicons/react/24/outline'; import { ArrowUturnLeftIcon, Bars3Icon, @@ -169,6 +172,8 @@ const DiscoverSliderEdit = ({ return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices); case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES: return intl.formatMessage(sliderTitles.tmdbtvstreamingservices); + case DiscoverSliderType.TMDB_LIST: + return intl.formatMessage(sliderTitles.tmdblist); default: return 'Unknown Slider'; } @@ -245,6 +250,9 @@ const DiscoverSliderEdit = ({ {slider.type === DiscoverSliderType.TMDB_SEARCH && ( }>{slider.data} )} + {slider.type === DiscoverSliderType.TMDB_LIST && ( + }>{slider.data} + )}
{!slider.isBuiltIn && ( diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 4ce5e34f66..8bfdd8c5b7 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -88,6 +88,7 @@ export const sliderTitles = defineMessages('components.Discover', { tmdbsearch: 'TMDB Search', tmdbmoviestreamingservices: 'TMDB Movie Streaming Services', tmdbtvstreamingservices: 'TMDB TV Streaming Services', + tmdblist: 'TMDB List', }); export const QueryFilterOptions = z.object({ diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 5638d6fb34..593b3b31e7 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -355,6 +355,19 @@ const Discover = () => { /> ); break; + case DiscoverSliderType.TMDB_LIST: + sliderComponent = ( + + ); + break; case DiscoverSliderType.TMDB_SEARCH: sliderComponent = ( void; + /** + * Rows hold off fetching until they are near the viewport. Set to false for a + * row that must load even if it is never scrolled to, such as a preview whose + * result count gates a form. + */ + lazy?: boolean; } const MediaSlider = ({ @@ -41,11 +50,46 @@ const MediaSlider = ({ sliderKey, hideWhenEmpty = false, onNewTitles, + lazy = true, }: MediaSliderProps) => { const settings = useSettings(); const { hasPermission } = useUser(); + const headerRef = useRef(null); + const [shouldFetch, setShouldFetch] = useState(!lazy); + + // A Discover page can hold a dozen rows; without this they would all fetch at + // once on mount, most of them for posters nobody scrolls down to. + useEffect(() => { + if (shouldFetch) { + return; + } + + if (!headerRef.current || typeof IntersectionObserver === 'undefined') { + setShouldFetch(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setShouldFetch(true); + observer.disconnect(); + } + }, + { rootMargin: LAZY_MOUNT_ROOT_MARGIN } + ); + + observer.observe(headerRef.current); + + return () => observer.disconnect(); + }, [shouldFetch]); + const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { + if (!shouldFetch) { + return null; + } + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; } @@ -98,7 +142,9 @@ const MediaSlider = ({ } }, [titles, setSize, size, data, onNewTitles]); - if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) { + // Only once we know what the row holds: while `data` is undefined the row has + // not answered yet, and hiding it early would keep it from ever loading. + if (hideWhenEmpty && data && (data[0]?.results ?? []).length === 0) { return null; } @@ -177,7 +223,7 @@ const MediaSlider = ({ return ( <> -
+
{linkUrl ? ( {title} @@ -192,7 +238,7 @@ const MediaSlider = ({ diff --git a/src/components/Slider/index.tsx b/src/components/Slider/index.tsx index 3be9c383a6..000d447ea1 100644 --- a/src/components/Slider/index.tsx +++ b/src/components/Slider/index.tsx @@ -1,11 +1,17 @@ import TitleCard from '@app/components/TitleCard'; import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; import { useSpring } from '@react-spring/web'; import { debounce } from 'lodash'; import { useCallback, useEffect, useRef, useState, type JSX } from 'react'; import { useIntl } from 'react-intl'; +const messages = defineMessages('components.Slider', { + previous: 'Scroll backward', + next: 'Scroll forward', +}); + interface SliderProps { sliderKey: string; items?: JSX.Element[]; @@ -153,6 +159,7 @@ const Slider = ({ onClick={() => slide(Direction.LEFT)} disabled={scrollPos.isStart} type="button" + aria-label={intl.formatMessage(messages.previous)} > @@ -163,6 +170,7 @@ const Slider = ({ onClick={() => slide(Direction.RIGHT)} disabled={scrollPos.isEnd} type="button" + aria-label={intl.formatMessage(messages.next)} > diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d546f6782a..42421eb796 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -33,10 +33,13 @@ "components.Discover.CreateSlider.editSlider": "Edit Slider", "components.Discover.CreateSlider.editfail": "Failed to edit slider.", "components.Discover.CreateSlider.editsuccess": "Edited slider and saved discover customization settings.", + "components.Discover.CreateSlider.helptmdblist": "The number at the end of the list URL, e.g. 8677608 for themoviedb.org/list/8677608.", + "components.Discover.CreateSlider.listpreviewfailed": "This list could not be loaded. Check that the ID is correct and that the list is public on TMDB.", "components.Discover.CreateSlider.needresults": "You need to have at least 1 result.", "components.Discover.CreateSlider.nooptions": "No results.", "components.Discover.CreateSlider.providetmdbgenreid": "Provide a TMDB Genre ID", "components.Discover.CreateSlider.providetmdbkeywordid": "Provide a TMDB Keyword ID", + "components.Discover.CreateSlider.providetmdblist": "Provide TMDB List ID", "components.Discover.CreateSlider.providetmdbnetwork": "Provide TMDB Network ID", "components.Discover.CreateSlider.providetmdbsearch": "Provide a search query", "components.Discover.CreateSlider.providetmdbstudio": "Provide TMDB Studio ID", @@ -46,7 +49,11 @@ "components.Discover.CreateSlider.slidernameplaceholder": "Slider Name", "components.Discover.CreateSlider.starttyping": "Starting typing to search.", "components.Discover.CreateSlider.validationDatarequired": "You must provide a data value.", + "components.Discover.CreateSlider.validationListidnumeric": "The TMDB list ID must be a number of at most 12 digits.", "components.Discover.CreateSlider.validationTitlerequired": "You must provide a title.", + "components.Discover.DiscoverList.listTitle": "TMDB List", + "components.Discover.DiscoverList.listunavailable": "This list is no longer available", + "components.Discover.DiscoverList.listunavailabledescription": "It may have been deleted, or it may no longer be public on TMDB.", "components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies", "components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Movies", "components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies", @@ -131,6 +138,7 @@ "components.Discover.studios": "Studios", "components.Discover.timeWindowDay": "Daily", "components.Discover.timeWindowWeek": "Weekly", + "components.Discover.tmdblist": "TMDB List", "components.Discover.tmdbmoviegenre": "TMDB Movie Genre", "components.Discover.tmdbmoviekeyword": "TMDB Movie Keyword", "components.Discover.tmdbmoviestreamingservices": "TMDB Movie Streaming Services", @@ -1337,6 +1345,8 @@ "components.Setup.signinWithPlex": "Enter your Plex details", "components.Setup.subtitle": "Get started by choosing your media server", "components.Setup.welcome": "Welcome to Seerr", + "components.Slider.next": "Scroll forward", + "components.Slider.previous": "Scroll backward", "components.StatusBadge.managemedia": "Manage {mediaType}", "components.StatusBadge.openinarr": "Open in {arr}", "components.StatusBadge.playonplex": "Play on {mediaServerName}", diff --git a/src/pages/discover/list/[listId]/index.tsx b/src/pages/discover/list/[listId]/index.tsx new file mode 100644 index 0000000000..a9a3df99a3 --- /dev/null +++ b/src/pages/discover/list/[listId]/index.tsx @@ -0,0 +1,8 @@ +import DiscoverList from '@app/components/Discover/DiscoverList'; +import type { NextPage } from 'next'; + +const DiscoverListPage: NextPage = () => { + return ; +}; + +export default DiscoverListPage;