refactor: return pre-resolved internal IDs from all listing endpoints and remove client-side resolve mutations

All explore, discover, search, recommendation, and person-credit listing procedures now include the internal database `id` on every item so clients can navigate and act without a separate resolve round-trip.

- Remove `titles.resolve` and `people.resolve` mutation calls from the native search screen, hero banners, poster rows, and cast cards; replace with direct `Link` navigation using the pre-returned `id`.
- Change `titles.quickAdd` to accept `{ id }` instead of `{ tmdbId, type }` and update every call site on native and web.
- Key all user-status and episode-progress lookups by `id` instead of `tmdbId-type` composite strings across `PosterCard`, `HorizontalPosterRow`, `FilterableTitleRow`, and `usePosterActions`; drop the `tmdbId` prop from `PosterCard` entirely.
- Remove the `titles.hydrateSeasons` auto-trigger from the title detail screen; season hydration now happens server-side on resolve.
- Delete the `browse-thumbhashes` and `browse-title-ids` server procedures and remove them from the router.
- Extend `@sofa/api` schemas with an `id` field on all listing-item types; update `packages/core` services and add a DB migration accordingly.
This commit is contained in:
2026-03-16 16:35:50 -04:00
parent ccc7922737
commit 25736f684a
48 changed files with 6965 additions and 1777 deletions
@@ -1,43 +0,0 @@
import { db } from "@sofa/db/client";
import { and, inArray } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
type BrowseLookup = {
tmdbId: number;
type: "movie" | "tv";
};
export function browseLookupKey({ tmdbId, type }: BrowseLookup): string {
return `${tmdbId}-${type}`;
}
export function getBrowsePosterThumbHashes(lookups: BrowseLookup[]) {
if (lookups.length === 0) {
return new Map<string, string | null>();
}
const tmdbIds = [...new Set(lookups.map((lookup) => lookup.tmdbId))];
const mediaTypes = [...new Set(lookups.map((lookup) => lookup.type))];
const rows = db
.select({
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(
and(inArray(titles.tmdbId, tmdbIds), inArray(titles.type, mediaTypes)),
)
.all();
return new Map(
rows.map((row) => [
browseLookupKey({
tmdbId: row.tmdbId,
type: row.type as "movie" | "tv",
}),
row.posterThumbHash,
]),
);
}
@@ -1,35 +0,0 @@
import { db } from "@sofa/db/client";
import { inArray } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
interface BrowseTitleLookup {
tmdbId: number;
type: "movie" | "tv";
}
export function getBrowseTitleIds(
lookups: BrowseTitleLookup[],
): Record<string, string> {
if (lookups.length === 0) return {};
const rows = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
})
.from(titles)
.where(
inArray(
titles.tmdbId,
lookups.map((lookup) => lookup.tmdbId),
),
)
.all();
const idsByLookup: Record<string, string> = {};
for (const row of rows) {
idsByLookup[`${row.tmdbId}-${row.type}`] = row.id;
}
return idsByLookup;
}
+17 -20
View File
@@ -1,18 +1,14 @@
import { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import {
getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds,
getEpisodeProgressByTitleIds,
getUserStatusesByTitleIds,
} from "@sofa/core/tracking";
import { discover as discoverTmdb } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
import { getBrowseTitleIds } from "./browse-title-ids";
export const discover = os.discover
.use(authed)
@@ -51,22 +47,23 @@ export const discover = os.discover
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? null,
}));
const titleIdsByLookup = getBrowseTitleIds(
baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
);
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const titleMap = ensureBrowseTitlesExist(baseItems);
const items = baseItems.map((item) => {
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
return {
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
lookups.length > 0
titleIds.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
+55 -36
View File
@@ -1,18 +1,14 @@
import { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import {
getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds,
getEpisodeProgressByTitleIds,
getUserStatusesByTitleIds,
} from "@sofa/core/tracking";
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
import { getBrowseTitleIds } from "./browse-title-ids";
function requireTmdb() {
if (!isTmdbConfigured()) {
@@ -54,29 +50,51 @@ export const trending = os.explore.trending
(r) =>
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
);
const titleIdsByLookup = getBrowseTitleIds([
...baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
// Batch-upsert all browse items (+ hero) into the titles table
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: tmdbImageUrl(
(heroResult.poster_path as string) ?? null,
"posters",
),
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 posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
];
const titleMap = ensureBrowseTitlesExist(allBrowseItems);
const items = baseItems.map((item) => {
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
return {
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const heroEntry = heroResult
? titleMap.get(
`${heroResult.id as number}-${heroResult.media_type as string}`,
)
: undefined;
const hero = heroResult
? {
id: titleIdsByLookup[
`${heroResult.id as number}-${heroResult.media_type as "movie" | "tv"}`
],
id: heroEntry?.id ?? "",
tmdbId: heroResult.id as number,
type: heroResult.media_type as "movie" | "tv",
title:
@@ -90,12 +108,12 @@ export const trending = os.explore.trending
}
: null;
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
lookups.length > 0
titleIds.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
@@ -127,22 +145,23 @@ export const popular = os.explore.popular
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | undefined) ?? null,
}));
const titleIdsByLookup = getBrowseTitleIds(
baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
);
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const titleMap = ensureBrowseTitlesExist(baseItems);
const items = baseItems.map((item) => {
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
return {
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
lookups.length > 0
titleIds.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
+1 -14
View File
@@ -1,9 +1,5 @@
import { ORPCError } from "@orpc/server";
import {
fetchFullFilmography,
getOrFetchPerson,
getOrFetchPersonByTmdbId,
} from "@sofa/core/person";
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { os } from "../context";
import { authed } from "../middleware";
@@ -34,12 +30,3 @@ export const detail = os.people.detail
totalResults: allCredits.length,
};
});
export const resolve = os.people.resolve
.use(authed)
.handler(async ({ input }) => {
const person = await getOrFetchPersonByTmdbId(input.tmdbId);
if (!person)
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
return { id: person.id };
});
+56 -17
View File
@@ -1,4 +1,6 @@
import { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { ensureBrowsePersonsExist } from "@sofa/core/person";
import {
searchMovies,
searchMulti,
@@ -25,23 +27,36 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
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: tmdbImageUrl(r.profile_path ?? null, "profiles"),
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: (personResults.results ?? []).map((r) => ({
tmdbId: r.id,
type: "person" as const,
title: r.name ?? "",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
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,
results: personItems.map((r) => ({
...r,
id: personMap.get(r.tmdbId),
})),
page: personResults.page ?? input.page,
totalPages: personResults.total_pages ?? 1,
@@ -108,8 +123,32 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
})
.filter((r): r is NonNullable<typeof r> => r !== null);
// Batch-import movie/TV results so they have internal IDs
const titleResults = mapped.filter(
(r): r is typeof r & { type: "movie" | "tv" } => r.type !== "person",
);
const titleMap = ensureBrowseTitlesExist(titleResults);
// Batch-import person results so they have internal IDs
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 { ...r, id: personMap.get(r.tmdbId) };
const entry = titleMap.get(`${r.tmdbId}-${r.type}`);
return { ...r, id: entry?.id };
});
return {
results: mapped,
results,
page: raw.page ?? input.page,
totalPages: raw.total_pages ?? 1,
totalResults: raw.total_results ?? 0,
+14 -26
View File
@@ -1,10 +1,6 @@
import { ORPCError } from "@orpc/server";
import { getRecommendationsForTitle } from "@sofa/core/discovery";
import {
ensureTvHydrated,
getOrFetchTitle,
getOrFetchTitleByTmdbId,
} from "@sofa/core/metadata";
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import {
getUserStatusesByTitleIds,
getUserTitleInfo,
@@ -16,7 +12,7 @@ import {
} from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { userTitleStatus } from "@sofa/db/schema";
import { titles, userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
@@ -29,15 +25,6 @@ export const detail = os.titles.detail
return result;
});
export const resolve = os.titles.resolve
.use(authed)
.handler(async ({ input }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
if (!title)
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
return { id: title.id };
});
export const updateStatus = os.titles.updateStatus
.use(authed)
.handler(({ input, context }) => {
@@ -83,23 +70,24 @@ export const recommendations = os.titles.recommendations
return { recommendations: recs, userStatuses };
});
export const hydrateSeasons = os.titles.hydrateSeasons
.use(authed)
.handler(async ({ input }) => {
const seasons = await ensureTvHydrated(input.id, input.tmdbId);
return { seasons };
});
export const quickAdd = os.titles.quickAdd
.use(authed)
.handler(async ({ input, context }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
// Look up the title (it exists as a shell from browse/search import)
const title = db
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles)
.where(eq(titles.id, input.id))
.get();
if (!title) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Failed to import title",
});
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
}
// Trigger full TMDB import if still a shell
getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(
() => {},
);
const existing = db
.select()
.from(userTitleStatus)
-3
View File
@@ -16,14 +16,12 @@ import * as titles from "./procedures/titles";
export const implementedRouter = {
titles: {
detail: titles.detail,
resolve: titles.resolve,
updateStatus: titles.updateStatus,
updateRating: titles.updateRating,
watchMovie: titles.watchMovie,
watchAll: titles.watchAll,
userInfo: titles.userInfo,
recommendations: titles.recommendations,
hydrateSeasons: titles.hydrateSeasons,
quickAdd: titles.quickAdd,
},
episodes: {
@@ -37,7 +35,6 @@ export const implementedRouter = {
},
people: {
detail: people.detail,
resolve: people.resolve,
},
dashboard: {
stats: dashboard.stats,