refactor(core): move discover logic into @sofa/core/browse

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 03:56:10 -04:00
1 parent 07e8e79e51
commit 0f82de55cb
6 files changed
+593 -443

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
"exports": {
"./availability": "./src/availability.ts",
"./backup": "./src/backup.ts",
"./browse": "./src/browse.ts",
"./cache": "./src/cache.ts",
"./colors": "./src/colors.ts",
"./credits": "./src/credits.ts",
+414
View File
@@ -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 ?? "",
})),
};
}
+154
View File
@@ -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");
});
});