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
+10 -55
View File
@@ -4,7 +4,7 @@ import {
useInfiniteQuery,
useMutation,
} from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router";
import { Stack } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
@@ -19,10 +19,8 @@ import { useDebounce } from "@/hooks/use-debounce";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
export default function SearchScreen() {
const { navigate } = useRouter();
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query.trim(), 300);
@@ -38,36 +36,9 @@ export default function SearchScreen() {
}),
});
// Track which item is currently being resolved/added
const [resolvingId, setResolvingId] = useState<string | null>(null);
// Track which item is currently being added
const [addingId, setAddingId] = useState<string | null>(null);
const resolveTitleMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
setResolvingId(null);
if (id) navigate(`/title/${id}`);
},
onError: () => {
setResolvingId(null);
toast.error("Failed to load title");
},
}),
);
const resolvePersonMutation = useMutation(
orpc.people.resolve.mutationOptions({
onSuccess: ({ id }) => {
setResolvingId(null);
if (id) navigate(`/person/${id}`);
},
onError: () => {
setResolvingId(null);
toast.error("Failed to load person");
},
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
@@ -83,27 +54,13 @@ export default function SearchScreen() {
}),
);
// Use refs for mutation.mutate to keep callbacks stable across renders
const resolveTitleMutateRef = useRef(resolveTitleMutation.mutate);
resolveTitleMutateRef.current = resolveTitleMutation.mutate;
const resolvePersonMutateRef = useRef(resolvePersonMutation.mutate);
resolvePersonMutateRef.current = resolvePersonMutation.mutate;
// Use ref for mutation.mutate to keep callback stable across renders
const quickAddMutateRef = useRef(quickAddMutation.mutate);
quickAddMutateRef.current = quickAddMutation.mutate;
const handleResolve = useCallback((item: SearchResultItem) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setResolvingId(`${item.type}-${item.tmdbId}`);
if (item.type === "person") {
resolvePersonMutateRef.current({ tmdbId: item.tmdbId });
} else {
resolveTitleMutateRef.current({ tmdbId: item.tmdbId, type: item.type });
}
}, []);
const handleQuickAdd = useCallback((tmdbId: number, type: "movie" | "tv") => {
setAddingId(`${type}-${tmdbId}`);
quickAddMutateRef.current({ tmdbId, type });
const handleQuickAdd = useCallback((id: string) => {
setAddingId(id);
quickAddMutateRef.current({ id });
}, []);
// Memoize mapped results to maintain stable references
@@ -111,7 +68,7 @@ export default function SearchScreen() {
() =>
searchResults.data?.pages.flatMap((page) =>
page.results.map((r) => ({
tmdbId: r.tmdbId,
id: r.id,
title: r.title,
type: r.type,
posterPath: r.posterPath,
@@ -126,17 +83,15 @@ export default function SearchScreen() {
({ item }: { item: SearchResultItem }) => (
<SearchResultRow
item={item}
onResolve={handleResolve}
onQuickAdd={handleQuickAdd}
isResolving={resolvingId === `${item.type}-${item.tmdbId}`}
isAdding={addingId === `${item.type}-${item.tmdbId}`}
isAdding={addingId === item.id}
/>
),
[handleResolve, handleQuickAdd, resolvingId, addingId],
[handleQuickAdd, addingId],
);
const keyExtractor = useCallback(
(item: SearchResultItem) => `${item.type}-${item.tmdbId}`,
(item: SearchResultItem) => `${item.type}-${item.id}`,
[],
);
+2 -5
View File
@@ -75,7 +75,7 @@ export default function PersonDetailScreen() {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const { handlePress, handleQuickAdd, addingKey, failedKey, resetError } =
const { handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions();
const {
@@ -134,7 +134,6 @@ export default function PersonDetailScreen() {
>
<PosterCard
id={credit.titleId}
tmdbId={credit.tmdbId}
title={credit.title}
type={credit.type}
posterPath={credit.posterPath}
@@ -143,9 +142,8 @@ export default function PersonDetailScreen() {
voteAverage={credit.voteAverage}
userStatus={userStatuses[credit.titleId] ?? null}
width={columnWidth}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${credit.tmdbId}-${credit.type}`}
isAdding={addingKey === credit.titleId}
failedKey={failedKey}
onQuickAddFailed={resetError}
/>
@@ -154,7 +152,6 @@ export default function PersonDetailScreen() {
[
columnWidth,
userStatuses,
handlePress,
handleQuickAdd,
addingKey,
failedKey,
+2 -40
View File
@@ -16,7 +16,7 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient";
import { Link, useLocalSearchParams, useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -168,14 +168,6 @@ export default function TitleDetailScreen() {
}),
);
const hydrateMutation = useMutation(
orpc.titles.hydrateSeasons.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
}),
);
const title = detail.data?.title;
const palette = title?.colorPalette ?? null;
useTitleTheme(palette);
@@ -212,7 +204,6 @@ export default function TitleDetailScreen() {
() =>
(recommendations.data?.recommendations ?? []).map((item) => ({
id: item.id,
tmdbId: item.tmdbId,
title: item.title,
type: item.type,
posterPath: item.posterPath,
@@ -231,12 +222,6 @@ export default function TitleDetailScreen() {
[],
);
const hydratedTitleId = useRef<string | null>(null);
const hydrateSeasonsRef = useRef(hydrateMutation.mutate);
useEffect(() => {
hydrateSeasonsRef.current = hydrateMutation.mutate;
}, [hydrateMutation.mutate]);
const titleScrollContentStyle = useMemo(
() => ({
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
@@ -271,17 +256,6 @@ export default function TitleDetailScreen() {
[palette?.darkVibrant],
);
useEffect(() => {
if (
detail.data?.needsHydration &&
title?.type === "tv" &&
hydratedTitleId.current !== id
) {
hydratedTitleId.current = id;
hydrateSeasonsRef.current({ id, tmdbId: title.tmdbId });
}
}, [detail.data?.needsHydration, title?.type, title?.tmdbId, id]);
if (detail.isPending) {
return (
<>
@@ -487,10 +461,7 @@ export default function TitleDetailScreen() {
currentStatus={userInfo.data?.status ?? null}
onStatusChange={(status) => {
if (status === "watchlist") {
quickAddMutation.mutate({
tmdbId: title.tmdbId,
type: title.type,
});
quickAddMutation.mutate({ id });
} else {
updateStatus.mutate({ id, status: null });
}
@@ -620,15 +591,6 @@ export default function TitleDetailScreen() {
</Animated.View>
)}
{hydrateMutation.isPending && (
<View className="items-center py-6">
<Spinner colorClassName="accent-title-accent" />
<Text className="mt-2 text-muted-foreground text-sm">
Loading season data...
</Text>
</View>
)}
{/* Cast */}
{cast.length > 0 && (
<Animated.View
@@ -10,8 +10,7 @@ import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { usePosterActions } from "@/hooks/use-poster-actions";
export interface PosterRowItem {
id?: string;
tmdbId: number;
id: string;
title: string;
type: string;
posterPath: string | null;
@@ -30,17 +29,13 @@ export function HorizontalPosterRow({
items: PosterRowItem[];
isLoading?: boolean;
}) {
const { handlePress, handleQuickAdd, addingKey, failedKey, resetError } =
const { handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions();
const keyExtractor = useCallback(
(item: PosterRowItem) => item.id ?? `${item.tmdbId}-${item.type}`,
[],
);
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => (
<PosterCard
id={item.id}
tmdbId={item.tmdbId}
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
@@ -49,14 +44,13 @@ export function HorizontalPosterRow({
voteAverage={item.voteAverage}
userStatus={item.userStatus}
episodeProgress={item.episodeProgress}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
isAdding={addingKey === item.id}
failedKey={failedKey}
onQuickAddFailed={resetError}
/>
),
[addingKey, failedKey, handlePress, handleQuickAdd, resetError],
[addingKey, failedKey, handleQuickAdd, resetError],
);
if (isLoading) {
@@ -28,8 +28,7 @@ export function FilterableTitleRow({
icon: Icon;
mediaType: "movie" | "tv";
defaultItems: Array<{
id?: string;
tmdbId: number;
id: string;
title: string;
type: string;
posterPath: string | null;
@@ -93,14 +92,11 @@ export function FilterableTitleRow({
// Map items into PosterRowItem shape with status/progress resolved
const items = useMemo<PosterRowItem[]>(
() =>
rawItems.map((item) => {
const key = `${item.tmdbId}-${item.type}`;
return {
...item,
userStatus: userStatuses[key] ?? null,
episodeProgress: episodeProgress[key] ?? null,
};
}),
rawItems.map((item) => ({
...item,
userStatus: userStatuses[item.id] ?? null,
episodeProgress: episodeProgress[item.id] ?? null,
})),
[rawItems, userStatuses, episodeProgress],
);
@@ -1,9 +1,7 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { Link, useRouter } from "expo-router";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Link } from "expo-router";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
@@ -15,12 +13,9 @@ import Animated, {
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
id?: string;
tmdbId: number;
id: string;
title: string;
type: string;
backdropPath?: string | null;
@@ -30,7 +25,6 @@ export interface HeroBannerItem {
}
export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
@@ -42,22 +36,6 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
],
}));
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const handlePress = useCallback(() => {
resolveMutation.mutate({
tmdbId: item.tmdbId,
type: item.type as "movie" | "tv",
});
}, [item.tmdbId, item.type, resolveMutation]);
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
@@ -74,124 +52,117 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
]
.filter(Boolean)
.join(", ");
const titleHref = item.id
? (`/title/${item.id}` as `/title/${string}`)
: null;
const titleHref = `/title/${item.id}` as `/title/${string}`;
const bannerContent = (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
opacity: resolveMutation.isPending ? 0.7 : 1,
borderCurve: "continuous",
},
]}
>
<Pressable
onPress={titleHref ? undefined : handlePress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
return (
<Link href={titleHref}>
<Link.Trigger>
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
borderCurve: "continuous",
},
]}
>
<View
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</GlassView>
) : (
<>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.overview}
{item.title}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</GlassView>
) : (
<>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</>
)}
</Pressable>
</Animated.View>
</GestureDetector>
</View>
</>
)}
</View>
</Animated.View>
</GestureDetector>
</Link.Trigger>
<Link.Preview />
</Link>
);
if (titleHref) {
return (
<Link href={titleHref}>
<Link.Trigger>{bannerContent}</Link.Trigger>
<Link.Preview />
</Link>
);
}
return bannerContent;
}
@@ -1,8 +1,6 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { Link, useRouter } from "expo-router";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Link } from "expo-router";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
@@ -15,12 +13,9 @@ import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
id?: string;
tmdbId: number;
id: string;
title: string;
type: string;
backdropPath?: string | null;
@@ -30,7 +25,6 @@ export interface HeroBannerItem {
}
export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
@@ -42,22 +36,6 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
],
}));
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const handlePress = useCallback(() => {
resolveMutation.mutate({
tmdbId: item.tmdbId,
type: item.type as "movie" | "tv",
});
}, [item.tmdbId, item.type, resolveMutation]);
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
@@ -73,81 +51,78 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
]
.filter(Boolean)
.join(", ");
const titleHref = item.id
? (`/title/${item.id}` as `/title/${string}`)
: null;
const titleHref = `/title/${item.id}` as `/title/${string}`;
const bannerContent = (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
opacity: resolveMutation.isPending ? 0.7 : 1,
borderCurve: "continuous",
},
]}
>
<Pressable
onPress={titleHref ? undefined : handlePress}
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
return (
<Link href={titleHref}>
<Link.Trigger>
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
borderCurve: "continuous",
},
]}
>
<View
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon icon={IconStarFilled} size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={primary}
/>
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</View>
</Pressable>
</Animated.View>
</GestureDetector>
</Animated.View>
</GestureDetector>
</Link.Trigger>
<Link.Preview />
</Link>
);
if (titleHref) {
return (
<Link href={titleHref}>
<Link.Trigger>{bannerContent}</Link.Trigger>
<Link.Preview />
</Link>
);
}
return bannerContent;
}
@@ -1,6 +1,5 @@
import { FlashList } from "@shopify/flash-list";
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { Alert, Pressable, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
@@ -14,25 +13,12 @@ import {
import * as Haptics from "@/utils/haptics";
export function RecentlyViewedList() {
const { navigate } = useRouter();
const { items, removeItem, clearAll } = useRecentlyViewed();
const [mutedForeground, primaryColor] = useCSSVariable([
"--color-muted-foreground",
"--color-primary",
]) as [string, string];
const handlePress = useCallback(
(item: RecentlyViewedItem) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.type === "person") {
navigate(`/person/${item.id}`);
} else {
navigate(`/title/${item.id}`);
}
},
[navigate],
);
const handleDelete = useCallback(
(id: string) => {
removeItem(id);
@@ -58,13 +44,9 @@ export function RecentlyViewedList() {
const renderItem = useCallback(
({ item }: { item: RecentlyViewedItem }) => (
<RecentlyViewedRow
item={item}
onPress={handlePress}
onDelete={handleDelete}
/>
<RecentlyViewedRow item={item} onDelete={handleDelete} />
),
[handlePress, handleDelete],
[handleDelete],
);
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []);
@@ -1,4 +1,5 @@
import { memo } from "react";
import { Link } from "expo-router";
import { memo, useMemo } from "react";
import { Pressable } from "react-native";
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
import { SwipeableRow } from "@/components/ui/swipeable-row";
@@ -6,11 +7,9 @@ import type { RecentlyViewedItem } from "@/lib/recently-viewed";
export const RecentlyViewedRow = memo(function RecentlyViewedRow({
item,
onPress,
onDelete,
}: {
item: RecentlyViewedItem;
onPress: (item: RecentlyViewedItem) => void;
onDelete: (id: string) => void;
}) {
const accessibilityLabel = [
@@ -21,21 +20,30 @@ export const RecentlyViewedRow = memo(function RecentlyViewedRow({
.filter(Boolean)
.join(", ");
const href = useMemo(
() =>
item.type === "person"
? (`/person/${item.id}` as `/person/${string}`)
: (`/title/${item.id}` as `/title/${string}`),
[item.id, item.type],
);
return (
<SwipeableRow onDelete={() => onDelete(item.id)}>
<Pressable
onPress={() => onPress(item)}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
className="bg-background px-4 py-3"
style={({ pressed }) => ({
borderBottomWidth: 0.5,
borderBottomColor: "rgba(255,255,255,0.08)",
opacity: pressed ? 0.7 : 1,
})}
>
<RecentlyViewedRowContent item={item} />
</Pressable>
<Link href={href} asChild>
<Pressable
accessibilityRole="link"
accessibilityLabel={accessibilityLabel}
className="bg-background px-4 py-3"
style={({ pressed }) => ({
borderBottomWidth: 0.5,
borderBottomColor: "rgba(255,255,255,0.08)",
opacity: pressed ? 0.7 : 1,
})}
>
<RecentlyViewedRowContent item={item} />
</Pressable>
</Link>
</SwipeableRow>
);
});
@@ -1,14 +1,14 @@
import { IconLoader, IconPlus } from "@tabler/icons-react-native";
import { memo } from "react";
import { Link } from "expo-router";
import { memo, useMemo } from "react";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
export interface SearchResultItem {
tmdbId: number;
id?: string;
title: string;
type: "movie" | "tv" | "person";
posterPath?: string | null;
@@ -18,15 +18,11 @@ export interface SearchResultItem {
export const SearchResultRow = memo(function SearchResultRow({
item,
onResolve,
onQuickAdd,
isResolving,
isAdding,
}: {
item: SearchResultItem;
onResolve: (item: SearchResultItem) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
isResolving: boolean;
onQuickAdd: (id: string) => void;
isAdding: boolean;
}) {
const primary = useCSSVariable("--color-primary") as string;
@@ -42,6 +38,71 @@ export const SearchResultRow = memo(function SearchResultRow({
.filter(Boolean)
.join(", ");
const href = useMemo(() => {
if (!item.id) return undefined;
return item.type === "person"
? (`/person/${item.id}` as `/person/${string}`)
: (`/title/${item.id}` as `/title/${string}`);
}, [item.id, item.type]);
const rowContent = (
<Pressable
accessibilityRole="link"
accessibilityLabel={accessibilityLabel}
className="flex-1 flex-row items-center"
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
})}
>
<View
className="mr-3 overflow-hidden bg-secondary"
style={{
width: 44,
height: item.type === "person" ? 44 : 66,
borderRadius: item.type === "person" ? 22 : 8,
borderCurve: item.type === "person" ? undefined : "continuous",
}}
>
{imageSrc ? (
<Image
source={{ uri: imageSrc }}
recyclingKey={imageSrc}
className="h-full w-full"
contentFit="cover"
/>
) : null}
</View>
<View className="flex-1">
<Text
numberOfLines={1}
className="font-medium font-sans text-base text-foreground"
>
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="rounded-full bg-secondary px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="text-muted-foreground text-xs"
>
{item.type === "movie"
? "Movie"
: item.type === "tv"
? "TV"
: "Person"}
</Text>
</View>
{item.releaseDate ? (
<Text className="text-muted-foreground text-xs">
{item.releaseDate.slice(0, 4)}
</Text>
) : null}
</View>
</View>
</Pressable>
);
return (
<View
className="flex-row items-center border-border border-b px-4 py-3"
@@ -49,68 +110,18 @@ export const SearchResultRow = memo(function SearchResultRow({
borderBottomWidth: 0.5,
}}
>
<Pressable
onPress={() => onResolve(item)}
disabled={isResolving}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
className="flex-1 flex-row items-center"
style={({ pressed }) => ({
opacity: pressed || isResolving ? 0.6 : 1,
})}
>
<View
className="mr-3 overflow-hidden bg-secondary"
style={{
width: 44,
height: item.type === "person" ? 44 : 66,
borderRadius: item.type === "person" ? 22 : 8,
borderCurve: item.type === "person" ? undefined : "continuous",
}}
>
{imageSrc ? (
<Image
source={{ uri: imageSrc }}
recyclingKey={imageSrc}
className="h-full w-full"
contentFit="cover"
/>
) : null}
</View>
{href ? (
<Link href={href} asChild>
{rowContent}
</Link>
) : (
rowContent
)}
<View className="flex-1">
<Text
numberOfLines={1}
className="font-medium font-sans text-base text-foreground"
>
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="rounded-full bg-secondary px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="text-muted-foreground text-xs"
>
{item.type === "movie"
? "Movie"
: item.type === "tv"
? "TV"
: "Person"}
</Text>
</View>
{item.releaseDate ? (
<Text className="text-muted-foreground text-xs">
{item.releaseDate.slice(0, 4)}
</Text>
) : null}
</View>
</View>
</Pressable>
{item.type !== "person" && (
{item.type !== "person" && item.id && (
<Pressable
onPress={() => onQuickAdd(item.tmdbId, item.type as "movie" | "tv")}
disabled={isAdding || isResolving}
onPress={() => onQuickAdd(item.id as string)}
disabled={isAdding}
accessibilityRole="button"
accessibilityLabel={`Add ${item.title} to watchlist`}
hitSlop={12}
@@ -123,10 +134,6 @@ export const SearchResultRow = memo(function SearchResultRow({
)}
</Pressable>
)}
{isResolving && (
<Spinner size="sm" colorClassName="accent-primary" className="ml-2" />
)}
</View>
);
});
+37 -34
View File
@@ -1,4 +1,4 @@
import { useRouter } from "expo-router";
import { Link } from "expo-router";
import { Pressable, View } from "react-native";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
@@ -18,46 +18,49 @@ export function CastCard({
const accessibilityLabel = person.character
? `${person.name} as ${person.character}`
: person.name;
const { navigate } = useRouter();
return (
<Pressable
accessibilityRole="link"
<Link
href={`/person/${person.personId}` as `/person/${string}`}
accessibilityLabel={accessibilityLabel}
hitSlop={8}
onPress={() => navigate(`/person/${person.personId}`)}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
asChild
>
<View className="w-20 items-center">
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash}
recyclingKey={person.personId}
className="h-full w-full"
contentFit="cover"
accessible={false}
/>
)}
</View>
<Text
numberOfLines={1}
maxFontSizeMultiplier={1.2}
className="text-center font-medium font-sans text-foreground text-xs"
>
{person.name}
</Text>
{person.character ? (
<Pressable
accessibilityRole="link"
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View className="w-20 items-center">
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash}
recyclingKey={person.personId}
className="h-full w-full"
contentFit="cover"
accessible={false}
/>
)}
</View>
<Text
numberOfLines={1}
maxFontSizeMultiplier={1.0}
className="text-center text-muted-foreground text-xs"
maxFontSizeMultiplier={1.2}
className="text-center font-medium font-sans text-foreground text-xs"
>
{person.character}
{person.name}
</Text>
) : null}
</View>
</Pressable>
{person.character ? (
<Text
numberOfLines={1}
maxFontSizeMultiplier={1.0}
className="text-center text-muted-foreground text-xs"
>
{person.character}
</Text>
) : null}
</View>
</Pressable>
</Link>
);
}
+98 -130
View File
@@ -32,8 +32,7 @@ import { toast } from "@/lib/toast";
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface PosterCardProps {
id?: string;
tmdbId: number;
id: string;
title: string;
type: "movie" | "tv";
posterPath: string | null;
@@ -43,12 +42,7 @@ interface PosterCardProps {
userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
width?: number;
onPress: (
id: string | undefined,
tmdbId: number,
type: "movie" | "tv",
) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
onQuickAdd: (id: string) => void;
isAdding?: boolean;
failedKey?: string | null;
onQuickAddFailed?: () => void;
@@ -56,7 +50,6 @@ interface PosterCardProps {
export function PosterCard({
id,
tmdbId,
title,
type,
posterPath,
@@ -66,7 +59,6 @@ export function PosterCard({
userStatus,
episodeProgress,
width = 140,
onPress,
onQuickAdd,
isAdding,
failedKey,
@@ -94,11 +86,11 @@ export function PosterCard({
}, [userStatus]);
useEffect(() => {
if (failedKey === `${tmdbId}-${type}`) {
if (failedKey === id) {
setLocalStatus(userStatus ?? null);
onQuickAddFailed?.();
}
}, [failedKey, tmdbId, type, userStatus, onQuickAddFailed]);
}, [failedKey, id, userStatus, onQuickAddFailed]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
@@ -108,15 +100,11 @@ export function PosterCard({
],
}));
const handlePressAction = useCallback(() => {
onPress(id, tmdbId, type);
}, [onPress, id, tmdbId, type]);
const handleQuickAddPress = useCallback(() => {
if (localStatus || isAdding) return;
setLocalStatus("watchlist");
onQuickAdd(tmdbId, type);
}, [localStatus, isAdding, onQuickAdd, tmdbId, type]);
onQuickAdd(id);
}, [localStatus, isAdding, onQuickAdd, id]);
const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5;
@@ -153,7 +141,7 @@ export function PosterCard({
thumbHash={posterThumbHash}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
recyclingKey={`poster-${tmdbId}`}
recyclingKey={`poster-${id}`}
transition={200}
/>
) : (
@@ -271,120 +259,100 @@ export function PosterCard({
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
// Cards with id: use context menu with navigation
if (id) {
const titleHref = `/title/${id}` as `/title/${string}`;
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Link href={titleHref}>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
</ContextMenu.Trigger>
<ContextMenu.Content>
{!localStatus && (
<ContextMenu.Item key="watchlist" onSelect={handleQuickAddPress}>
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus !== "in_progress" && (
<ContextMenu.Item
key="watching"
onSelect={async () => {
await client.titles.updateStatus({
id,
status: "in_progress",
});
toast.success("Marked as watching");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{type === "movie" && (
<ContextMenu.Item
key="watched"
onSelect={async () => {
await client.titles.watchMovie({ id });
toast.success(
title ? `Marked "${title}" as watched` : "Marked as watched",
);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus && (
<ContextMenu.Item
key="remove"
destructive
onSelect={async () => {
await client.titles.updateStatus({ id, status: null });
setLocalStatus(null);
toast.success("Removed from library");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
);
}
const titleHref = `/title/${id}` as `/title/${string}`;
return (
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Pressable
onPress={handlePressAction}
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
<ContextMenu.Root>
<ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Link href={titleHref}>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
</ContextMenu.Trigger>
<ContextMenu.Content>
{!localStatus && (
<ContextMenu.Item key="watchlist" onSelect={handleQuickAddPress}>
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus !== "in_progress" && (
<ContextMenu.Item
key="watching"
onSelect={async () => {
await client.titles.updateStatus({
id,
status: "in_progress",
});
toast.success("Marked as watching");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
{cardContent}
</Pressable>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{type === "movie" && (
<ContextMenu.Item
key="watched"
onSelect={async () => {
await client.titles.watchMovie({ id });
toast.success(
title ? `Marked "${title}" as watched` : "Marked as watched",
);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus && (
<ContextMenu.Item
key="remove"
destructive
onSelect={async () => {
await client.titles.updateStatus({ id, status: null });
setLocalStatus(null);
toast.success("Removed from library");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
);
}
+6 -29
View File
@@ -1,27 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
/**
* Provides shared press/quickAdd handlers for PosterCard lists.
* Provides shared quickAdd handlers for PosterCard lists.
* Use once per list parent instead of per-card to avoid creating
* a mutation observer for every mounted PosterCard.
*/
export function usePosterActions() {
const { navigate } = useRouter();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
@@ -37,37 +25,26 @@ export function usePosterActions() {
}),
);
const handlePress = useCallback(
(id: string | undefined, tmdbId: number, type: "movie" | "tv") => {
if (id) {
navigate(`/title/${id}`);
} else {
resolveMutation.mutate({ tmdbId, type });
}
},
[navigate, resolveMutation.mutate],
);
const handleQuickAdd = useCallback(
(tmdbId: number, type: "movie" | "tv") => {
quickAddMutation.mutate({ tmdbId, type });
(id: string) => {
quickAddMutation.mutate({ id });
},
[quickAddMutation.mutate],
);
const addingKey =
quickAddMutation.isPending && quickAddMutation.variables
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}`
? quickAddMutation.variables.id
: null;
const failedKey =
quickAddMutation.isError && quickAddMutation.variables
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}`
? quickAddMutation.variables.id
: null;
const resetError = useCallback(() => {
quickAddMutation.reset();
}, [quickAddMutation.reset]);
return { handlePress, handleQuickAdd, addingKey, failedKey, resetError };
return { handleQuickAdd, addingKey, failedKey, resetError };
}
@@ -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,
+7 -40
View File
@@ -8,11 +8,10 @@ import {
IconX,
} from "@tabler/icons-react";
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
import { skipToken, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import {
Command,
@@ -69,6 +68,7 @@ for (const entry of SHORTCUT_DESCRIPTIONS) {
}
interface SearchResult {
id?: string;
tmdbId: number;
type: "movie" | "tv" | "person";
title: string;
@@ -144,51 +144,18 @@ export function CommandPalette() {
};
}, [debouncedQuery, results.length, setRecentSearches]);
const resolvePersonMutation = useMutation(
orpc.people.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/people/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load person");
},
}),
);
const resolveTitleMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
const handleSelect = useCallback(
(result: SearchResult) => {
if (!result.id) return;
setCommandPaletteOpen(false);
progress.start();
if (result.type === "person") {
resolvePersonMutation.mutate({ tmdbId: result.tmdbId });
void navigate({ to: "/people/$id", params: { id: result.id } });
} else {
resolveTitleMutation.mutate({
tmdbId: result.tmdbId,
type: result.type,
});
void navigate({ to: "/titles/$id", params: { id: result.id } });
}
},
[
setCommandPaletteOpen,
progress,
resolvePersonMutation,
resolveTitleMutation,
],
[setCommandPaletteOpen, progress, navigate],
);
const handleRecentSearch = useCallback((q: string) => {
@@ -251,7 +218,7 @@ export function CommandPalette() {
<CommandGroup heading="Results">
{results.map((r) => (
<CommandItem
key={`${r.type}-${r.tmdbId}`}
key={r.id ?? `${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
className="flex items-center gap-3 py-2"
>
@@ -3,7 +3,6 @@ import { Skeleton } from "@/components/ui/skeleton";
interface TitleGridItem {
id: string;
tmdbId: number;
type: string;
title: string;
posterPath: string | null;
@@ -43,7 +42,6 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
>
<TitleCard
id={t.id}
tmdbId={t.tmdbId}
type={t.type}
title={t.title}
posterPath={t.posterPath}
@@ -96,7 +96,7 @@ export function ExploreClient() {
<div className="space-y-10">
{hero && (
<HeroBanner
tmdbId={hero.tmdbId}
id={hero.id}
type={hero.type}
title={hero.title}
overview={hero.overview}
@@ -12,7 +12,7 @@ interface Genre {
}
interface TitleRowItem {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
posterPath: string | null;
@@ -177,26 +177,21 @@ export function FilterableTitleRow({
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item: TitleRowItem, i: number) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
id={item.id}
type={item.type}
title={item.title}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`]
}
userStatus={userStatuses[item.id]}
episodeProgress={episodeProgress[item.id]}
/>
</div>
</div>
+13 -41
View File
@@ -4,15 +4,10 @@ import {
IconPlus,
IconStar,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { orpc } from "@/lib/orpc/client";
import { Link } from "@tanstack/react-router";
interface HeroBannerProps {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
overview: string;
@@ -21,34 +16,13 @@ interface HeroBannerProps {
}
export function HeroBanner({
tmdbId,
id,
type,
title,
overview,
backdropPath,
voteAverage,
}: HeroBannerProps) {
const navigate = useNavigate();
const progress = useProgress();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
function handleNavigate() {
if (resolveMutation.isPending) return;
progress.start();
resolveMutation.mutate({ tmdbId, type });
}
return (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
<div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
@@ -103,28 +77,26 @@ export function HeroBanner({
Trending today
</span>
</div>
<button
type="button"
className="group/title cursor-pointer text-left"
onClick={handleNavigate}
disabled={resolveMutation.isPending}
<Link
to="/titles/$id"
params={{ id }}
className="group/title text-left"
>
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
{title}
</h2>
</button>
</Link>
<p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm">
{overview}
</p>
<button
type="button"
onClick={handleNavigate}
disabled={resolveMutation.isPending}
className="mt-4 inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20 disabled:opacity-70"
<Link
to="/titles/$id"
params={{ id }}
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
>
<IconPlus aria-hidden={true} className="size-4" />
Add to Library
</button>
</Link>
</div>
</div>
</div>
+5 -10
View File
@@ -4,7 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
interface TitleRowItem {
tmdbId: number;
id: string;
type: "movie" | "tv";
title: string;
posterPath: string | null;
@@ -70,26 +70,21 @@ export function TitleRow({
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
id={item.id}
type={item.type}
title={item.title}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress?.[`${item.tmdbId}-${item.type}`]
}
userStatus={userStatuses?.[item.id]}
episodeProgress={episodeProgress?.[item.id]}
/>
</div>
</div>
@@ -125,7 +125,6 @@ export function FilmographyGrid({
>
<TitleCard
id={credit.titleId}
tmdbId={credit.tmdbId}
type={credit.type}
title={credit.title}
posterPath={credit.posterPath}
+9 -48
View File
@@ -9,11 +9,9 @@ import {
IconStarFilled,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { type MotionStyle, type MotionValue, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
@@ -57,8 +55,7 @@ interface CardInnerProps {
}
export interface TitleCardProps extends CardInnerProps {
id?: string;
tmdbId: number;
id: string;
}
const statusConfig = {
@@ -80,12 +77,10 @@ const statusConfig = {
} as const;
function QuickAddButton({
tmdbId,
type,
id,
userStatus,
}: {
tmdbId: number;
type: "movie" | "tv";
id: string;
userStatus?: TitleStatus | null;
}) {
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
@@ -112,7 +107,7 @@ function QuickAddButton({
e.preventDefault();
e.stopPropagation();
if (quickAddMutation.isPending || isAdded) return;
quickAddMutation.mutate({ tmdbId, type });
quickAddMutation.mutate({ id });
}
if (isAdded && config) {
@@ -292,7 +287,6 @@ function CardInner({
export function TitleCard({
id,
tmdbId,
type,
title,
posterPath,
@@ -303,21 +297,6 @@ export function TitleCard({
episodeProgress,
}: TitleCardProps) {
const tilt = useTiltEffect();
const navigate = useNavigate();
const progress = useProgress();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id: resolvedId }) => {
if (resolvedId)
void navigate({ to: "/titles/$id", params: { id: resolvedId } });
else progress.done();
},
onError: () => {
progress.done();
toast.error("Failed to load title");
},
}),
);
const cardContent = (
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
@@ -341,28 +320,10 @@ export function TitleCard({
return (
<div className="group relative">
<QuickAddButton
tmdbId={tmdbId}
type={type as "movie" | "tv"}
userStatus={userStatus}
/>
{id ? (
<Link to="/titles/$id" params={{ id }}>
{cardContent}
</Link>
) : (
<button
type="button"
disabled={resolveMutation.isPending}
className={`w-full text-left ${resolveMutation.isPending ? "pointer-events-none opacity-70" : "cursor-pointer"}`}
onClick={() => {
progress.start();
resolveMutation.mutate({ tmdbId, type: type as "movie" | "tv" });
}}
>
{cardContent}
</button>
)}
<QuickAddButton id={id} userStatus={userStatus} />
<Link to="/titles/$id" params={{ id }}>
{cardContent}
</Link>
</div>
);
}
@@ -1,21 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client";
import { SeasonsSkeleton, TitleSeasons } from "./title-seasons";
export function AsyncTitleSeasons({
titleId,
tmdbId,
}: {
titleId: string;
tmdbId: number;
}) {
const { data, isPending } = useQuery(
orpc.titles.hydrateSeasons.queryOptions({
input: { id: titleId, tmdbId },
}),
);
if (isPending) return <SeasonsSkeleton />;
if (!data?.seasons || data.seasons.length === 0) return null;
return <TitleSeasons seasons={data.seasons} />;
}
@@ -46,7 +46,6 @@ export function TitleRecommendations({ titleId }: { titleId: string }) {
>
<TitleCard
id={rec.id}
tmdbId={rec.tmdbId}
type={rec.type}
title={rec.title}
posterPath={rec.posterPath}
+3 -16
View File
@@ -1,6 +1,4 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Suspense } from "react";
import { AsyncTitleSeasons } from "@/components/titles/async-title-seasons";
import { TitleActions } from "@/components/titles/title-actions";
import { TitleAvailability } from "@/components/titles/title-availability";
import { TitleCast } from "@/components/titles/title-cast";
@@ -8,10 +6,7 @@ import { TitleHero } from "@/components/titles/title-hero";
import { TitleKeyboardShortcuts } from "@/components/titles/title-keyboard-shortcuts";
import { TitleProvider } from "@/components/titles/title-provider";
import { TitleRecommendations } from "@/components/titles/title-recommendations";
import {
SeasonsSkeleton,
TitleSeasons,
} from "@/components/titles/title-seasons";
import { TitleSeasons } from "@/components/titles/title-seasons";
import { TitleTheme } from "@/components/titles/title-theme";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
@@ -46,8 +41,7 @@ export const Route = createFileRoute("/_app/titles/$id")({
});
function TitleDetailPage() {
const { title, seasons, needsHydration, availability, cast } =
Route.useLoaderData();
const { title, seasons, availability, cast } = Route.useLoaderData();
const themeStyle = getThemeCssProperties(title.colorPalette);
@@ -69,14 +63,7 @@ function TitleDetailPage() {
<TitleAvailability availability={availability} />
</TitleHero>
{title.type === "tv" && needsHydration && (
<Suspense fallback={<SeasonsSkeleton />}>
<AsyncTitleSeasons titleId={title.id} tmdbId={title.tmdbId} />
</Suspense>
)}
{title.type === "tv" && !needsHydration && seasons.length > 0 && (
<TitleSeasons />
)}
{title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
<TitleCast cast={cast} titleType={title.type} />
@@ -1,19 +0,0 @@
---
title: Resolve TMDB ID to local person
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: >-
Look up or import a person by their TMDB ID. Returns the internal ID
for use with other endpoints.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Look up or import a person by their TMDB ID. Returns the internal ID for use with other endpoints.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/people/resolve","method":"post"}]} />
@@ -9,12 +9,11 @@ _openapi:
contents:
- content: >-
Comprehensive health check covering database, TMDB connectivity, cron
jobs, image cache, backups, and environment. Does not require
authentication.
jobs, image cache, backups, and environment. Admin only.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Does not require authentication.
Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Admin only.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/system/health","method":"get"}]} />
@@ -1,19 +0,0 @@
---
title: Hydrate TV seasons
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: >-
Fetch full season and episode data from TMDB for a TV show. Required
before tracking individual episodes.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Fetch full season and episode data from TMDB for a TV show. Required before tracking individual episodes.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/{id}/hydrate-seasons","method":"post"}]} />
@@ -8,13 +8,13 @@ _openapi:
headings: []
contents:
- content: >-
Import a title by TMDB ID and add it to the user's watchlist in one
step. If the title already exists in the user's library, returns
Add a title to the user's watchlist and trigger a full TMDB import if
needed. If the title already exists in the user's library, returns
alreadyAdded: true.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Import a title by TMDB ID and add it to the user's watchlist in one step. If the title already exists in the user's library, returns alreadyAdded: true.
Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/quick-add","method":"post"}]} />
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/{id}/quick-add","method":"post"}]} />
@@ -1,19 +0,0 @@
---
title: Resolve TMDB ID to local title
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: >-
Look up or import a title by its TMDB ID and media type. Returns the
internal ID for use with other endpoints.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Look up or import a title by its TMDB ID and media type. Returns the internal ID for use with other endpoints.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/resolve","method":"post"}]} />
+76 -346
View File
@@ -248,6 +248,50 @@
],
"description": "Content rating (e.g. PG-13, TV-MA)"
},
"imdbId": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "IMDb title ID (e.g. tt0137523)"
},
"tvdbId": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"description": "TVDB ID (TV shows only)"
},
"originalLanguage": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Original language ISO 639-1 code (e.g. en)"
},
"runtimeMinutes": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"description": "Runtime in minutes (movies only)"
},
"colorPalette": {
"anyOf": [
{
@@ -366,6 +410,10 @@
"voteCount",
"status",
"contentRating",
"imdbId",
"tvdbId",
"originalLanguage",
"runtimeMinutes",
"colorPalette",
"trailerVideoKey",
"genres"
@@ -840,6 +888,10 @@
"BrowseItem": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Internal title ID"
},
"tmdbId": {
"type": "number",
"description": "TMDB numeric ID"
@@ -912,6 +964,7 @@
}
},
"required": [
"id",
"tmdbId",
"type",
"title",
@@ -1544,11 +1597,7 @@
"items": {
"$ref": "#/components/schemas/Season"
},
"description": "TV seasons (empty for movies or unhydrated shows)"
},
"needsHydration": {
"type": "boolean",
"description": "Whether season/episode data needs to be fetched from TMDB before tracking"
"description": "TV seasons (empty for movies)"
},
"availability": {
"type": "array",
@@ -1612,7 +1661,6 @@
"required": [
"title",
"seasons",
"needsHydration",
"availability",
"cast"
],
@@ -1689,133 +1737,6 @@
]
}
},
"/titles/resolve": {
"post": {
"operationId": "titles.resolve",
"summary": "Resolve TMDB ID to local title",
"description": "Look up or import a title by its TMDB ID and media type. Returns the internal ID for use with other endpoints.",
"tags": [
"Titles"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"tmdbId": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "TMDB numeric ID"
},
"type": {
"enum": [
"movie",
"tv"
],
"description": "Media type"
}
},
"required": [
"tmdbId",
"type"
],
"description": "TMDB ID and media type pair for resolving titles"
}
}
}
},
"responses": {
"200": {
"description": "Internal title ID",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Internal title ID"
}
},
"required": [
"id"
],
"description": "Resolved internal title ID"
}
}
}
},
"404": {
"description": "404",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "NOT_FOUND"
},
"status": {
"const": 404
},
"message": {
"type": "string",
"default": "Title not found"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/titles/{id}/status": {
"put": {
"operationId": "titles.updateStatus",
@@ -2162,11 +2083,11 @@
]
}
},
"/titles/{id}/hydrate-seasons": {
"/titles/{id}/quick-add": {
"post": {
"operationId": "titles.hydrateSeasons",
"summary": "Hydrate TV seasons",
"description": "Fetch full season and episode data from TMDB for a TV show. Required before tracking individual episodes.",
"operationId": "titles.quickAdd",
"summary": "Quick add title to library",
"description": "Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
"tags": [
"Titles"
],
@@ -2178,98 +2099,16 @@
"schema": {
"type": "string",
"minLength": 1,
"description": "Internal title ID"
"description": "Internal UUIDv7 identifier"
}
}
],
"requestBody": {
"required": true,
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"tmdbId": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "TMDB ID for fetching season data"
}
},
"required": [
"tmdbId"
],
"description": "Title identifiers for fetching season/episode data"
}
}
}
},
"responses": {
"200": {
"description": "Hydrated seasons with episodes",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"seasons": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Season"
},
"description": "Hydrated seasons with episodes"
}
},
"required": [
"seasons"
],
"description": "Freshly fetched season and episode data from TMDB"
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/titles/quick-add": {
"post": {
"operationId": "titles.quickAdd",
"summary": "Quick add title to library",
"description": "Import a title by TMDB ID and add it to the user's watchlist in one step. If the title already exists in the user's library, returns alreadyAdded: true.",
"tags": [
"Titles"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"tmdbId": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "TMDB numeric ID"
},
"type": {
"enum": [
"movie",
"tv"
],
"description": "Media type"
}
},
"required": [
"tmdbId",
"type"
],
"description": "TMDB ID and media type pair for resolving titles"
"type": "object"
}
}
}
@@ -2300,8 +2139,8 @@
}
}
},
"500": {
"description": "500",
"404": {
"description": "404",
"content": {
"application/json": {
"schema": {
@@ -2313,14 +2152,14 @@
"const": true
},
"code": {
"const": "INTERNAL_SERVER_ERROR"
"const": "NOT_FOUND"
},
"status": {
"const": 500
"const": 404
},
"message": {
"type": "string",
"default": "Failed to import title from TMDB"
"default": "Title not found"
},
"data": {}
},
@@ -2754,124 +2593,6 @@
]
}
},
"/people/resolve": {
"post": {
"operationId": "people.resolve",
"summary": "Resolve TMDB ID to local person",
"description": "Look up or import a person by their TMDB ID. Returns the internal ID for use with other endpoints.",
"tags": [
"People"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"tmdbId": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "The Movie Database (TMDB) numeric ID"
}
},
"required": [
"tmdbId"
]
}
}
}
},
"responses": {
"200": {
"description": "Internal person ID",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Internal person ID"
}
},
"required": [
"id"
],
"description": "Resolved internal person ID"
}
}
}
},
"404": {
"description": "404",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "NOT_FOUND"
},
"status": {
"const": 404
},
"message": {
"type": "string",
"default": "Person not found"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/dashboard/stats": {
"get": {
"operationId": "dashboard.stats",
@@ -3464,6 +3185,10 @@
{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Internal title ID"
},
"tmdbId": {
"type": "number",
"description": "TMDB numeric ID"
@@ -3500,6 +3225,7 @@
}
},
"required": [
"id",
"tmdbId",
"type",
"title",
@@ -4027,6 +3753,10 @@
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Internal title ID (present for movie/tv results, absent for people)"
},
"tmdbId": {
"type": "number",
"description": "TMDB numeric ID"
@@ -4595,7 +4325,7 @@
"get": {
"operationId": "system.health",
"summary": "Get system health report",
"description": "Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Does not require authentication.",
"description": "Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Admin only.",
"tags": [
"System"
],
+5 -53
View File
@@ -14,8 +14,6 @@ import {
DiscoverOutput,
FilenameParam,
GenresOutput,
HydrateSeasonsInput,
HydrateSeasonsOutput,
IdParam,
IntegrationOutput,
IntegrationsListOutput,
@@ -24,7 +22,6 @@ import {
PageParam,
PaginatedInput,
PersonDetailOutput,
PersonResolveOutput,
PopularOutput,
ProviderParam,
PublicInfoOutput,
@@ -40,9 +37,6 @@ import {
TelemetryOutput,
TitleDetailOutput,
TitleRecommendationsOutput,
TitleResolveOutput,
TmdbIdParam,
TmdbIdTypeParam,
ToggleRegistrationInput,
ToggleTelemetryInput,
ToggleUpdateCheckInput,
@@ -80,21 +74,6 @@ export const contract = {
.errors({
NOT_FOUND: { message: "Title not found" },
}),
resolve: oc
.route({
method: "POST",
path: "/titles/resolve",
tags: ["Titles"],
summary: "Resolve TMDB ID to local title",
description:
"Look up or import a title by its TMDB ID and media type. Returns the internal ID for use with other endpoints.",
successDescription: "Internal title ID",
})
.input(TmdbIdTypeParam)
.output(TitleResolveOutput)
.errors({
NOT_FOUND: { message: "Title not found" },
}),
updateStatus: oc
.route({
method: "PUT",
@@ -164,33 +143,21 @@ export const contract = {
})
.input(IdParam)
.output(TitleRecommendationsOutput),
hydrateSeasons: oc
.route({
method: "POST",
path: "/titles/{id}/hydrate-seasons",
tags: ["Titles"],
summary: "Hydrate TV seasons",
description:
"Fetch full season and episode data from TMDB for a TV show. Required before tracking individual episodes.",
successDescription: "Hydrated seasons with episodes",
})
.input(HydrateSeasonsInput)
.output(HydrateSeasonsOutput),
quickAdd: oc
.route({
method: "POST",
path: "/titles/quick-add",
path: "/titles/{id}/quick-add",
tags: ["Titles"],
summary: "Quick add title to library",
description:
"Import a title by TMDB ID and add it to the user's watchlist in one step. If the title already exists in the user's library, returns alreadyAdded: true.",
"Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
successDescription:
"Title ID and whether it was already in the library",
})
.input(TmdbIdTypeParam)
.input(IdParam)
.output(QuickAddOutput)
.errors({
INTERNAL_SERVER_ERROR: { message: "Failed to import title from TMDB" },
NOT_FOUND: { message: "Title not found" },
}),
},
episodes: {
@@ -267,21 +234,6 @@ export const contract = {
.errors({
NOT_FOUND: { message: "Person not found" },
}),
resolve: oc
.route({
method: "POST",
path: "/people/resolve",
tags: ["People"],
summary: "Resolve TMDB ID to local person",
description:
"Look up or import a person by their TMDB ID. Returns the internal ID for use with other endpoints.",
successDescription: "Internal person ID",
})
.input(TmdbIdParam)
.output(PersonResolveOutput)
.errors({
NOT_FOUND: { message: "Person not found" },
}),
},
dashboard: {
stats: oc
@@ -461,7 +413,7 @@ export const contract = {
tags: ["System"],
summary: "Get system health report",
description:
"Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Does not require authentication.",
"Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Admin only.",
successDescription: "Full system health report",
})
.output(SystemHealthOutput),
+19 -55
View File
@@ -10,9 +10,6 @@ export const ProviderParam = z.object({
.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"])
.describe("Media server provider type"),
});
export const TmdbIdParam = z.object({
tmdbId: z.number().int().describe("The Movie Database (TMDB) numeric ID"),
});
export const FilenameParam = z.object({
filename: z.string().min(1).describe("Backup filename"),
});
@@ -24,13 +21,6 @@ export const TrendingTypeParam = z.object({
.enum(["all", "movie", "tv"])
.describe("Trending category: all, movie, or tv"),
});
export const TmdbIdTypeParam = z
.object({
tmdbId: z.number().int().describe("TMDB numeric ID"),
type: z.enum(["movie", "tv"]).describe("Media type"),
})
.meta({ description: "TMDB ID and media type pair for resolving titles" });
// ─── Pagination ──────────────────────────────────────────────
/** Page param for TMDB-backed endpoints (fixed ~20 items/page from TMDB) */
@@ -103,13 +93,6 @@ export const BatchWatchInput = z
})
.meta({ description: "Batch of episode IDs to mark as watched" });
export const HydrateSeasonsInput = z
.object({
id: z.string().min(1).describe("Internal title ID"),
tmdbId: z.number().int().describe("TMDB ID for fetching season data"),
})
.meta({ description: "Title identifiers for fetching season/episode data" });
// ─── Search / Discover inputs ──────────────────────────────────
export const SearchInput = z
@@ -394,6 +377,16 @@ export const ResolvedTitleSchema = z
.string()
.nullable()
.describe("Content rating (e.g. PG-13, TV-MA)"),
imdbId: z.string().nullable().describe("IMDb title ID (e.g. tt0137523)"),
tvdbId: z.number().nullable().describe("TVDB ID (TV shows only)"),
originalLanguage: z
.string()
.nullable()
.describe("Original language ISO 639-1 code (e.g. en)"),
runtimeMinutes: z
.number()
.nullable()
.describe("Runtime in minutes (movies only)"),
colorPalette: ColorPaletteSchema.nullable(),
trailerVideoKey: z
.string()
@@ -451,10 +444,7 @@ export const PersonCreditSchema = z
/** Reusable TMDB browse result (trending / popular / discover items) */
export const TmdbBrowseItem = z
.object({
id: z
.string()
.optional()
.describe("Internal title ID when the title already exists locally"),
id: z.string().describe("Internal title ID"),
tmdbId: z.number().describe("TMDB numeric ID"),
type: mediaType,
title: z.string().describe("Display title"),
@@ -514,14 +504,7 @@ const BrowseOutput = z
export const TitleDetailOutput = z
.object({
title: ResolvedTitleSchema,
seasons: z
.array(SeasonSchema)
.describe("TV seasons (empty for movies or unhydrated shows)"),
needsHydration: z
.boolean()
.describe(
"Whether season/episode data needs to be fetched from TMDB before tracking",
),
seasons: z.array(SeasonSchema).describe("TV seasons (empty for movies)"),
availability: z
.array(AvailabilityOfferSchema)
.describe("Streaming availability offers"),
@@ -532,12 +515,6 @@ export const TitleDetailOutput = z
"Full title details with seasons, cast, and streaming availability",
});
export const TitleResolveOutput = z
.object({
id: z.string().describe("Internal title ID"),
})
.meta({ description: "Resolved internal title ID" });
export const UserInfoOutput = z
.object({
status: z
@@ -581,12 +558,6 @@ export const PersonDetailOutput = z
"Person profile with paginated filmography and user's statuses for their titles",
});
export const PersonResolveOutput = z
.object({
id: z.string().describe("Internal person ID"),
})
.meta({ description: "Resolved internal person ID" });
// ─── Dashboard outputs ─────────────────────────────────────────
export const DashboardStatsOutput = z
@@ -694,10 +665,7 @@ export const TrendingOutput = z
items: z.array(TmdbBrowseItem).describe("Trending titles"),
hero: z
.object({
id: z
.string()
.optional()
.describe("Internal title ID when the title already exists locally"),
id: z.string().describe("Internal title ID"),
tmdbId: z.number().describe("TMDB numeric ID"),
type: mediaType,
title: z.string().describe("Display title"),
@@ -735,6 +703,12 @@ export const SearchOutput = z
results: z.array(
z
.object({
id: z
.string()
.optional()
.describe(
"Internal title ID (present for movie/tv results, absent for people)",
),
tmdbId: z.number().describe("TMDB numeric ID"),
type: z.enum(["movie", "tv", "person"]).describe("Result type"),
title: z.string().describe("Title or person name"),
@@ -1121,16 +1095,6 @@ export const AuthConfigOutput = z
description: "Authentication provider configuration",
});
// ─── Title hydrate seasons output ─────────────────────────────
export const HydrateSeasonsOutput = z
.object({
seasons: z.array(SeasonSchema).describe("Hydrated seasons with episodes"),
})
.meta({
description: "Freshly fetched season and episode data from TMDB",
});
// ═══════════════════════════════════════════════════════════════
// Inferred types — use these instead of hand-written interfaces
// ═══════════════════════════════════════════════════════════════
+362 -179
View File
@@ -152,25 +152,52 @@ export function extractTvContentRating(show: TmdbTvDetails): string | null {
return us?.rating || null;
}
/** Fire-and-forget enrichment tasks (availability, recommendations, art, credits, trailer) */
function fireAndForgetEnrichment(
titleId: string,
posterPath: string | null | undefined,
backdropPath: string | null | undefined,
type: "movie" | "tv",
) {
refreshAvailability(titleId).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(titleId).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(titleId, posterPath, backdropPath, type).catch((err) =>
log.debug("Cache/thumbhash failed:", err),
);
refreshCredits(titleId).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(titleId).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
}
type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */
const inflightImports = new Map<number, ImportResult>();
/** In-flight import promises keyed by `${tmdbId}-${type}` — coalesces concurrent calls */
const inflightImports = new Map<string, ImportResult>();
export function getOrFetchTitleByTmdbId(
tmdbId: number,
type: "movie" | "tv",
): ImportResult {
const inflight = inflightImports.get(tmdbId);
const key = `${tmdbId}-${type}`;
const inflight = inflightImports.get(key);
if (inflight) {
log.debug(`Import already in-flight for TMDB ${tmdbId}, coalescing`);
log.debug(
`Import already in-flight for ${type} TMDB ${tmdbId}, coalescing`,
);
return inflight;
}
const promise = _getOrFetchTitleByTmdbId(tmdbId, type).finally(() => {
inflightImports.delete(tmdbId);
inflightImports.delete(key);
}) as ImportResult;
inflightImports.set(tmdbId, promise);
inflightImports.set(key, promise);
return promise;
}
@@ -180,12 +207,65 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
const existing = db
.select()
.from(titles)
.where(eq(titles.tmdbId, tmdbId))
.where(and(eq(titles.tmdbId, tmdbId), eq(titles.type, type)))
.get();
if (existing) {
// For TV shows, check if seasons/episodes were actually loaded.
// They may be missing if a prior fetch failed or the title was created
// as a shell by the recommendations system (lastFetchedAt: null).
// Shell title — upgrade to full import
if (!existing.lastFetchedAt) {
if (type === "movie") {
const movie = await getMovieDetails(tmdbId);
updateTitleWithArtInvalidation(existing, {
title: movie.title ?? existing.title,
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(existing.id, movie.genres ?? []);
fireAndForgetEnrichment(
existing.id,
movie.poster_path,
movie.backdrop_path,
"movie",
);
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
}
// TV shell — fetch details + children
const show = await getTvDetails(tmdbId);
updateTitleWithArtInvalidation(existing, {
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null,
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
fireAndForgetEnrichment(
existing.id,
show.poster_path,
show.backdrop_path,
"tv",
);
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
}
// For fully-fetched TV shows, check if seasons are missing (e.g. prior failure)
if (existing.type === "tv") {
const hasSeason = db
.select({ id: seasons.id })
@@ -195,40 +275,18 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
.get();
if (!hasSeason) {
const show = await getTvDetails(tmdbId);
if (!existing.lastFetchedAt) {
updateTitleWithArtInvalidation(existing, {
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null,
lastFetchedAt: new Date(),
});
}
upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
refreshAvailability(existing.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(
fireAndForgetEnrichment(
existing.id,
show.poster_path,
show.backdrop_path,
"tv",
).catch((err) => log.debug("Cache/thumbhash failed:", err));
refreshCredits(existing.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(existing.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
}
}
return existing;
}
@@ -251,26 +309,20 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now,
},
tmdbId,
);
if (!row) return undefined;
upsertGenres(row.id, movie.genres ?? []);
refreshAvailability(row.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(row.id, movie.poster_path, movie.backdrop_path, "movie").catch(
(err) => log.debug("Cache/thumbhash failed:", err),
);
refreshCredits(row.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
fireAndForgetEnrichment(
row.id,
movie.poster_path,
movie.backdrop_path,
"movie",
);
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
return row;
@@ -293,6 +345,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
voteCount: show.vote_count,
status: show.status,
contentRating: extractTvContentRating(show),
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: now,
},
tmdbId,
@@ -301,21 +355,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
upsertGenres(row.id, show.genres ?? []);
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
refreshAvailability(row.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(row.id, show.poster_path, show.backdrop_path, "tv").catch(
(err) => log.debug("Cache/thumbhash failed:", err),
);
refreshCredits(row.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
fireAndForgetEnrichment(row.id, show.poster_path, show.backdrop_path, "tv");
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
return row;
}
@@ -340,6 +380,9 @@ export async function refreshTitle(titleId: string) {
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now,
});
upsertGenres(titleId, movie.genres ?? []);
@@ -358,6 +401,8 @@ export async function refreshTitle(titleId: string) {
status: show.status,
contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null,
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: now,
});
upsertGenres(titleId, show.genres ?? []);
@@ -387,125 +432,135 @@ export async function refreshTvChildren(
tmdbId: number,
numberOfSeasons: number,
) {
// Fetch all seasons from TMDB concurrently (~40 req/s rate limit)
const seasonNumbers = Array.from(
{ length: numberOfSeasons },
(_, i) => i + 1,
);
const fetched = await Promise.allSettled(
seasonNumbers.map((sn) => getTvSeasonDetails(tmdbId, sn)),
);
const now = new Date();
for (let sn = 1; sn <= numberOfSeasons; sn++) {
// Rate-limit: 250ms between TMDB calls
if (sn > 1) await delay(250);
for (let i = 0; i < seasonNumbers.length; i++) {
const result = fetched[i];
const sn = seasonNumbers[i];
if (result.status === "rejected") {
log.error(
`Failed to fetch season ${sn} for TMDB ${tmdbId}:`,
result.reason,
);
continue;
}
try {
const seasonData = await getTvSeasonDetails(tmdbId, sn);
const existingSeason = db
.select({
id: seasons.id,
posterPath: seasons.posterPath,
})
.from(seasons)
.where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, sn)))
.get();
const seasonData = result.value;
const seasonRow = db
.insert(seasons)
.values({
titleId,
seasonNumber: seasonData.season_number,
const existingSeason = db
.select({
id: seasons.id,
posterPath: seasons.posterPath,
})
.from(seasons)
.where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, sn)))
.get();
const seasonRow = db
.insert(seasons)
.values({
titleId,
seasonNumber: seasonData.season_number,
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
})
.onConflictDoUpdate({
target: [seasons.titleId, seasons.seasonNumber],
set: {
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
})
.onConflictDoUpdate({
target: [seasons.titleId, seasons.seasonNumber],
set: {
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
},
})
.returning()
.get();
},
})
.returning()
.get();
// Snapshot existing episode still paths before upsert so we can detect changes
const oldEpStills = new Map(
db
.select({
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
})
.from(episodes)
.where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id))
.all()
.map((e) => [e.episodeNumber, e.stillPath] as const),
);
// Snapshot existing episode still paths before upsert so we can detect changes
const oldEpStills = new Map(
db
.select({
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
})
.from(episodes)
.where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id))
.all()
.map((e) => [e.episodeNumber, e.stillPath] as const),
);
// Batch all episode upserts in a single transaction per season
const eps = seasonData.episodes ?? [];
if (eps.length > 0) {
db.transaction((tx) => {
for (const ep of eps) {
tx.insert(episodes)
.values({
seasonId: seasonRow.id,
episodeNumber: ep.episode_number,
// Batch all episode upserts in a single transaction per season
const eps = seasonData.episodes ?? [];
if (eps.length > 0) {
db.transaction((tx) => {
for (const ep of eps) {
tx.insert(episodes)
.values({
seasonId: seasonRow.id,
episodeNumber: ep.episode_number,
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
})
.onConflictDoUpdate({
target: [episodes.seasonId, episodes.episodeNumber],
set: {
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
})
.onConflictDoUpdate({
target: [episodes.seasonId, episodes.episodeNumber],
set: {
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
},
})
.run();
}
});
}
// Clear stale hashes when image paths change during the upsert.
// Full hash (re)generation is handled by syncTitleArt() after
// cache warming, so we only need to null out stale values here.
if (
(existingSeason?.posterPath ?? null) !==
(seasonData.poster_path ?? null)
) {
db.update(seasons)
.set({ posterThumbHash: null })
.where(eq(seasons.id, seasonRow.id))
.run();
}
const seasonEps = db
.select({
id: episodes.id,
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
stillThumbHash: episodes.stillThumbHash,
})
.from(episodes)
.where(eq(episodes.seasonId, seasonRow.id))
.all();
for (const ep of seasonEps) {
const oldStill = oldEpStills.get(ep.episodeNumber);
if (oldStill !== ep.stillPath && ep.stillThumbHash) {
db.update(episodes)
.set({ stillThumbHash: null })
.where(eq(episodes.id, ep.id))
},
})
.run();
}
});
}
// Clear stale hashes when image paths change during the upsert.
// Full hash (re)generation is handled by syncTitleArt() after
// cache warming, so we only need to null out stale values here.
if (
(existingSeason?.posterPath ?? null) !== (seasonData.poster_path ?? null)
) {
db.update(seasons)
.set({ posterThumbHash: null })
.where(eq(seasons.id, seasonRow.id))
.run();
}
const seasonEps = db
.select({
id: episodes.id,
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
stillThumbHash: episodes.stillThumbHash,
})
.from(episodes)
.where(eq(episodes.seasonId, seasonRow.id))
.all();
for (const ep of seasonEps) {
const oldStill = oldEpStills.get(ep.episodeNumber);
if (oldStill !== ep.stillPath && ep.stillThumbHash) {
db.update(episodes)
.set({ stillThumbHash: null })
.where(eq(episodes.id, ep.id))
.run();
}
} catch (err) {
// Skip this season and continue with the rest — partial data is
// better than aborting entirely. The next refresh cycle will retry.
log.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}:`, err);
}
}
}
@@ -797,13 +852,12 @@ function fetchSeasonsFromDb(titleId: string): Season[] {
* Ensure a TV title is fully hydrated (seasons/episodes fetched from TMDB).
* Returns the hydrated seasons data.
*/
export async function ensureTvHydrated(
titleId: string,
tmdbId: number,
): Promise<Season[]> {
export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return [];
const { tmdbId } = title;
// Shell title: fetch details + children
if (!title.lastFetchedAt) {
try {
@@ -814,6 +868,8 @@ export async function ensureTvHydrated(
backdropPath: show.backdrop_path,
status: show.status,
contentRating: extractTvContentRating(show),
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(titleId, show.genres ?? []);
@@ -942,20 +998,12 @@ function readAvailability(
export async function getOrFetchTitle(id: string): Promise<{
title: ResolvedTitle;
seasons: Season[];
needsHydration: boolean;
availability: AvailabilityOffer[];
cast: CastMember[];
} | null> {
let title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return null;
// For TV titles, check if seasons need hydration (reuse result below)
const existingSeasons =
title.type === "tv" && title.lastFetchedAt ? fetchSeasonsFromDb(id) : null;
const needsTvHydration =
title.type === "tv" &&
(!title.lastFetchedAt || existingSeasons?.length === 0);
// If this is a shell movie title, fetch full details now (movies are fast)
if (title.type === "movie" && !title.lastFetchedAt) {
try {
@@ -972,6 +1020,9 @@ export async function getOrFetchTitle(id: string): Promise<{
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(id, movie.genres ?? []);
@@ -981,7 +1032,14 @@ export async function getOrFetchTitle(id: string): Promise<{
}
}
const titleSeasons = needsTvHydration ? [] : (existingSeasons ?? []);
// For TV titles, hydrate seasons inline if needed
let titleSeasons: Season[] = [];
if (title.type === "tv") {
titleSeasons = title.lastFetchedAt ? fetchSeasonsFromDb(id) : [];
if (titleSeasons.length === 0) {
titleSeasons = await ensureTvHydrated(id);
}
}
// Read enrichment data, then backfill anything missing
let availability = readAvailability(title.id, title.title);
@@ -1028,6 +1086,10 @@ export async function getOrFetchTitle(id: string): Promise<{
voteCount: title.voteCount,
status: title.status,
contentRating: title.contentRating,
imdbId: title.imdbId,
tvdbId: title.tvdbId,
originalLanguage: title.originalLanguage,
runtimeMinutes: title.runtimeMinutes,
colorPalette: palette,
trailerVideoKey: title.trailerVideoKey,
genres: titleGenreRows.map((r) => r.name),
@@ -1036,7 +1098,6 @@ export async function getOrFetchTitle(id: string): Promise<{
return {
title: resolvedTitle,
seasons: titleSeasons,
needsHydration: needsTvHydration,
availability,
cast,
};
@@ -1175,6 +1236,128 @@ async function syncTitleArt(
}
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
// ─── Browse batch upsert ─────────────────────────────────────
interface BrowseTitleInput {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
backdropPath?: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
overview?: string | null;
popularity?: number | null;
voteAverage?: number | null;
voteCount?: number | null;
}
function browseTitleKey(tmdbId: number, type: string): string {
return `${tmdbId}-${type}`;
}
/**
* Ensure every browse/search result has a local title row.
* Inserts shell titles (`lastFetchedAt = null`) for new (tmdbId, type) pairs.
* Returns a map keyed by `${tmdbId}-${type}` → { id, posterThumbHash }.
*/
export function ensureBrowseTitlesExist(
items: BrowseTitleInput[],
): Map<string, { id: string; posterThumbHash: string | null }> {
if (items.length === 0) return new Map();
// Deduplicate by (tmdbId, type)
const unique = new Map<string, BrowseTitleInput>();
for (const item of items) {
const key = browseTitleKey(item.tmdbId, item.type);
if (!unique.has(key)) unique.set(key, item);
}
const tmdbIds = [...new Set(items.map((i) => i.tmdbId))];
// Batch-fetch existing titles (1 query)
const existing = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const result = new Map<
string,
{ id: string; posterThumbHash: string | null }
>();
for (const row of existing) {
result.set(browseTitleKey(row.tmdbId, row.type), {
id: row.id,
posterThumbHash: row.posterThumbHash,
});
}
// Find items that need inserting
const missingKeys = [...unique.keys()].filter((key) => !result.has(key));
if (missingKeys.length === 0) return result;
// Insert missing in a single transaction
db.transaction((tx) => {
for (const key of missingKeys) {
const item = unique.get(key);
if (!item) continue;
const row = tx
.insert(titles)
.values({
tmdbId: item.tmdbId,
type: item.type,
title: item.title,
overview: item.overview ?? null,
releaseDate: item.releaseDate ?? null,
firstAirDate: item.firstAirDate ?? null,
posterPath: item.posterPath,
backdropPath: item.backdropPath ?? null,
popularity: item.popularity ?? null,
voteAverage: item.voteAverage ?? null,
voteCount: item.voteCount ?? null,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning({ id: titles.id, posterThumbHash: titles.posterThumbHash })
.get();
if (row) {
result.set(key, {
id: row.id,
posterThumbHash: row.posterThumbHash,
});
}
}
// Fallback for any that conflicted (concurrent insert)
const stillMissing = missingKeys.filter((key) => !result.has(key));
if (stillMissing.length > 0) {
const missingTmdbIds = stillMissing.map(
(key) => unique.get(key)?.tmdbId ?? 0,
);
const fallbacks = tx
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, missingTmdbIds))
.all();
for (const f of fallbacks) {
result.set(browseTitleKey(f.tmdbId, f.type), {
id: f.id,
posterThumbHash: f.posterThumbHash,
});
}
}
});
return result;
}
+79
View File
@@ -373,3 +373,82 @@ export async function fetchFullFilmography(
throw err;
}
}
// ─── Browse batch upsert ─────────────────────────────────────
interface BrowsePersonInput {
tmdbId: number;
name: string;
profilePath: string | null;
knownForDepartment?: string | null;
popularity?: number | null;
}
/**
* Ensure every person search result has a local person row.
* Inserts shell persons (`lastFetchedAt = null`) for new tmdbIds.
* Returns a map of tmdbId → internal UUID.
*/
export function ensureBrowsePersonsExist(
items: BrowsePersonInput[],
): Map<number, string> {
if (items.length === 0) return new Map();
const unique = new Map<number, BrowsePersonInput>();
for (const item of items) {
if (!unique.has(item.tmdbId)) unique.set(item.tmdbId, item);
}
const tmdbIds = [...unique.keys()];
const existing = db
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
const result = new Map<number, string>();
for (const row of existing) {
result.set(row.tmdbId, row.id);
}
const missing = tmdbIds.filter((id) => !result.has(id));
if (missing.length === 0) return result;
db.transaction((tx) => {
for (const tmdbId of missing) {
const item = unique.get(tmdbId);
if (!item) continue;
const row = tx
.insert(persons)
.values({
tmdbId: item.tmdbId,
name: item.name,
profilePath: item.profilePath,
knownForDepartment: item.knownForDepartment ?? null,
popularity: item.popularity ?? null,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning({ id: persons.id })
.get();
if (row) {
result.set(tmdbId, row.id);
}
}
const stillMissing = missing.filter((id) => !result.has(id));
if (stillMissing.length > 0) {
const fallbacks = tx
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, stillMissing))
.all();
for (const f of fallbacks) {
result.set(f.tmdbId, f.id);
}
}
});
return result;
}
+7 -41
View File
@@ -401,39 +401,6 @@ export function rateTitleStars(
.run();
}
export function getUserStatusesByTmdbIds(
userId: string,
tmdbIds: { tmdbId: number; type: string }[],
): Record<string, "watchlist" | "in_progress" | "completed"> {
if (tmdbIds.length === 0) return {};
const allTmdbIds = tmdbIds.map((t) => t.tmdbId);
const rows = db
.select({
tmdbId: titles.tmdbId,
type: titles.type,
status: userTitleStatus.status,
})
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
inArray(titles.tmdbId, allTmdbIds),
),
)
.all();
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
for (const row of rows) {
result[`${row.tmdbId}-${row.type}`] = row.status as
| "watchlist"
| "in_progress"
| "completed";
}
return result;
}
export function getUserStatusesByTitleIds(
userId: string,
titleIds: string[],
@@ -464,16 +431,15 @@ export function getUserStatusesByTitleIds(
return result;
}
export function getEpisodeProgressByTmdbIds(
export function getEpisodeProgressByTitleIds(
userId: string,
tmdbIds: { tmdbId: number; type: string }[],
titleIds: string[],
): Record<string, { watched: number; total: number }> {
const tvIds = tmdbIds.filter((t) => t.type === "tv").map((t) => t.tmdbId);
if (tvIds.length === 0) return {};
if (titleIds.length === 0) return {};
const rows = db
.select({
tmdbId: titles.tmdbId,
titleId: titles.id,
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as(
"totalEpisodes",
),
@@ -492,14 +458,14 @@ export function getEpisodeProgressByTmdbIds(
eq(userEpisodeWatches.userId, userId),
),
)
.where(and(inArray(titles.tmdbId, tvIds), eq(titles.type, "tv")))
.groupBy(titles.tmdbId)
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
.groupBy(titles.id)
.all();
const result: Record<string, { watched: number; total: number }> = {};
for (const row of rows) {
if (row.watchedEpisodes > 0) {
result[`${row.tmdbId}-tv`] = {
result[row.titleId] = {
watched: row.watchedEpisodes,
total: row.totalEpisodes,
};
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS `titles_tmdbId_unique`;--> statement-breakpoint
CREATE UNIQUE INDEX `titles_tmdbId_type_unique` ON `titles` (`tmdbId`,`type`);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
ALTER TABLE `titles` ADD `imdbId` text;--> statement-breakpoint
ALTER TABLE `titles` ADD `originalLanguage` text;--> statement-breakpoint
ALTER TABLE `titles` ADD `runtimeMinutes` integer;
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -118,12 +118,15 @@ export const titles = sqliteTable(
voteCount: int("voteCount"),
status: text("status"),
contentRating: text("contentRating"),
imdbId: text("imdbId"),
originalLanguage: text("originalLanguage"),
runtimeMinutes: int("runtimeMinutes"),
colorPalette: text("colorPalette"),
trailerVideoKey: text("trailerVideoKey"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titles_tmdbId_unique").on(table.tmdbId),
uniqueIndex("titles_tmdbId_type_unique").on(table.tmdbId, table.type),
index("titles_type_releaseDate").on(table.type, table.releaseDate),
index("titles_type_firstAirDate").on(table.type, table.firstAirDate),
index("titles_lastFetchedAt").on(table.lastFetchedAt),