Skip to content

Commit 24fa3f8

Browse files
feat(discover): add tmdb list discover source
support creating discover sliders from public tmdb lists
2 parents d660a54 + 3614ff9 commit 24fa3f8

File tree

9 files changed

+293
-9
lines changed

9 files changed

+293
-9
lines changed

seerr-api.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5868,6 +5868,52 @@ paths:
58685868
- $ref: '#/components/schemas/MovieResult'
58695869
- $ref: '#/components/schemas/TvResult'
58705870
- $ref: '#/components/schemas/PersonResult'
5871+
/discover/list/{listId}:
5872+
get:
5873+
summary: Get TMDB list by ID
5874+
description: Returns items from a public TMDB list as discovery results.
5875+
tags:
5876+
- search
5877+
parameters:
5878+
- in: path
5879+
name: listId
5880+
required: true
5881+
schema:
5882+
type: number
5883+
example: 8234446
5884+
- in: query
5885+
name: language
5886+
schema:
5887+
type: string
5888+
example: en
5889+
- in: query
5890+
name: page
5891+
required: false
5892+
schema:
5893+
type: number
5894+
example: 1
5895+
responses:
5896+
'200':
5897+
description: Results
5898+
content:
5899+
application/json:
5900+
schema:
5901+
type: object
5902+
properties:
5903+
page:
5904+
type: number
5905+
totalPages:
5906+
type: number
5907+
totalResults:
5908+
type: number
5909+
results:
5910+
type: array
5911+
items:
5912+
anyOf:
5913+
- $ref: '#/components/schemas/MovieResult'
5914+
- $ref: '#/components/schemas/TvResult'
5915+
- $ref: '#/components/schemas/PersonResult'
5916+
- $ref: '#/components/schemas/Collection'
58715917
/discover/keyword/{keywordId}/movies:
58725918
get:
58735919
summary: Get movies from keyword

server/api/themoviedb/index.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getSettings } from '@server/lib/settings';
55
import { sortBy } from 'lodash';
66
import type {
77
TmdbCollection,
8+
TmdbCollectionResult,
89
TmdbCompanySearchResponse,
910
TmdbExternalIdResponse,
1011
TmdbGenre,
@@ -13,16 +14,19 @@ import type {
1314
TmdbKeywordSearchResponse,
1415
TmdbLanguage,
1516
TmdbMovieDetails,
17+
TmdbMovieResult,
1618
TmdbNetwork,
1719
TmdbPersonCombinedCredits,
1820
TmdbPersonDetails,
21+
TmdbPersonResult,
1922
TmdbProductionCompany,
2023
TmdbRegion,
2124
TmdbSearchMovieResponse,
2225
TmdbSearchMultiResponse,
2326
TmdbSearchTvResponse,
2427
TmdbSeasonWithEpisodes,
2528
TmdbTvDetails,
29+
TmdbTvResult,
2630
TmdbUpcomingMoviesResponse,
2731
TmdbWatchProviderDetails,
2832
TmdbWatchProviderRegion,
@@ -758,6 +762,62 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
758762
}
759763
};
760764

765+
/**
766+
* Retrieve a public TMDB list by its ID. The TMDB API returns a list of mixed
767+
* media items under the `items` property. This helper normalises the response
768+
* to the same paginated structure used by discover and trending endpoints.
769+
* If the list is private or does not exist, an exception will be thrown.
770+
*/
771+
public getList = async ({
772+
listId,
773+
language = this.locale,
774+
}: {
775+
listId: number;
776+
language?: string;
777+
}): Promise<{
778+
page: number;
779+
total_pages: number;
780+
total_results: number;
781+
results: (
782+
| TmdbMovieResult
783+
| TmdbTvResult
784+
| TmdbPersonResult
785+
| TmdbCollectionResult
786+
)[];
787+
}> => {
788+
try {
789+
const data = await this.get<any>(`/list/${listId}`, {
790+
params: {
791+
language,
792+
},
793+
});
794+
795+
// The API does not provide pagination on lists so we normalise here.
796+
const items =
797+
data?.items ??
798+
([] as (
799+
| TmdbMovieResult
800+
| TmdbTvResult
801+
| TmdbPersonResult
802+
| TmdbCollectionResult
803+
)[]);
804+
805+
return {
806+
page: 1,
807+
total_pages: 1,
808+
total_results: items.length,
809+
results: items as (
810+
| TmdbMovieResult
811+
| TmdbTvResult
812+
| TmdbPersonResult
813+
| TmdbCollectionResult
814+
)[],
815+
};
816+
} catch (e) {
817+
throw new Error(`[TMDB] Failed to fetch list: ${e.message}`);
818+
}
819+
};
820+
761821
public async getByExternalId({
762822
externalId,
763823
type,

server/api/tvdb/index.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
204204
seasonNumber: number;
205205
language?: string;
206206
}): Promise<TmdbSeasonWithEpisodes> {
207+
if (seasonNumber === 0) {
208+
return this.createEmptySeasonResponse(tvId);
209+
}
210+
207211
try {
208212
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
209213

@@ -277,12 +281,12 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
277281
}
278282

279283
const seasons = tvdbData.seasons
280-
.filter((season) => season.type && season.type.type === 'official')
281-
.sort((a, b) => a.number - b.number)
282-
.map((season) => this.createSeasonData(season, tvdbData))
283284
.filter(
284-
(season) => season && season.season_number >= 0
285-
) as TmdbTvSeasonResult[];
285+
(season) =>
286+
season.number > 0 && season.type && season.type.type === 'official'
287+
)
288+
.sort((a, b) => a.number - b.number)
289+
.map((season) => this.createSeasonData(season, tvdbData));
286290

287291
return seasons;
288292
}
@@ -291,14 +295,13 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
291295
season: TvdbSeasonDetails,
292296
tvdbData: TvdbTvDetails
293297
): TmdbTvSeasonResult {
294-
const seasonNumber = season.number ?? -1;
295-
if (seasonNumber < 0) {
298+
if (!season.number) {
296299
return {
297300
id: 0,
298301
episode_count: 0,
299302
name: '',
300303
overview: '',
301-
season_number: -1,
304+
season_number: 0,
302305
poster_path: '',
303306
air_date: '',
304307
};

server/constants/discover.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export enum DiscoverSliderType {
2222
TMDB_NETWORK,
2323
TMDB_MOVIE_STREAMING_SERVICES,
2424
TMDB_TV_STREAMING_SERVICES,
25+
TMDB_LIST,
2526
}
2627

2728
export const defaultSliders: Partial<DiscoverSlider>[] = [

server/routes/discover.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ discoverRoutes.get('/trending', async (req, res, next) => {
712712
} catch (e) {
713713
logger.debug('Something went wrong retrieving trending items', {
714714
label: 'API',
715-
errorMessage: e.message,
715+
errorMessage: (e as Error).message,
716716
});
717717
return next({
718718
status: 500,
@@ -917,4 +917,145 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
917917
}
918918
);
919919

920+
// TMDB list slider: return items from a TMDB list by id
921+
discoverRoutes.get<{ listId: string }>(
922+
'/list/:listId',
923+
async (req, res, next) => {
924+
const listId = Number(req.params.listId);
925+
if (!listId || Number.isNaN(listId)) {
926+
return next({ status: 400, message: 'Invalid list ID.' });
927+
}
928+
929+
// same helper as in /trending etc.
930+
const tmdb = createTmdbWithRegionLanguage(req.user);
931+
const language = (req.query.language as string) ?? req.locale;
932+
const page = req.query.page ? Number(req.query.page) || 1 : 1;
933+
934+
try {
935+
let data: { results: any[] } | null = null;
936+
937+
// 1) v3: all items come without pagination
938+
try {
939+
const v3 = await tmdb.getList({ listId, language });
940+
data = v3;
941+
} catch {
942+
data = null;
943+
}
944+
945+
// 2) fallback to v4 if v3 is empty or failed
946+
if (!data || !Array.isArray(data.results) || data.results.length === 0) {
947+
// use the existing Axios instance of the TMDB client (proxy/timeouts are preserved)
948+
const axiosInstance: any = (tmdb as any).axios;
949+
const v4Url = `https://api.themoviedb.org/4/list/${listId}`;
950+
951+
try {
952+
const resp = await axiosInstance.get(v4Url, {
953+
params: { page, language },
954+
});
955+
956+
const v4 = resp.data as {
957+
page?: number;
958+
total_pages?: number;
959+
total_results?: number;
960+
results?: { id: number; media_type: string }[];
961+
};
962+
963+
const media = await Media.getRelatedMedia(
964+
req.user,
965+
(v4.results ?? []).map((r) => r.id)
966+
);
967+
968+
const mappedResults = (v4.results ?? []).map((r) => {
969+
switch (r.media_type) {
970+
case 'movie':
971+
return mapMovieResult(
972+
r as any,
973+
media.find(
974+
(m) => m.tmdbId === r.id && m.mediaType === MediaType.MOVIE
975+
)
976+
);
977+
case 'tv':
978+
return mapTvResult(
979+
r as any,
980+
media.find(
981+
(m) => m.tmdbId === r.id && m.mediaType === MediaType.TV
982+
)
983+
);
984+
case 'person':
985+
return mapPersonResult(r as any);
986+
default:
987+
return mapCollectionResult(r as any);
988+
}
989+
});
990+
991+
return res.status(200).json({
992+
page: v4.page ?? 1,
993+
totalPages: v4.total_pages ?? 1,
994+
totalResults: mappedResults.length,
995+
results: mappedResults,
996+
});
997+
} catch {
998+
// if v4 also fails → continue to empty response below
999+
}
1000+
}
1001+
1002+
// 3) return v3 data if available
1003+
if (data && Array.isArray(data.results) && data.results.length > 0) {
1004+
const media = await Media.getRelatedMedia(
1005+
req.user,
1006+
data.results.map((r) => r.id)
1007+
);
1008+
1009+
const mappedResults = data.results.map((result) =>
1010+
isMovie(result)
1011+
? mapMovieResult(
1012+
result,
1013+
media.find(
1014+
(m) =>
1015+
m.tmdbId === result.id && m.mediaType === MediaType.MOVIE
1016+
)
1017+
)
1018+
: isPerson(result)
1019+
? mapPersonResult(result)
1020+
: isCollection(result)
1021+
? mapCollectionResult(result)
1022+
: mapTvResult(
1023+
result,
1024+
media.find(
1025+
(m) => m.tmdbId === result.id && m.mediaType === MediaType.TV
1026+
)
1027+
)
1028+
);
1029+
1030+
return res.status(200).json({
1031+
page: 1,
1032+
totalPages: 1,
1033+
totalResults: mappedResults.length,
1034+
results: mappedResults,
1035+
});
1036+
}
1037+
1038+
// 4) both paths empty → return empty response
1039+
return res.status(200).json({
1040+
page: 1,
1041+
totalPages: 1,
1042+
totalResults: 0,
1043+
results: [],
1044+
});
1045+
} catch (err) {
1046+
logger.debug('TMDB list slider failed', {
1047+
label: 'API',
1048+
errorMessage: (err as Error).message,
1049+
listId: req.params.listId,
1050+
});
1051+
return res.status(200).json({
1052+
page: 1,
1053+
totalPages: 1,
1054+
totalResults: 0,
1055+
results: [],
1056+
});
1057+
}
1058+
}
1059+
);
1060+
9201061
export default discoverRoutes;

src/components/Discover/CreateSlider/index.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const messages = defineMessages('components.Discover.CreateSlider', {
4444
searchStudios: 'Search studios…',
4545
starttyping: 'Starting typing to search.',
4646
nooptions: 'No results.',
47+
providetmdblistid: 'Provide a TMDB List ID',
4748
});
4849

4950
type CreateSliderProps = {
@@ -295,6 +296,13 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
295296
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
296297
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
297298
},
299+
{
300+
type: DiscoverSliderType.TMDB_LIST,
301+
title: intl.formatMessage(sliderTitles.tmdbList),
302+
dataUrl: '/api/v1/discover/list/$value',
303+
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
304+
dataPlaceholderText: intl.formatMessage(messages.providetmdblistid),
305+
},
298306
];
299307

300308
return (
@@ -470,6 +478,18 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
470478
/>
471479
);
472480
break;
481+
case DiscoverSliderType.TMDB_LIST:
482+
dataInput = (
483+
<Field
484+
type="text"
485+
inputMode="numeric"
486+
pattern="[0-9]*"
487+
name="data"
488+
id="data"
489+
placeholder={activeOption?.dataPlaceholderText}
490+
/>
491+
);
492+
break;
473493
default:
474494
dataInput = (
475495
<Field

0 commit comments

Comments
 (0)