Files
channels-seerr/server/routes/discover.ts
2025-12-15 09:26:35 +10:00

1048 lines
29 KiB
TypeScript

import ListenBrainzAPI from '@server/api/listenbrainz';
import MusicBrainz from '@server/api/musicbrainz';
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import { Watchlist } from '@server/entity/Watchlist';
import type {
GenreSliderItem,
WatchlistResponse,
} from '@server/interfaces/api/discoverInterfaces';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie';
import {
mapCollectionResult,
mapMovieResult,
mapPersonResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import { z } from 'zod';
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const settings = getSettings();
const discoverRegion =
user?.settings?.streamingRegion === 'all'
? ''
: user?.settings?.streamingRegion
? user?.settings?.streamingRegion
: settings.main.discoverRegion;
const originalLanguage =
user?.settings?.originalLanguage === 'all'
? ''
: user?.settings?.originalLanguage
? user?.settings?.originalLanguage
: settings.main.originalLanguage;
return new TheMovieDb({
discoverRegion,
originalLanguage,
});
};
const discoverRoutes = Router();
const QueryFilterOptions = z.object({
page: z.coerce.string().optional(),
sortBy: z.coerce.string().optional(),
primaryReleaseDateGte: z.coerce.string().optional(),
primaryReleaseDateLte: z.coerce.string().optional(),
firstAirDateGte: z.coerce.string().optional(),
firstAirDateLte: z.coerce.string().optional(),
studio: z.coerce.string().optional(),
genre: z.coerce.string().optional(),
keywords: z.coerce.string().optional(),
excludeKeywords: z.coerce.string().optional(),
language: z.coerce.string().optional(),
withRuntimeGte: z.coerce.string().optional(),
withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(),
voteCountGte: z.coerce.string().optional(),
voteCountLte: z.coerce.string().optional(),
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
status: z.coerce.string().optional(),
certification: z.coerce.string().optional(),
certificationGte: z.coerce.string().optional(),
certificationLte: z.coerce.string().optional(),
certificationCountry: z.coerce.string().optional(),
certificationMode: z.enum(['exact', 'range']).optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
const ApiQuerySchema = QueryFilterOptions.omit({
certificationMode: true,
});
discoverRoutes.get('/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = ApiQuerySchema.parse(req.query);
const keywords = query.keywords;
const excludeKeywords = query.excludeKeywords;
const data = await tmdb.getDiscoverMovies({
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
originalLanguage: query.language,
genre: query.genre,
studio: query.studio,
primaryReleaseDateLte: query.primaryReleaseDateLte
? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0]
: undefined,
primaryReleaseDateGte: query.primaryReleaseDateGte
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
: undefined,
keywords,
excludeKeywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
certification: query.certification,
certificationGte: query.certificationGte,
certificationLte: query.certificationLte,
certificationCountry: query.certificationCountry,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
const keywordResults = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving popular movies', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve popular movies.',
});
}
});
discoverRoutes.get<{ language: string }>(
'/movies/language/:language',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const languages = await tmdb.getLanguages();
const language = languages.find(
(lang) => lang.iso_639_1 === req.params.language
);
if (!language) {
return next({ status: 404, message: 'Language not found.' });
}
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
originalLanguage: req.params.language,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
language,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving movies by language', {
label: 'API',
errorMessage: e.message,
language: req.params.language,
});
return next({
status: 500,
message: 'Unable to retrieve movies by language.',
});
}
}
);
discoverRoutes.get<{ genreId: string }>(
'/movies/genre/:genreId',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const genres = await tmdb.getMovieGenres({
language: (req.query.language as string) ?? req.locale,
});
const genre = genres.find(
(genre) => genre.id === Number(req.params.genreId)
);
if (!genre) {
return next({ status: 404, message: 'Genre not found.' });
}
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
genre: req.params.genreId as string,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
genre,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving movies by genre', {
label: 'API',
errorMessage: e.message,
genreId: req.params.genreId,
});
return next({
status: 500,
message: 'Unable to retrieve movies by genre.',
});
}
}
);
discoverRoutes.get<{ studioId: string }>(
'/movies/studio/:studioId',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const studio = await tmdb.getStudio(Number(req.params.studioId));
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
studio: req.params.studioId as string,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
studio: mapProductionCompany(studio),
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving movies by studio', {
label: 'API',
errorMessage: e.message,
studioId: req.params.studioId,
});
return next({
status: 500,
message: 'Unable to retrieve movies by studio.',
});
}
}
);
discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
const now = new Date();
const offset = now.getTimezoneOffset();
const date = new Date(now.getTime() - offset * 60 * 1000)
.toISOString()
.split('T')[0];
try {
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
primaryReleaseDateGte: date,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving upcoming movies', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve upcoming movies.',
});
}
});
discoverRoutes.get('/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = ApiQuerySchema.parse(req.query);
const keywords = query.keywords;
const excludeKeywords = query.excludeKeywords;
const data = await tmdb.getDiscoverTv({
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
genre: query.genre,
network: query.network ? Number(query.network) : undefined,
firstAirDateLte: query.firstAirDateLte
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
: undefined,
firstAirDateGte: query.firstAirDateGte
? new Date(query.firstAirDateGte).toISOString().split('T')[0]
: undefined,
originalLanguage: query.language,
keywords,
excludeKeywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
withStatus: query.status,
certification: query.certification,
certificationGte: query.certificationGte,
certificationLte: query.certificationLte,
certificationCountry: query.certificationCountry,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
const keywordResults = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving popular series', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve popular series.',
});
}
});
discoverRoutes.get<{ language: string }>(
'/tv/language/:language',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const languages = await tmdb.getLanguages();
const language = languages.find(
(lang) => lang.iso_639_1 === req.params.language
);
if (!language) {
return next({ status: 404, message: 'Language not found.' });
}
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
originalLanguage: req.params.language,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
language,
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving series by language', {
label: 'API',
errorMessage: e.message,
language: req.params.language,
});
return next({
status: 500,
message: 'Unable to retrieve series by language.',
});
}
}
);
discoverRoutes.get<{ genreId: string }>(
'/tv/genre/:genreId',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const genres = await tmdb.getTvGenres({
language: (req.query.language as string) ?? req.locale,
});
const genre = genres.find(
(genre) => genre.id === Number(req.params.genreId)
);
if (!genre) {
return next({ status: 404, message: 'Genre not found.' });
}
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
genre: req.params.genreId,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
genre,
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving series by genre', {
label: 'API',
errorMessage: e.message,
genreId: req.params.genreId,
});
return next({
status: 500,
message: 'Unable to retrieve series by genre.',
});
}
}
);
discoverRoutes.get<{ networkId: string }>(
'/tv/network/:networkId',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const network = await tmdb.getNetwork(Number(req.params.networkId));
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
network: Number(req.params.networkId),
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
network: mapNetwork(network),
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving series by network', {
label: 'API',
errorMessage: e.message,
networkId: req.params.networkId,
});
return next({
status: 500,
message: 'Unable to retrieve series by network.',
});
}
}
);
discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
const now = new Date();
const offset = now.getTimezoneOffset();
const date = new Date(now.getTime() - offset * 60 * 1000)
.toISOString()
.split('T')[0];
try {
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
firstAirDateGte: date,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving upcoming series', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve upcoming series.',
});
}
});
discoverRoutes.get('/trending', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const data = await tmdb.getAllTrending({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
isMovie(result)
? mapMovieResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
: isPerson(result)
? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving trending items', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve trending items.',
});
}
});
discoverRoutes.get<{ keywordId: string }>(
'/keyword/:keywordId/movies',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId),
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
});
const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
),
});
} catch (e) {
logger.debug('Something went wrong retrieving movies by keyword', {
label: 'API',
errorMessage: e.message,
keywordId: req.params.keywordId,
});
return next({
status: 500,
message: 'Unable to retrieve movies by keyword.',
});
}
}
);
discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
'/genreslider/movie',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getMovieGenres({
language: (req.query.language as string) ?? req.locale,
});
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverMovies({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,
name: genre.name,
backdrops: genreData.results
.filter((title) => !!title.backdrop_path)
.map((title) => title.backdrop_path) as string[],
});
})
);
const sortedData = sortBy(mappedGenres, 'name');
return res.status(200).json(sortedData);
} catch (e) {
logger.debug('Something went wrong retrieving the movie genre slider', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve movie genre slider.',
});
}
}
);
discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
'/genreslider/tv',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getTvGenres({
language: (req.query.language as string) ?? req.locale,
});
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverTv({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,
name: genre.name,
backdrops: genreData.results
.filter((title) => !!title.backdrop_path)
.map((title) => title.backdrop_path) as string[],
});
})
);
const sortedData = sortBy(mappedGenres, 'name');
return res.status(200).json(sortedData);
} catch (e) {
logger.debug('Something went wrong retrieving the series genre slider', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve series genre slider.',
});
}
}
);
discoverRoutes.get('/music', async (req, res, next) => {
const listenbrainz = new ListenBrainzAPI();
const musicbrainz = new MusicBrainz();
try {
const page = Number(req.query.page) || 1;
const pageSize = 20;
const offset = (page - 1) * pageSize;
const sortBy = (req.query.sortBy as string) || 'listen_count.desc';
const data = await listenbrainz.getTopAlbums({
offset,
count: pageSize,
range: 'week',
});
const media = await Media.getRelatedMedia(
req.user,
data.payload.release_groups.map((album) => album.release_group_mbid)
);
const albumDetailsPromises = data.payload.release_groups.map(
async (album) => {
try {
const details = await musicbrainz.getAlbum({
albumId: album.release_group_mbid,
});
const images =
details.images?.length > 0
? details.images.filter((img) => img.CoverType === 'Cover')
: album.caa_id
? [
{
CoverType: 'Cover',
Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`,
},
]
: [];
return {
id: album.release_group_mbid,
mediaType: 'album',
type: 'Album',
title: album.release_group_name,
artistname: album.artist_name,
artistId: album.artist_mbids[0],
releasedate: details.releasedate || '',
images,
mediaInfo: media?.find(
(med) => med.mbId === album.release_group_mbid
),
listenCount: album.listen_count,
};
} catch (e) {
return {
id: album.release_group_mbid,
mediaType: 'album',
type: 'Album',
title: album.release_group_name,
artistname: album.artist_name,
artistId: album.artist_mbids[0],
releasedate: '',
images: album.caa_id
? [
{
CoverType: 'Cover',
Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`,
},
]
: [],
mediaInfo: media?.find(
(med) => med.mbId === album.release_group_mbid
),
listenCount: album.listen_count,
};
}
}
);
const results = await Promise.all(albumDetailsPromises);
switch (sortBy) {
case 'listen_count.asc':
results.sort((a, b) => a.listenCount - b.listenCount);
break;
case 'listen_count.desc':
results.sort((a, b) => b.listenCount - a.listenCount);
break;
case 'title.asc':
results.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'title.desc':
results.sort((a, b) => b.title.localeCompare(a.title));
break;
case 'release_date.asc':
results.sort((a, b) =>
(a.releasedate || '').localeCompare(b.releasedate || '')
);
break;
case 'release_date.desc':
results.sort((a, b) =>
(b.releasedate || '').localeCompare(a.releasedate || '')
);
break;
}
return res.status(200).json({
page,
totalPages: Math.ceil(data.payload.count / pageSize),
totalResults: data.payload.count,
results,
});
} catch (e) {
logger.debug('Something went wrong retrieving popular music', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve popular music.',
});
}
});
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist',
async (req, res) => {
const userRepository = getRepository(User);
const itemsPerPage = 20;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
where: { id: req.user?.id },
select: ['id', 'plexToken'],
});
if (activeUser && !activeUser?.plexToken) {
// Non-Plex users can only see their own watchlist
const [result, total] = await getRepository(Watchlist).findAndCount({
where: { requestedBy: { id: activeUser?.id } },
relations: {
/*requestedBy: true,media:true*/
},
// loadRelationIds: true,
take: itemsPerPage,
skip: offset,
});
if (total) {
return res.json({
page: page,
totalPages: Math.ceil(total / itemsPerPage),
totalResults: total,
results: result,
});
}
}
if (!activeUser?.plexToken) {
// We will just return an empty array if the user has no Plex token
return res.json({
page: 1,
totalPages: 1,
totalResults: 0,
results: [],
});
}
// List watchlist from Plex
const plexTV = new PlexTvAPI(activeUser.plexToken);
const watchlist = await plexTV.getWatchlist({ offset });
return res.json({
page,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
id: item.tmdbId,
ratingKey: item.ratingKey,
title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId,
})),
});
}
);
export default discoverRoutes;