From 46d64a2d92e677a80e0387b8679167d678fcdee7 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Tue, 25 Aug 2026 19:16:31 +0200 Subject: [PATCH 1/2] feat(search): restore collection results in main search --- seerr-api.yml | 37 ++++++++++++++++++++-- server/api/themoviedb/index.ts | 26 +++++++++++++++ server/api/themoviedb/interfaces.ts | 15 +++++++++ server/routes/search.ts | 49 +++++++++++++++++++++++++---- src/components/Search/index.tsx | 3 +- src/hooks/useDiscover.ts | 9 ++++-- 6 files changed, 127 insertions(+), 12 deletions(-) diff --git a/seerr-api.yml b/seerr-api.yml index f45c91ce23..12d9efc664 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -829,6 +829,38 @@ components: oneOf: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' + CollectionResult: + type: object + required: + - id + - mediaType + - title + properties: + id: + type: number + example: 10 + mediaType: + type: string + default: 'collection' + title: + type: string + example: Star Wars Collection + originalTitle: + type: string + example: Star Wars Collection + adult: + type: boolean + example: false + posterPath: + type: string + backdropPath: + type: string + overview: + type: string + example: Overview of the collection + originalLanguage: + type: string + example: 'en' Genre: type: object properties: @@ -5491,8 +5523,8 @@ paths: type: number /search: get: - summary: Search for movies, TV shows, or people - description: Returns a list of movies, TV shows, or people a JSON object. + summary: Search for movies, TV shows, people, or collections + description: Returns a list of movies, TV shows, people, or collections as a JSON object. tags: - search parameters: @@ -5537,6 +5569,7 @@ paths: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/PersonResult' + - $ref: '#/components/schemas/CollectionResult' /search/keyword: get: summary: Search for keywords diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 4693b3e86e..e069fab274 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -19,6 +19,7 @@ import type { TmdbPersonDetails, TmdbProductionCompany, TmdbRegion, + TmdbSearchCollectionResponse, TmdbSearchMovieResponse, TmdbSearchMultiResponse, TmdbSearchTvResponse, @@ -280,6 +281,31 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } }; + public searchCollections = async ({ + query, + page = 1, + includeAdult = false, + language = this.locale, + }: SearchOptions): Promise => { + try { + const data = await this.get( + '/search/collection', + { + params: { query, page, include_adult: includeAdult, language }, + } + ); + + return data; + } catch { + return { + page: 1, + results: [], + total_pages: 1, + total_results: 0, + }; + } + }; + public getPerson = async ({ personId, language = this.locale, diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index cd68ccc70b..762a7d81f5 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -40,6 +40,17 @@ export interface TmdbCollectionResult { original_language: string; } +export interface TmdbCollectionSearchResult { + id: number; + adult: boolean; + name: string; + original_name: string; + original_language: string; + overview: string; + poster_path?: string; + backdrop_path?: string; +} + export interface TmdbPersonResult { id: number; name: string; @@ -73,6 +84,10 @@ export interface TmdbSearchTvResponse extends TmdbPaginatedResponse { results: TmdbTvResult[]; } +export interface TmdbSearchCollectionResponse extends TmdbPaginatedResponse { + results: TmdbCollectionSearchResult[]; +} + export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse { dates: { maximum: string; diff --git a/server/routes/search.ts b/server/routes/search.ts index cccb38f286..805a5bc9e3 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,5 +1,8 @@ import TheMovieDb from '@server/api/themoviedb'; -import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; +import type { + TmdbCollectionResult, + TmdbSearchMultiResponse, +} from '@server/api/themoviedb/interfaces'; import Media from '@server/entity/Media'; import { findSearchProvider } from '@server/lib/search'; import logger from '@server/logger'; @@ -25,12 +28,46 @@ searchRoutes.get('/', async (req, res, next) => { }); } else { const tmdb = new TheMovieDb(); + const page = Number(req.query.page) || 1; + const language = (req.query.language as string) ?? req.locale; - results = await tmdb.searchMulti({ - query: queryString, - page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, - }); + const [multi, collections] = await Promise.all([ + tmdb.searchMulti({ + query: queryString, + page, + language, + }), + tmdb.searchCollections({ + query: queryString, + page, + language, + }), + ]); + + const collectionResults: TmdbCollectionResult[] = collections.results.map( + (collection) => ({ + id: collection.id, + media_type: 'collection', + adult: collection.adult, + title: collection.name, + original_title: collection.original_name, + overview: collection.overview, + original_language: collection.original_language, + poster_path: collection.poster_path, + backdrop_path: collection.backdrop_path, + }) + ); + + const multiWithoutCollections = multi.results.filter( + (result) => result.media_type !== 'collection' + ); + + results = { + page, + total_pages: Math.max(multi.total_pages, collections.total_pages), + total_results: multi.total_results + collections.total_results, + results: [...collectionResults, ...multiWithoutCollections], + }; } const media = await Media.getRelatedMedia( diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e5a54180bb..60f1037cc3 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -5,6 +5,7 @@ import useDiscover from '@app/hooks/useDiscover'; import ErrorPage from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; import type { + CollectionResult, MovieResult, PersonResult, TvResult, @@ -29,7 +30,7 @@ const Search = () => { titles, fetchMore, error, - } = useDiscover( + } = useDiscover( `/api/v1/search`, { query: router.query.query, diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts index 237e895712..92f89093f5 100644 --- a/src/hooks/useDiscover.ts +++ b/src/hooks/useDiscover.ts @@ -143,11 +143,14 @@ const useDiscover = < } const isEmpty = !isLoadingInitialData && titles?.length === 0; + const lastPageData = data?.[data.length - 1]; + const totalPages = lastPageData?.totalPages ?? 0; const isReachingEnd = isEmpty || - (!!data && (data[data?.length - 1]?.results.length ?? 0) < 20) || - (!!data && (data[data?.length - 1]?.totalResults ?? 0) <= size * 20) || - (!!data && (data[data?.length - 1]?.totalResults ?? 0) < 41); + (!!lastPageData && + (totalPages > 0 + ? size >= totalPages + : (lastPageData.results.length ?? 0) < 20)); useEffect(() => { if (error && titles.length) { From 1e50cb06607227546b45a1c86d5bc13ef8e80603 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 31 Aug 2026 20:31:03 +0200 Subject: [PATCH 2/2] feat(search): implement caching for collection search results --- server/routes/search.ts | 43 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/server/routes/search.ts b/server/routes/search.ts index 805a5bc9e3..f062700f57 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,9 +1,11 @@ import TheMovieDb from '@server/api/themoviedb'; import type { TmdbCollectionResult, + TmdbSearchCollectionResponse, TmdbSearchMultiResponse, } from '@server/api/themoviedb/interfaces'; import Media from '@server/entity/Media'; +import cacheManager from '@server/lib/cache'; import { findSearchProvider } from '@server/lib/search'; import logger from '@server/logger'; import { mapSearchResults } from '@server/models/Search'; @@ -31,17 +33,48 @@ searchRoutes.get('/', async (req, res, next) => { const page = Number(req.query.page) || 1; const language = (req.query.language as string) ?? req.locale; + const tmdbCache = cacheManager.getCache('tmdb').data; + const pagesKey = `search-collections-pages:${language}:${queryString}`; + const knownCollections = tmdbCache.get<{ + total_pages: number; + total_results: number; + }>(pagesKey); + + const fetchCollections = + async (): Promise => { + if (knownCollections && page > knownCollections.total_pages) { + return { + page, + results: [], + total_pages: knownCollections.total_pages, + total_results: knownCollections.total_results, + }; + } + + const collections = await tmdb.searchCollections({ + query: queryString, + page, + language, + }); + + tmdbCache.set( + pagesKey, + { + total_pages: collections.total_pages, + total_results: collections.total_results, + }, + 300 + ); + return collections; + }; + const [multi, collections] = await Promise.all([ tmdb.searchMulti({ query: queryString, page, language, }), - tmdb.searchCollections({ - query: queryString, - page, - language, - }), + fetchCollections(), ]); const collectionResults: TmdbCollectionResult[] = collections.results.map(