mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
|
||||
@@ -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,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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user