mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -04:00
refactor(core): move discover logic into @sofa/core/browse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+593
-443
No files matched your search
@@ -1,15 +0,0 @@
|
|||||||
/** TMDB rejects page > 500 (and our PageParam caps at 500). */
|
|
||||||
export const TMDB_MAX_PAGE = 500;
|
|
||||||
|
|
||||||
export function clampTotalPages(totalPages: number | undefined): number {
|
|
||||||
return Math.min(totalPages ?? 1, TMDB_MAX_PAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** TMDB's /discover/tv sorts by first_air_date where /discover/movie uses primary_release_date. */
|
|
||||||
export function tmdbSortBy(type: "movie" | "tv", sortBy: string | undefined): string {
|
|
||||||
const value = sortBy ?? "popularity.desc";
|
|
||||||
if (type === "tv" && value.startsWith("primary_release_date.")) {
|
|
||||||
return value.replace("primary_release_date.", "first_air_date.");
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
@@ -1,419 +1,51 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
|
||||||
|
|
||||||
import { AppErrorCode } from "@sofa/api/errors";
|
|
||||||
import { WATCH_REGION } from "@sofa/config";
|
|
||||||
import { getRecommendationsFeed } from "@sofa/core/discovery";
|
|
||||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
|
||||||
import { ensureBrowsePersonsExist } from "@sofa/core/person";
|
|
||||||
import { getPlatformTmdbIdMap, getPlatformTmdbIds, listPlatforms } from "@sofa/core/platforms";
|
|
||||||
import { getDisplayStatusesByTitleIds, getEpisodeProgressByTitleIds } from "@sofa/core/tracking";
|
|
||||||
import {
|
import {
|
||||||
discover as discoverTmdb,
|
assertTmdbConfigured,
|
||||||
getGenres,
|
browseCatalog,
|
||||||
getPopular,
|
getPopularFeed,
|
||||||
getTrending,
|
getTmdbGenres,
|
||||||
searchMovies,
|
getTrendingFeed,
|
||||||
searchMulti,
|
searchCatalog,
|
||||||
searchPerson,
|
} from "@sofa/core/browse";
|
||||||
searchTv,
|
import { getRecommendationsFeed } from "@sofa/core/discovery";
|
||||||
} from "@sofa/tmdb/client";
|
import { getPlatformTmdbIdMap, listPlatforms } from "@sofa/core/platforms";
|
||||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
|
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
import { clampTotalPages, tmdbSortBy } from "./discover-utils";
|
|
||||||
|
|
||||||
function requireTmdb() {
|
|
||||||
if (!isTmdbConfigured()) {
|
|
||||||
throw new ORPCError("PRECONDITION_FAILED", {
|
|
||||||
message: "TMDB API key is not configured",
|
|
||||||
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Trending ─────────────────────────────────────────────────
|
// ─── Trending ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export const trending = os.discover.trending.use(authed).handler(async ({ input, context }) => {
|
export const trending = os.discover.trending.use(authed).handler(({ input, context }) => {
|
||||||
requireTmdb();
|
assertTmdbConfigured();
|
||||||
|
return getTrendingFeed(context.user.id, input);
|
||||||
const data = await getTrending(input.type, "day", input.page);
|
|
||||||
const results = (data.results ?? []) as Record<string, unknown>[];
|
|
||||||
|
|
||||||
const baseItems = results
|
|
||||||
.filter((r) => r.poster_path)
|
|
||||||
.map((r) => {
|
|
||||||
const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : "movie";
|
|
||||||
return {
|
|
||||||
tmdbId: r.id as number,
|
|
||||||
type: mediaType as "movie" | "tv",
|
|
||||||
title: ((r.title ?? r.name) as string) || "",
|
|
||||||
posterPath: (r.poster_path as string) ?? null,
|
|
||||||
releaseDate: (r.release_date as string | undefined) ?? null,
|
|
||||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
|
||||||
voteAverage: (r.vote_average as number | undefined) ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const heroResult = results.find(
|
|
||||||
(r) => r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allBrowseItems = [
|
|
||||||
...baseItems,
|
|
||||||
...(heroResult
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
tmdbId: heroResult.id as number,
|
|
||||||
type: heroResult.media_type as "movie" | "tv",
|
|
||||||
title: ((heroResult.title ?? heroResult.name) as string | undefined) ?? "",
|
|
||||||
posterPath: (heroResult.poster_path as string) ?? null,
|
|
||||||
releaseDate: (heroResult.release_date as string | undefined) ?? null,
|
|
||||||
firstAirDate: (heroResult.first_air_date as string | undefined) ?? null,
|
|
||||||
voteAverage: (heroResult.vote_average as number | undefined) ?? null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
const titleMap = ensureBrowseTitlesExist(allBrowseItems);
|
|
||||||
|
|
||||||
const items = baseItems.map((item) => {
|
|
||||||
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
|
|
||||||
return Object.assign(item, {
|
|
||||||
id: entry?.id ?? "",
|
|
||||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
|
||||||
posterThumbHash: entry?.posterThumbHash ?? null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const heroEntry = heroResult
|
|
||||||
? titleMap.get(`${heroResult.id as number}-${heroResult.media_type as string}`)
|
|
||||||
: undefined;
|
|
||||||
const hero = heroResult
|
|
||||||
? {
|
|
||||||
id: heroEntry?.id ?? "",
|
|
||||||
tmdbId: heroResult.id as number,
|
|
||||||
type: heroResult.media_type as "movie" | "tv",
|
|
||||||
title: ((heroResult.title ?? heroResult.name) as string | undefined) ?? "",
|
|
||||||
overview: (heroResult.overview as string | undefined) ?? "",
|
|
||||||
backdropPath: tmdbImageUrl((heroResult.backdrop_path as string) ?? null, "backdrops"),
|
|
||||||
voteAverage: heroResult.vote_average as number,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const titleIds = items.map((r) => r.id);
|
|
||||||
const [userStatuses, episodeProgress] =
|
|
||||||
titleIds.length > 0
|
|
||||||
? [
|
|
||||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
|
||||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
|
||||||
]
|
|
||||||
: [{}, {}];
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
hero,
|
|
||||||
userStatuses,
|
|
||||||
episodeProgress,
|
|
||||||
page: (data as { page?: number }).page ?? input.page,
|
|
||||||
totalPages: clampTotalPages((data as { total_pages?: number }).total_pages),
|
|
||||||
totalResults: (data as { total_results?: number }).total_results ?? 0,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Popular ──────────────────────────────────────────────────
|
// ─── Popular ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export const popular = os.discover.popular.use(authed).handler(async ({ input, context }) => {
|
export const popular = os.discover.popular.use(authed).handler(({ input, context }) => {
|
||||||
requireTmdb();
|
assertTmdbConfigured();
|
||||||
|
return getPopularFeed(context.user.id, input);
|
||||||
const data = await getPopular(input.type, input.page);
|
|
||||||
const baseItems = ((data.results ?? []) as Record<string, unknown>[])
|
|
||||||
.filter((r) => r.poster_path)
|
|
||||||
.map((r) => ({
|
|
||||||
tmdbId: r.id as number,
|
|
||||||
type: input.type,
|
|
||||||
title: ((r.title ?? r.name) as string) || "",
|
|
||||||
posterPath: (r.poster_path as string) ?? null,
|
|
||||||
releaseDate: (r.release_date as string | undefined) ?? null,
|
|
||||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
|
||||||
voteAverage: (r.vote_average as number | undefined) ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const titleMap = ensureBrowseTitlesExist(baseItems);
|
|
||||||
const items = baseItems.map((item) => {
|
|
||||||
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
|
|
||||||
return Object.assign(item, {
|
|
||||||
id: entry?.id ?? "",
|
|
||||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
|
||||||
posterThumbHash: entry?.posterThumbHash ?? null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const titleIds = items.map((r) => r.id);
|
|
||||||
const [userStatuses, episodeProgress] =
|
|
||||||
titleIds.length > 0
|
|
||||||
? [
|
|
||||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
|
||||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
|
||||||
]
|
|
||||||
: [{}, {}];
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
userStatuses,
|
|
||||||
episodeProgress,
|
|
||||||
page: data.page ?? input.page,
|
|
||||||
totalPages: clampTotalPages(data.total_pages),
|
|
||||||
totalResults: data.total_results ?? 0,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Search ───────────────────────────────────────────────────
|
// ─── Search ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const search = os.discover.search.use(authed).handler(async ({ input }) => {
|
export const search = os.discover.search.use(authed).handler(({ input }) => {
|
||||||
requireTmdb();
|
assertTmdbConfigured();
|
||||||
|
return searchCatalog(input);
|
||||||
const query = input.query.trim();
|
|
||||||
if (!query) {
|
|
||||||
return { results: [], page: 1, totalPages: 0, totalResults: 0 };
|
|
||||||
}
|
|
||||||
const type = input.type ?? null;
|
|
||||||
|
|
||||||
if (type === "person") {
|
|
||||||
const personResults = await searchPerson(query, input.page);
|
|
||||||
const personItems = (personResults.results ?? []).map((r) => ({
|
|
||||||
tmdbId: r.id,
|
|
||||||
type: "person" as const,
|
|
||||||
title: r.name ?? "",
|
|
||||||
posterPath: null,
|
|
||||||
profilePath: r.profile_path ?? null,
|
|
||||||
overview: null,
|
|
||||||
releaseDate: null,
|
|
||||||
popularity: r.popularity ?? null,
|
|
||||||
voteAverage: null,
|
|
||||||
knownForDepartment: r.known_for_department ?? null,
|
|
||||||
knownFor:
|
|
||||||
(r.known_for
|
|
||||||
?.slice(0, 3)
|
|
||||||
.map((k) => k.title ?? (k as { name?: string }).name)
|
|
||||||
.filter((s): s is string => !!s) as string[]) ?? null,
|
|
||||||
}));
|
|
||||||
const personMap = ensureBrowsePersonsExist(
|
|
||||||
personItems.map((r) => ({
|
|
||||||
tmdbId: r.tmdbId,
|
|
||||||
name: r.title,
|
|
||||||
profilePath: r.profilePath,
|
|
||||||
knownForDepartment: r.knownForDepartment,
|
|
||||||
popularity: r.popularity,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
results: personItems.map((r) =>
|
|
||||||
Object.assign(r, {
|
|
||||||
id: personMap.get(r.tmdbId),
|
|
||||||
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
page: personResults.page ?? input.page,
|
|
||||||
totalPages: clampTotalPages(personResults.total_pages),
|
|
||||||
totalResults: personResults.total_results ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw =
|
|
||||||
type === "movie"
|
|
||||||
? await searchMovies(query, input.page)
|
|
||||||
: type === "tv"
|
|
||||||
? await searchTv(query, input.page)
|
|
||||||
: await searchMulti(query, input.page);
|
|
||||||
|
|
||||||
type SearchResult = {
|
|
||||||
id: number;
|
|
||||||
media_type?: string;
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
overview?: string;
|
|
||||||
poster_path?: string | null;
|
|
||||||
profile_path?: string | null;
|
|
||||||
release_date?: string;
|
|
||||||
first_air_date?: string;
|
|
||||||
popularity?: number;
|
|
||||||
vote_average?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapped = ((raw.results ?? []) as SearchResult[])
|
|
||||||
.map((r) => {
|
|
||||||
if (r.media_type === "person") {
|
|
||||||
return {
|
|
||||||
tmdbId: r.id,
|
|
||||||
type: "person" as const,
|
|
||||||
title: r.name ?? "Unknown",
|
|
||||||
posterPath: null,
|
|
||||||
profilePath: r.profile_path ?? null,
|
|
||||||
overview: null,
|
|
||||||
releaseDate: null,
|
|
||||||
popularity: r.popularity ?? null,
|
|
||||||
voteAverage: null,
|
|
||||||
knownForDepartment: null,
|
|
||||||
knownFor: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type;
|
|
||||||
if (!mediaType) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
tmdbId: r.id,
|
|
||||||
type: mediaType,
|
|
||||||
title: r.title ?? r.name ?? "",
|
|
||||||
overview: r.overview ?? null,
|
|
||||||
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
|
||||||
posterPath: r.poster_path ?? null,
|
|
||||||
profilePath: null,
|
|
||||||
popularity: r.popularity ?? null,
|
|
||||||
voteAverage: r.vote_average ?? null,
|
|
||||||
knownForDepartment: null,
|
|
||||||
knownFor: null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
|
||||||
|
|
||||||
const titleResults = mapped.filter(
|
|
||||||
(r): r is typeof r & { type: "movie" | "tv" } => r.type !== "person",
|
|
||||||
);
|
|
||||||
const titleMap = ensureBrowseTitlesExist(
|
|
||||||
titleResults.map((r) => ({
|
|
||||||
tmdbId: r.tmdbId,
|
|
||||||
type: r.type,
|
|
||||||
title: r.title,
|
|
||||||
overview: r.overview,
|
|
||||||
posterPath: r.posterPath,
|
|
||||||
popularity: r.popularity,
|
|
||||||
voteAverage: r.voteAverage,
|
|
||||||
releaseDate: r.type === "movie" ? r.releaseDate : null,
|
|
||||||
firstAirDate: r.type === "tv" ? r.releaseDate : null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const personResults = mapped.filter((r) => r.type === "person");
|
|
||||||
const personMap = ensureBrowsePersonsExist(
|
|
||||||
personResults.map((r) => ({
|
|
||||||
tmdbId: r.tmdbId,
|
|
||||||
name: r.title,
|
|
||||||
profilePath: r.profilePath,
|
|
||||||
knownForDepartment: r.knownForDepartment,
|
|
||||||
popularity: r.popularity,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = mapped.map((r) => {
|
|
||||||
if (r.type === "person") {
|
|
||||||
return Object.assign(r, {
|
|
||||||
id: personMap.get(r.tmdbId),
|
|
||||||
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const entry = titleMap.get(`${r.tmdbId}-${r.type}`);
|
|
||||||
return Object.assign(r, { id: entry?.id, posterPath: tmdbImageUrl(r.posterPath, "posters") });
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
results,
|
|
||||||
page: raw.page ?? input.page,
|
|
||||||
totalPages: clampTotalPages(raw.total_pages),
|
|
||||||
totalResults: raw.total_results ?? 0,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Browse (filtered discovery) ──────────────────────────────
|
// ─── Browse (filtered discovery) ──────────────────────────────
|
||||||
|
|
||||||
export const browse = os.discover.browse.use(authed).handler(async ({ input, context }) => {
|
export const browse = os.discover.browse.use(authed).handler(({ input, context }) => {
|
||||||
requireTmdb();
|
assertTmdbConfigured();
|
||||||
|
return browseCatalog(context.user.id, input);
|
||||||
const params: Record<string, string> = {
|
|
||||||
sort_by: tmdbSortBy(input.type, input.sortBy),
|
|
||||||
"vote_count.gte": "50",
|
|
||||||
};
|
|
||||||
if (input.genreId) params.with_genres = String(input.genreId);
|
|
||||||
if (input.yearMin) {
|
|
||||||
const key = input.type === "movie" ? "primary_release_date.gte" : "first_air_date.gte";
|
|
||||||
params[key] = `${input.yearMin}-01-01`;
|
|
||||||
}
|
|
||||||
if (input.yearMax) {
|
|
||||||
const key = input.type === "movie" ? "primary_release_date.lte" : "first_air_date.lte";
|
|
||||||
params[key] = `${input.yearMax}-12-31`;
|
|
||||||
}
|
|
||||||
if (input.ratingMin != null) params["vote_average.gte"] = String(input.ratingMin);
|
|
||||||
if (input.language) params.with_original_language = input.language;
|
|
||||||
if (input.platformId) {
|
|
||||||
const tmdbIds = getPlatformTmdbIds(input.platformId);
|
|
||||||
if (tmdbIds.length > 0) {
|
|
||||||
params.with_watch_providers = tmdbIds.join("|");
|
|
||||||
params.watch_region = WATCH_REGION;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await discoverTmdb(input.type, params, input.page);
|
|
||||||
|
|
||||||
type DiscoverResult = NonNullable<typeof results.results>[number] & {
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
release_date?: string;
|
|
||||||
first_air_date?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseItems = ((results.results ?? []) as DiscoverResult[])
|
|
||||||
.filter((r) => r.poster_path)
|
|
||||||
.map((r) => ({
|
|
||||||
tmdbId: r.id,
|
|
||||||
type: input.type,
|
|
||||||
title: r.title ?? r.name ?? "",
|
|
||||||
posterPath: r.poster_path ?? null,
|
|
||||||
releaseDate: (r.release_date as string | undefined) ?? null,
|
|
||||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
|
||||||
voteAverage: r.vote_average ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const titleMap = ensureBrowseTitlesExist(baseItems);
|
|
||||||
const items = baseItems.map((item) => {
|
|
||||||
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
|
|
||||||
return Object.assign(item, {
|
|
||||||
id: entry?.id ?? "",
|
|
||||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
|
||||||
posterThumbHash: entry?.posterThumbHash ?? null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const titleIds = items.map((r) => r.id);
|
|
||||||
const [userStatuses, episodeProgress] =
|
|
||||||
titleIds.length > 0
|
|
||||||
? [
|
|
||||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
|
||||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
|
||||||
]
|
|
||||||
: [{}, {}];
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
userStatuses,
|
|
||||||
episodeProgress,
|
|
||||||
page: results.page ?? input.page,
|
|
||||||
totalPages: clampTotalPages(results.total_pages),
|
|
||||||
totalResults: results.total_results ?? 0,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Genres ───────────────────────────────────────────────────
|
// ─── Genres ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const genres = os.discover.genres.use(authed).handler(async ({ input }) => {
|
export const genres = os.discover.genres.use(authed).handler(({ input }) => {
|
||||||
requireTmdb();
|
assertTmdbConfigured();
|
||||||
const data = await getGenres(input.type);
|
return getTmdbGenres(input.type);
|
||||||
return {
|
|
||||||
genres: (data.genres ?? []).map((g) => ({
|
|
||||||
id: g.id,
|
|
||||||
name: g.name ?? "",
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Platforms ────────────────────────────────────────────────
|
// ─── Platforms ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { clampTotalPages, tmdbSortBy } from "../src/orpc/procedures/discover-utils";
|
|
||||||
|
|
||||||
describe("clampTotalPages", () => {
|
|
||||||
test("defaults to 1 when undefined", () => {
|
|
||||||
expect(clampTotalPages(undefined)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes through values under the cap", () => {
|
|
||||||
expect(clampTotalPages(42)).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("caps at 500", () => {
|
|
||||||
expect(clampTotalPages(1234)).toBe(500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("tmdbSortBy", () => {
|
|
||||||
test("maps TV date sorts to first_air_date", () => {
|
|
||||||
expect(tmdbSortBy("tv", "primary_release_date.desc")).toBe("first_air_date.desc");
|
|
||||||
expect(tmdbSortBy("tv", "primary_release_date.asc")).toBe("first_air_date.asc");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves movie date sorts unchanged", () => {
|
|
||||||
expect(tmdbSortBy("movie", "primary_release_date.desc")).toBe("primary_release_date.desc");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defaults to popularity.desc", () => {
|
|
||||||
expect(tmdbSortBy("tv", undefined)).toBe("popularity.desc");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves other TV sorts unchanged", () => {
|
|
||||||
expect(tmdbSortBy("tv", "vote_average.desc")).toBe("vote_average.desc");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./availability": "./src/availability.ts",
|
"./availability": "./src/availability.ts",
|
||||||
"./backup": "./src/backup.ts",
|
"./backup": "./src/backup.ts",
|
||||||
|
"./browse": "./src/browse.ts",
|
||||||
"./cache": "./src/cache.ts",
|
"./cache": "./src/cache.ts",
|
||||||
"./colors": "./src/colors.ts",
|
"./colors": "./src/colors.ts",
|
||||||
"./credits": "./src/credits.ts",
|
"./credits": "./src/credits.ts",
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
|
import type { DiscoverInput } from "@sofa/api/schemas";
|
||||||
|
import { WATCH_REGION } from "@sofa/config";
|
||||||
|
import {
|
||||||
|
discover,
|
||||||
|
getGenres,
|
||||||
|
getPopular,
|
||||||
|
getTrending,
|
||||||
|
searchMovies,
|
||||||
|
searchMulti,
|
||||||
|
searchPerson,
|
||||||
|
searchTv,
|
||||||
|
} from "@sofa/tmdb/client";
|
||||||
|
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||||
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
|
|
||||||
|
import { ensureBrowseTitlesExist } from "./metadata";
|
||||||
|
import { ensureBrowsePersonsExist } from "./person";
|
||||||
|
import { getPlatformTmdbIds } from "./platforms";
|
||||||
|
import { getDisplayStatusesByTitleIds, getEpisodeProgressByTitleIds } from "./tracking";
|
||||||
|
|
||||||
|
// ─── Paging helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** TMDB rejects page > 500 (and our PageParam caps at 500). */
|
||||||
|
export const TMDB_MAX_PAGE = 500;
|
||||||
|
|
||||||
|
export function clampTotalPages(totalPages: number | undefined): number {
|
||||||
|
return Math.min(totalPages ?? 1, TMDB_MAX_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TMDB's /discover/tv sorts by first_air_date where /discover/movie uses primary_release_date. */
|
||||||
|
export function tmdbSortBy(type: "movie" | "tv", sortBy: string | undefined): string {
|
||||||
|
const value = sortBy ?? "popularity.desc";
|
||||||
|
if (type === "tv" && value.startsWith("primary_release_date.")) {
|
||||||
|
return value.replace("primary_release_date.", "first_air_date.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
type MediaType = "movie" | "tv";
|
||||||
|
|
||||||
|
/** The subset of a TMDB list/search result the browse endpoints read. */
|
||||||
|
export type TmdbListResult = {
|
||||||
|
id: number;
|
||||||
|
media_type?: string;
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
overview?: string;
|
||||||
|
poster_path?: string | null;
|
||||||
|
backdrop_path?: string | null;
|
||||||
|
profile_path?: string | null;
|
||||||
|
release_date?: string;
|
||||||
|
first_air_date?: string;
|
||||||
|
popularity?: number;
|
||||||
|
vote_average?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TmdbPage = {
|
||||||
|
page?: number;
|
||||||
|
total_pages?: number;
|
||||||
|
total_results?: number;
|
||||||
|
results?: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowseItem = {
|
||||||
|
tmdbId: number;
|
||||||
|
type: MediaType;
|
||||||
|
title: string;
|
||||||
|
posterPath: string | null;
|
||||||
|
releaseDate: string | null;
|
||||||
|
firstAirDate: string | null;
|
||||||
|
voteAverage: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscoverFilters = z.infer<typeof DiscoverInput>;
|
||||||
|
|
||||||
|
export function assertTmdbConfigured(): void {
|
||||||
|
if (!isTmdbConfigured()) {
|
||||||
|
throw new ORPCError("PRECONDITION_FAILED", {
|
||||||
|
message: "TMDB API key is not configured",
|
||||||
|
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listResults(data: TmdbPage): TmdbListResult[] {
|
||||||
|
return (data.results ?? []) as TmdbListResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagingOf(data: TmdbPage, requestedPage: number) {
|
||||||
|
return {
|
||||||
|
page: data.page ?? requestedPage,
|
||||||
|
totalPages: clampTotalPages(data.total_pages),
|
||||||
|
totalResults: data.total_results ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMediaType(value: string | undefined): value is MediaType {
|
||||||
|
return value === "movie" || value === "tv";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBrowseItem(r: TmdbListResult, type: MediaType): BrowseItem {
|
||||||
|
return {
|
||||||
|
tmdbId: r.id,
|
||||||
|
type,
|
||||||
|
title: (r.title ?? r.name) || "",
|
||||||
|
posterPath: r.poster_path ?? null,
|
||||||
|
releaseDate: r.release_date ?? null,
|
||||||
|
firstAirDate: r.first_air_date ?? null,
|
||||||
|
voteAverage: r.vote_average ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists shell titles for `items` (plus any `extra` entries) and attaches ids and image URLs. */
|
||||||
|
function withTitleIds(items: BrowseItem[], extra: BrowseItem[] = []) {
|
||||||
|
const titleMap = ensureBrowseTitlesExist([...items, ...extra]);
|
||||||
|
const withIds = items.map((item) => {
|
||||||
|
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: entry?.id ?? "",
|
||||||
|
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
||||||
|
posterThumbHash: entry?.posterThumbHash ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { items: withIds, titleMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function userStateFor(userId: string, titleIds: string[]) {
|
||||||
|
if (titleIds.length === 0) {
|
||||||
|
return { userStatuses: {}, episodeProgress: {} };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userStatuses: getDisplayStatusesByTitleIds(userId, titleIds),
|
||||||
|
episodeProgress: getEpisodeProgressByTitleIds(userId, titleIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Trending ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTrendingFeed(
|
||||||
|
userId: string,
|
||||||
|
input: { type: "all" | "movie" | "tv"; page: number },
|
||||||
|
) {
|
||||||
|
const data = await getTrending(input.type, "day", input.page);
|
||||||
|
const results = listResults(data);
|
||||||
|
|
||||||
|
const baseItems = results
|
||||||
|
.filter((r) => r.poster_path)
|
||||||
|
.map((r) => toBrowseItem(r, isMediaType(r.media_type) ? r.media_type : "movie"));
|
||||||
|
const heroResult = results.find((r) => r.backdrop_path && isMediaType(r.media_type));
|
||||||
|
const heroItem =
|
||||||
|
heroResult && isMediaType(heroResult.media_type)
|
||||||
|
? toBrowseItem(heroResult, heroResult.media_type)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { items, titleMap } = withTitleIds(baseItems, heroItem ? [heroItem] : []);
|
||||||
|
|
||||||
|
const heroEntry = heroItem ? titleMap.get(`${heroItem.tmdbId}-${heroItem.type}`) : undefined;
|
||||||
|
const hero =
|
||||||
|
heroResult && heroItem
|
||||||
|
? {
|
||||||
|
id: heroEntry?.id ?? "",
|
||||||
|
tmdbId: heroItem.tmdbId,
|
||||||
|
type: heroItem.type,
|
||||||
|
title: heroItem.title,
|
||||||
|
overview: heroResult.overview ?? "",
|
||||||
|
backdropPath: tmdbImageUrl(heroResult.backdrop_path ?? null, "backdrops"),
|
||||||
|
voteAverage: heroResult.vote_average ?? 0,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
hero,
|
||||||
|
...userStateFor(
|
||||||
|
userId,
|
||||||
|
items.map((item) => item.id),
|
||||||
|
),
|
||||||
|
...pagingOf(data, input.page),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Popular ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getPopularFeed(userId: string, input: { type: MediaType; page: number }) {
|
||||||
|
const data = await getPopular(input.type, input.page);
|
||||||
|
const baseItems = listResults(data)
|
||||||
|
.filter((r) => r.poster_path)
|
||||||
|
.map((r) => toBrowseItem(r, input.type));
|
||||||
|
|
||||||
|
const { items } = withTitleIds(baseItems);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
...userStateFor(
|
||||||
|
userId,
|
||||||
|
items.map((item) => item.id),
|
||||||
|
),
|
||||||
|
...pagingOf(data, input.page),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Search ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SearchType = "movie" | "tv" | "person";
|
||||||
|
|
||||||
|
function toPersonResult(r: TmdbListResult) {
|
||||||
|
return {
|
||||||
|
tmdbId: r.id,
|
||||||
|
type: "person" as const,
|
||||||
|
title: r.name ?? "Unknown",
|
||||||
|
posterPath: null,
|
||||||
|
profilePath: r.profile_path ?? null,
|
||||||
|
overview: null,
|
||||||
|
releaseDate: null,
|
||||||
|
popularity: r.popularity ?? null,
|
||||||
|
voteAverage: null,
|
||||||
|
knownForDepartment: null,
|
||||||
|
knownFor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTitleSearchResult(r: TmdbListResult, type: MediaType) {
|
||||||
|
return {
|
||||||
|
tmdbId: r.id,
|
||||||
|
type,
|
||||||
|
title: r.title ?? r.name ?? "",
|
||||||
|
overview: r.overview ?? null,
|
||||||
|
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
||||||
|
posterPath: r.poster_path ?? null,
|
||||||
|
profilePath: null,
|
||||||
|
popularity: r.popularity ?? null,
|
||||||
|
voteAverage: r.vote_average ?? null,
|
||||||
|
knownForDepartment: null,
|
||||||
|
knownFor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchPeople(query: string, requestedPage: number) {
|
||||||
|
const personResults = await searchPerson(query, requestedPage);
|
||||||
|
const personItems = (personResults.results ?? []).map((r) => ({
|
||||||
|
tmdbId: r.id,
|
||||||
|
type: "person" as const,
|
||||||
|
title: r.name ?? "",
|
||||||
|
posterPath: null,
|
||||||
|
profilePath: r.profile_path ?? null,
|
||||||
|
overview: null,
|
||||||
|
releaseDate: null,
|
||||||
|
popularity: r.popularity ?? null,
|
||||||
|
voteAverage: null,
|
||||||
|
knownForDepartment: r.known_for_department ?? null,
|
||||||
|
knownFor:
|
||||||
|
r.known_for
|
||||||
|
?.slice(0, 3)
|
||||||
|
.map((k) => k.title ?? (k as { name?: string }).name)
|
||||||
|
.filter((s): s is string => !!s) ?? null,
|
||||||
|
}));
|
||||||
|
const personMap = ensureBrowsePersonsExist(
|
||||||
|
personItems.map((r) => ({
|
||||||
|
tmdbId: r.tmdbId,
|
||||||
|
name: r.title,
|
||||||
|
profilePath: r.profilePath,
|
||||||
|
knownForDepartment: r.knownForDepartment,
|
||||||
|
popularity: r.popularity,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
results: personItems.map((r) =>
|
||||||
|
Object.assign(r, {
|
||||||
|
id: personMap.get(r.tmdbId),
|
||||||
|
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...pagingOf(personResults, requestedPage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCatalog(input: {
|
||||||
|
query: string;
|
||||||
|
type?: SearchType | null;
|
||||||
|
page: number;
|
||||||
|
}) {
|
||||||
|
const query = input.query.trim();
|
||||||
|
if (!query) {
|
||||||
|
return { results: [], page: 1, totalPages: 0, totalResults: 0 };
|
||||||
|
}
|
||||||
|
const type = input.type ?? null;
|
||||||
|
|
||||||
|
if (type === "person") {
|
||||||
|
return searchPeople(query, input.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: TmdbPage =
|
||||||
|
type === "movie"
|
||||||
|
? await searchMovies(query, input.page)
|
||||||
|
: type === "tv"
|
||||||
|
? await searchTv(query, input.page)
|
||||||
|
: await searchMulti(query, input.page);
|
||||||
|
|
||||||
|
const mapped = listResults(raw)
|
||||||
|
.map((r) => {
|
||||||
|
if (r.media_type === "person") return toPersonResult(r);
|
||||||
|
const mediaType = isMediaType(r.media_type) ? r.media_type : type;
|
||||||
|
if (!mediaType) return null;
|
||||||
|
return toTitleSearchResult(r, mediaType);
|
||||||
|
})
|
||||||
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
|
||||||
|
const titleResults = mapped.filter(
|
||||||
|
(r): r is typeof r & { type: MediaType } => r.type !== "person",
|
||||||
|
);
|
||||||
|
const titleMap = ensureBrowseTitlesExist(
|
||||||
|
titleResults.map((r) => ({
|
||||||
|
tmdbId: r.tmdbId,
|
||||||
|
type: r.type,
|
||||||
|
title: r.title,
|
||||||
|
overview: r.overview,
|
||||||
|
posterPath: r.posterPath,
|
||||||
|
popularity: r.popularity,
|
||||||
|
voteAverage: r.voteAverage,
|
||||||
|
releaseDate: r.type === "movie" ? r.releaseDate : null,
|
||||||
|
firstAirDate: r.type === "tv" ? r.releaseDate : null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const personMap = ensureBrowsePersonsExist(
|
||||||
|
mapped
|
||||||
|
.filter((r) => r.type === "person")
|
||||||
|
.map((r) => ({
|
||||||
|
tmdbId: r.tmdbId,
|
||||||
|
name: r.title,
|
||||||
|
profilePath: r.profilePath,
|
||||||
|
knownForDepartment: r.knownForDepartment,
|
||||||
|
popularity: r.popularity,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = mapped.map((r) => {
|
||||||
|
if (r.type === "person") {
|
||||||
|
return Object.assign(r, {
|
||||||
|
id: personMap.get(r.tmdbId),
|
||||||
|
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entry = titleMap.get(`${r.tmdbId}-${r.type}`);
|
||||||
|
return Object.assign(r, { id: entry?.id, posterPath: tmdbImageUrl(r.posterPath, "posters") });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { results, ...pagingOf(raw, input.page) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Browse (filtered discovery) ──────────────────────────────
|
||||||
|
|
||||||
|
/** Builds the TMDB /discover query from the user's filters. */
|
||||||
|
export function buildDiscoverParams(
|
||||||
|
input: DiscoverFilters,
|
||||||
|
platformTmdbIds: number[],
|
||||||
|
watchRegion: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
sort_by: tmdbSortBy(input.type, input.sortBy),
|
||||||
|
"vote_count.gte": "50",
|
||||||
|
};
|
||||||
|
const dateKey = input.type === "movie" ? "primary_release_date" : "first_air_date";
|
||||||
|
if (input.genreId) params.with_genres = String(input.genreId);
|
||||||
|
if (input.yearMin) params[`${dateKey}.gte`] = `${input.yearMin}-01-01`;
|
||||||
|
if (input.yearMax) params[`${dateKey}.lte`] = `${input.yearMax}-12-31`;
|
||||||
|
if (input.ratingMin != null) params["vote_average.gte"] = String(input.ratingMin);
|
||||||
|
if (input.language) params.with_original_language = input.language;
|
||||||
|
if (platformTmdbIds.length > 0) {
|
||||||
|
params.with_watch_providers = platformTmdbIds.join("|");
|
||||||
|
params.watch_region = watchRegion;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browseCatalog(userId: string, input: DiscoverFilters) {
|
||||||
|
const platformTmdbIds = input.platformId ? getPlatformTmdbIds(input.platformId) : [];
|
||||||
|
const params = buildDiscoverParams(input, platformTmdbIds, WATCH_REGION);
|
||||||
|
|
||||||
|
const data = await discover(input.type, params, input.page);
|
||||||
|
const baseItems = listResults(data)
|
||||||
|
.filter((r) => r.poster_path)
|
||||||
|
.map((r) => toBrowseItem(r, input.type));
|
||||||
|
|
||||||
|
const { items } = withTitleIds(baseItems);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
...userStateFor(
|
||||||
|
userId,
|
||||||
|
items.map((item) => item.id),
|
||||||
|
),
|
||||||
|
...pagingOf(data, input.page),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Genres ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTmdbGenres(type: MediaType) {
|
||||||
|
const data = await getGenres(type);
|
||||||
|
return {
|
||||||
|
genres: (data.genres ?? []).map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name ?? "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { buildDiscoverParams, clampTotalPages, tmdbSortBy, toBrowseItem } from "../src/browse";
|
||||||
|
|
||||||
|
describe("clampTotalPages", () => {
|
||||||
|
test("defaults to 1 when undefined", () => {
|
||||||
|
expect(clampTotalPages(undefined)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through values under the cap", () => {
|
||||||
|
expect(clampTotalPages(42)).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caps at 500", () => {
|
||||||
|
expect(clampTotalPages(1234)).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tmdbSortBy", () => {
|
||||||
|
test("maps TV date sorts to first_air_date", () => {
|
||||||
|
expect(tmdbSortBy("tv", "primary_release_date.desc")).toBe("first_air_date.desc");
|
||||||
|
expect(tmdbSortBy("tv", "primary_release_date.asc")).toBe("first_air_date.asc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves movie date sorts unchanged", () => {
|
||||||
|
expect(tmdbSortBy("movie", "primary_release_date.desc")).toBe("primary_release_date.desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to popularity.desc", () => {
|
||||||
|
expect(tmdbSortBy("tv", undefined)).toBe("popularity.desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves other TV sorts unchanged", () => {
|
||||||
|
expect(tmdbSortBy("tv", "vote_average.desc")).toBe("vote_average.desc");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toBrowseItem", () => {
|
||||||
|
test("uses title for movies", () => {
|
||||||
|
expect(
|
||||||
|
toBrowseItem(
|
||||||
|
{
|
||||||
|
id: 603,
|
||||||
|
title: "The Matrix",
|
||||||
|
poster_path: "/matrix.jpg",
|
||||||
|
release_date: "1999-03-31",
|
||||||
|
vote_average: 8.2,
|
||||||
|
},
|
||||||
|
"movie",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
tmdbId: 603,
|
||||||
|
type: "movie",
|
||||||
|
title: "The Matrix",
|
||||||
|
posterPath: "/matrix.jpg",
|
||||||
|
releaseDate: "1999-03-31",
|
||||||
|
firstAirDate: null,
|
||||||
|
voteAverage: 8.2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to name for TV shows", () => {
|
||||||
|
expect(
|
||||||
|
toBrowseItem({ id: 1399, name: "Game of Thrones", first_air_date: "2011-04-17" }, "tv"),
|
||||||
|
).toMatchObject({
|
||||||
|
tmdbId: 1399,
|
||||||
|
type: "tv",
|
||||||
|
title: "Game of Thrones",
|
||||||
|
releaseDate: null,
|
||||||
|
firstAirDate: "2011-04-17",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps missing poster, dates, and rating to null and a missing title to empty", () => {
|
||||||
|
expect(toBrowseItem({ id: 1, poster_path: null }, "movie")).toEqual({
|
||||||
|
tmdbId: 1,
|
||||||
|
type: "movie",
|
||||||
|
title: "",
|
||||||
|
posterPath: null,
|
||||||
|
releaseDate: null,
|
||||||
|
firstAirDate: null,
|
||||||
|
voteAverage: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the type it is given, not the result's media_type", () => {
|
||||||
|
expect(toBrowseItem({ id: 2, media_type: "tv", name: "X" }, "movie").type).toBe("movie");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDiscoverParams", () => {
|
||||||
|
test("defaults to popularity sort with a minimum vote count", () => {
|
||||||
|
expect(buildDiscoverParams({ type: "movie", page: 1 }, [], "US")).toEqual({
|
||||||
|
sort_by: "popularity.desc",
|
||||||
|
"vote_count.gte": "50",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses primary_release_date bounds for movies", () => {
|
||||||
|
const params = buildDiscoverParams(
|
||||||
|
{ type: "movie", page: 1, yearMin: 1990, yearMax: 1999, genreId: 28, ratingMin: 7 },
|
||||||
|
[],
|
||||||
|
"US",
|
||||||
|
);
|
||||||
|
expect(params).toMatchObject({
|
||||||
|
"primary_release_date.gte": "1990-01-01",
|
||||||
|
"primary_release_date.lte": "1999-12-31",
|
||||||
|
with_genres: "28",
|
||||||
|
"vote_average.gte": "7",
|
||||||
|
});
|
||||||
|
expect(params).not.toHaveProperty("first_air_date.gte");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses first_air_date bounds and mapped sort for TV", () => {
|
||||||
|
const params = buildDiscoverParams(
|
||||||
|
{
|
||||||
|
type: "tv",
|
||||||
|
page: 1,
|
||||||
|
sortBy: "primary_release_date.asc",
|
||||||
|
yearMin: 2000,
|
||||||
|
yearMax: 2010,
|
||||||
|
language: "ko",
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
"US",
|
||||||
|
);
|
||||||
|
expect(params).toMatchObject({
|
||||||
|
sort_by: "first_air_date.asc",
|
||||||
|
"first_air_date.gte": "2000-01-01",
|
||||||
|
"first_air_date.lte": "2010-12-31",
|
||||||
|
with_original_language: "ko",
|
||||||
|
});
|
||||||
|
expect(params).not.toHaveProperty("primary_release_date.gte");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a rating minimum of 0", () => {
|
||||||
|
expect(buildDiscoverParams({ type: "movie", page: 1, ratingMin: 0 }, [], "US")).toMatchObject({
|
||||||
|
"vote_average.gte": "0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds watch provider filters when the platform has TMDB ids", () => {
|
||||||
|
expect(buildDiscoverParams({ type: "movie", page: 1 }, [8, 337, 15], "GB")).toMatchObject({
|
||||||
|
with_watch_providers: "8|337|15",
|
||||||
|
watch_region: "GB",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits watch provider filters when the id list is empty", () => {
|
||||||
|
const params = buildDiscoverParams({ type: "movie", page: 1 }, [], "GB");
|
||||||
|
expect(params).not.toHaveProperty("with_watch_providers");
|
||||||
|
expect(params).not.toHaveProperty("watch_region");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user