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, useInfiniteQuery,
useMutation, useMutation,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router"; import { Stack } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { ActivityIndicator, View } from "react-native"; import { ActivityIndicator, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated"; import Animated, { FadeIn } from "react-native-reanimated";
@@ -19,10 +19,8 @@ import { useDebounce } from "@/hooks/use-debounce";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
export default function SearchScreen() { export default function SearchScreen() {
const { navigate } = useRouter();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query.trim(), 300); const debouncedQuery = useDebounce(query.trim(), 300);
@@ -38,36 +36,9 @@ export default function SearchScreen() {
}), }),
}); });
// Track which item is currently being resolved/added // Track which item is currently being added
const [resolvingId, setResolvingId] = useState<string | null>(null);
const [addingId, setAddingId] = useState<string | null>(null); 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( const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({ orpc.titles.quickAdd.mutationOptions({
onSuccess: () => { onSuccess: () => {
@@ -83,27 +54,13 @@ export default function SearchScreen() {
}), }),
); );
// Use refs for mutation.mutate to keep callbacks stable across renders // Use ref for mutation.mutate to keep callback stable across renders
const resolveTitleMutateRef = useRef(resolveTitleMutation.mutate);
resolveTitleMutateRef.current = resolveTitleMutation.mutate;
const resolvePersonMutateRef = useRef(resolvePersonMutation.mutate);
resolvePersonMutateRef.current = resolvePersonMutation.mutate;
const quickAddMutateRef = useRef(quickAddMutation.mutate); const quickAddMutateRef = useRef(quickAddMutation.mutate);
quickAddMutateRef.current = quickAddMutation.mutate; quickAddMutateRef.current = quickAddMutation.mutate;
const handleResolve = useCallback((item: SearchResultItem) => { const handleQuickAdd = useCallback((id: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setAddingId(id);
setResolvingId(`${item.type}-${item.tmdbId}`); quickAddMutateRef.current({ id });
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 });
}, []); }, []);
// Memoize mapped results to maintain stable references // Memoize mapped results to maintain stable references
@@ -111,7 +68,7 @@ export default function SearchScreen() {
() => () =>
searchResults.data?.pages.flatMap((page) => searchResults.data?.pages.flatMap((page) =>
page.results.map((r) => ({ page.results.map((r) => ({
tmdbId: r.tmdbId, id: r.id,
title: r.title, title: r.title,
type: r.type, type: r.type,
posterPath: r.posterPath, posterPath: r.posterPath,
@@ -126,17 +83,15 @@ export default function SearchScreen() {
({ item }: { item: SearchResultItem }) => ( ({ item }: { item: SearchResultItem }) => (
<SearchResultRow <SearchResultRow
item={item} item={item}
onResolve={handleResolve}
onQuickAdd={handleQuickAdd} onQuickAdd={handleQuickAdd}
isResolving={resolvingId === `${item.type}-${item.tmdbId}`} isAdding={addingId === item.id}
isAdding={addingId === `${item.type}-${item.tmdbId}`}
/> />
), ),
[handleResolve, handleQuickAdd, resolvingId, addingId], [handleQuickAdd, addingId],
); );
const keyExtractor = useCallback( 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 mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const { handlePress, handleQuickAdd, addingKey, failedKey, resetError } = const { handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions(); usePosterActions();
const { const {
@@ -134,7 +134,6 @@ export default function PersonDetailScreen() {
> >
<PosterCard <PosterCard
id={credit.titleId} id={credit.titleId}
tmdbId={credit.tmdbId}
title={credit.title} title={credit.title}
type={credit.type} type={credit.type}
posterPath={credit.posterPath} posterPath={credit.posterPath}
@@ -143,9 +142,8 @@ export default function PersonDetailScreen() {
voteAverage={credit.voteAverage} voteAverage={credit.voteAverage}
userStatus={userStatuses[credit.titleId] ?? null} userStatus={userStatuses[credit.titleId] ?? null}
width={columnWidth} width={columnWidth}
onPress={handlePress}
onQuickAdd={handleQuickAdd} onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${credit.tmdbId}-${credit.type}`} isAdding={addingKey === credit.titleId}
failedKey={failedKey} failedKey={failedKey}
onQuickAddFailed={resetError} onQuickAddFailed={resetError}
/> />
@@ -154,7 +152,6 @@ export default function PersonDetailScreen() {
[ [
columnWidth, columnWidth,
userStatuses, userStatuses,
handlePress,
handleQuickAdd, handleQuickAdd,
addingKey, addingKey,
failedKey, failedKey,
+2 -40
View File
@@ -16,7 +16,7 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { Link, useLocalSearchParams, useRouter } from "expo-router"; import { Link, useLocalSearchParams, useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser"; 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 { Pressable, ScrollView, StyleSheet, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 title = detail.data?.title;
const palette = title?.colorPalette ?? null; const palette = title?.colorPalette ?? null;
useTitleTheme(palette); useTitleTheme(palette);
@@ -212,7 +204,6 @@ export default function TitleDetailScreen() {
() => () =>
(recommendations.data?.recommendations ?? []).map((item) => ({ (recommendations.data?.recommendations ?? []).map((item) => ({
id: item.id, id: item.id,
tmdbId: item.tmdbId,
title: item.title, title: item.title,
type: item.type, type: item.type,
posterPath: item.posterPath, 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( const titleScrollContentStyle = useMemo(
() => ({ () => ({
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32, paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
@@ -271,17 +256,6 @@ export default function TitleDetailScreen() {
[palette?.darkVibrant], [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) { if (detail.isPending) {
return ( return (
<> <>
@@ -487,10 +461,7 @@ export default function TitleDetailScreen() {
currentStatus={userInfo.data?.status ?? null} currentStatus={userInfo.data?.status ?? null}
onStatusChange={(status) => { onStatusChange={(status) => {
if (status === "watchlist") { if (status === "watchlist") {
quickAddMutation.mutate({ quickAddMutation.mutate({ id });
tmdbId: title.tmdbId,
type: title.type,
});
} else { } else {
updateStatus.mutate({ id, status: null }); updateStatus.mutate({ id, status: null });
} }
@@ -620,15 +591,6 @@ export default function TitleDetailScreen() {
</Animated.View> </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 */}
{cast.length > 0 && ( {cast.length > 0 && (
<Animated.View <Animated.View
@@ -10,8 +10,7 @@ import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { usePosterActions } from "@/hooks/use-poster-actions"; import { usePosterActions } from "@/hooks/use-poster-actions";
export interface PosterRowItem { export interface PosterRowItem {
id?: string; id: string;
tmdbId: number;
title: string; title: string;
type: string; type: string;
posterPath: string | null; posterPath: string | null;
@@ -30,17 +29,13 @@ export function HorizontalPosterRow({
items: PosterRowItem[]; items: PosterRowItem[];
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { handlePress, handleQuickAdd, addingKey, failedKey, resetError } = const { handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions(); usePosterActions();
const keyExtractor = useCallback( const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
(item: PosterRowItem) => item.id ?? `${item.tmdbId}-${item.type}`,
[],
);
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => ( ({ item }: { item: PosterRowItem }) => (
<PosterCard <PosterCard
id={item.id} id={item.id}
tmdbId={item.tmdbId}
title={item.title} title={item.title}
type={item.type as "movie" | "tv"} type={item.type as "movie" | "tv"}
posterPath={item.posterPath} posterPath={item.posterPath}
@@ -49,14 +44,13 @@ export function HorizontalPosterRow({
voteAverage={item.voteAverage} voteAverage={item.voteAverage}
userStatus={item.userStatus} userStatus={item.userStatus}
episodeProgress={item.episodeProgress} episodeProgress={item.episodeProgress}
onPress={handlePress}
onQuickAdd={handleQuickAdd} onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${item.tmdbId}-${item.type}`} isAdding={addingKey === item.id}
failedKey={failedKey} failedKey={failedKey}
onQuickAddFailed={resetError} onQuickAddFailed={resetError}
/> />
), ),
[addingKey, failedKey, handlePress, handleQuickAdd, resetError], [addingKey, failedKey, handleQuickAdd, resetError],
); );
if (isLoading) { if (isLoading) {
@@ -28,8 +28,7 @@ export function FilterableTitleRow({
icon: Icon; icon: Icon;
mediaType: "movie" | "tv"; mediaType: "movie" | "tv";
defaultItems: Array<{ defaultItems: Array<{
id?: string; id: string;
tmdbId: number;
title: string; title: string;
type: string; type: string;
posterPath: string | null; posterPath: string | null;
@@ -93,14 +92,11 @@ export function FilterableTitleRow({
// Map items into PosterRowItem shape with status/progress resolved // Map items into PosterRowItem shape with status/progress resolved
const items = useMemo<PosterRowItem[]>( const items = useMemo<PosterRowItem[]>(
() => () =>
rawItems.map((item) => { rawItems.map((item) => ({
const key = `${item.tmdbId}-${item.type}`; ...item,
return { userStatus: userStatuses[item.id] ?? null,
...item, episodeProgress: episodeProgress[item.id] ?? null,
userStatus: userStatuses[key] ?? null, })),
episodeProgress: episodeProgress[key] ?? null,
};
}),
[rawItems, userStatuses, episodeProgress], [rawItems, userStatuses, episodeProgress],
); );
@@ -1,9 +1,7 @@
import { IconStarFilled } from "@tabler/icons-react-native"; import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { Link, useRouter } from "expo-router"; import { Link } from "expo-router";
import { useCallback } from "react"; import { View } from "react-native";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
interpolate, interpolate,
@@ -15,12 +13,9 @@ import Animated, {
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem { export interface HeroBannerItem {
id?: string; id: string;
tmdbId: number;
title: string; title: string;
type: string; type: string;
backdropPath?: string | null; backdropPath?: string | null;
@@ -30,7 +25,6 @@ export interface HeroBannerItem {
} }
export function HeroBanner({ item }: { item: HeroBannerItem }) { export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string; const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0); 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() const tapGesture = Gesture.Tap()
.onBegin(() => { .onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 })); pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
@@ -74,124 +52,117 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
] ]
.filter(Boolean) .filter(Boolean)
.join(", "); .join(", ");
const titleHref = item.id const titleHref = `/title/${item.id}` as `/title/${string}`;
? (`/title/${item.id}` as `/title/${string}`)
: null;
const bannerContent = ( return (
<GestureDetector gesture={tapGesture}> <Link href={titleHref}>
<Animated.View <Link.Trigger>
className="mx-4 overflow-hidden rounded-2xl" <GestureDetector gesture={tapGesture}>
style={[ <Animated.View
animatedStyle, className="mx-4 overflow-hidden rounded-2xl"
{ style={[
height: 220, animatedStyle,
opacity: resolveMutation.isPending ? 0.7 : 1, {
borderCurve: "continuous", height: 220,
}, borderCurve: "continuous",
]} },
> ]}
<Pressable >
onPress={titleHref ? undefined : handlePress} <View
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details" accessibilityHint="Opens title details"
style={{ flex: 1 }} 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,
}}
> >
<Text {item.backdropPath && (
className="font-display text-2xl text-white" <Image
numberOfLines={2} source={{ uri: item.backdropPath }}
> className="absolute h-full w-full"
{item.title} contentFit="cover"
</Text> />
{item.overview ? ( )}
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}> {useGlass ? (
{item.overview} <GlassView
</Text> glassEffectStyle="regular"
) : null} colorScheme="dark"
<View className="mt-2 flex-row items-center gap-2"> style={{
{item.voteAverage != null && item.voteAverage > 0 && ( position: "absolute",
<View className="flex-row items-center gap-1"> bottom: 0,
<IconStarFilled size={12} color={primary} /> left: 0,
<Text className="text-primary text-xs"> right: 0,
{item.voteAverage.toFixed(1)} padding: 16,
</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 <Text
className="mt-1 text-white/70 text-xs" className="font-display text-2xl text-white"
numberOfLines={2} numberOfLines={2}
> >
{item.overview} {item.title}
</Text> </Text>
) : null} {item.overview ? (
<View className="mt-2 flex-row items-center gap-2"> <Text
{item.voteAverage != null && item.voteAverage > 0 && ( className="mt-1 text-white/70 text-xs"
<View className="flex-row items-center gap-1"> numberOfLines={2}
<IconStarFilled size={12} color={primary} /> >
<Text className="text-primary text-xs"> {item.overview}
{item.voteAverage.toFixed(1)} </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> </Text>
</View> </View>
)} </View>
<Text className="text-white/50 text-xs"> </>
{item.releaseDate?.slice(0, 4)} )}
</Text> </View>
</View> </Animated.View>
</View> </GestureDetector>
</> </Link.Trigger>
)} <Link.Preview />
</Pressable> </Link>
</Animated.View>
</GestureDetector>
); );
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 { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query"; import { Link } from "expo-router";
import { Link, useRouter } from "expo-router"; import { View } from "react-native";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
interpolate, interpolate,
@@ -15,12 +13,9 @@ import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem { export interface HeroBannerItem {
id?: string; id: string;
tmdbId: number;
title: string; title: string;
type: string; type: string;
backdropPath?: string | null; backdropPath?: string | null;
@@ -30,7 +25,6 @@ export interface HeroBannerItem {
} }
export function HeroBanner({ item }: { item: HeroBannerItem }) { export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string; const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0); 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() const tapGesture = Gesture.Tap()
.onBegin(() => { .onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 })); pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
@@ -73,81 +51,78 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
] ]
.filter(Boolean) .filter(Boolean)
.join(", "); .join(", ");
const titleHref = item.id const titleHref = `/title/${item.id}` as `/title/${string}`;
? (`/title/${item.id}` as `/title/${string}`)
: null;
const bannerContent = ( return (
<GestureDetector gesture={tapGesture}> <Link href={titleHref}>
<Animated.View <Link.Trigger>
className="mx-4 overflow-hidden rounded-2xl" <GestureDetector gesture={tapGesture}>
style={[ <Animated.View
animatedStyle, className="mx-4 overflow-hidden rounded-2xl"
{ style={[
height: 220, animatedStyle,
opacity: resolveMutation.isPending ? 0.7 : 1, {
borderCurve: "continuous", height: 220,
}, borderCurve: "continuous",
]} },
> ]}
<Pressable >
onPress={titleHref ? undefined : handlePress} <View
accessible accessible
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details" accessibilityHint="Opens title details"
style={{ flex: 1 }} 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}
> >
{item.title} {item.backdropPath && (
</Text> <Image
{item.overview ? ( source={{ uri: item.backdropPath }}
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}> className="absolute h-full w-full"
{item.overview} contentFit="cover"
</Text> />
) : null} )}
<View className="mt-2 flex-row items-center gap-2"> <View
{item.voteAverage != null && item.voteAverage > 0 && ( className="absolute inset-0"
<View className="flex-row items-center gap-1"> style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
<ScaledIcon icon={IconStarFilled} size={12} color={primary} /> />
<Text className="text-primary text-xs"> <View className="flex-1 justify-end p-4">
{item.voteAverage.toFixed(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">
<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> </Text>
</View> </View>
)} </View>
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View> </View>
</View> </Animated.View>
</Pressable> </GestureDetector>
</Animated.View> </Link.Trigger>
</GestureDetector> <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 { FlashList } from "@shopify/flash-list";
import { IconHistory, IconSearch } from "@tabler/icons-react-native"; import { IconHistory, IconSearch } from "@tabler/icons-react-native";
import { useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { Alert, Pressable, View } from "react-native"; import { Alert, Pressable, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
@@ -14,25 +13,12 @@ import {
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
export function RecentlyViewedList() { export function RecentlyViewedList() {
const { navigate } = useRouter();
const { items, removeItem, clearAll } = useRecentlyViewed(); const { items, removeItem, clearAll } = useRecentlyViewed();
const [mutedForeground, primaryColor] = useCSSVariable([ const [mutedForeground, primaryColor] = useCSSVariable([
"--color-muted-foreground", "--color-muted-foreground",
"--color-primary", "--color-primary",
]) as [string, string]; ]) 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( const handleDelete = useCallback(
(id: string) => { (id: string) => {
removeItem(id); removeItem(id);
@@ -58,13 +44,9 @@ export function RecentlyViewedList() {
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: RecentlyViewedItem }) => ( ({ item }: { item: RecentlyViewedItem }) => (
<RecentlyViewedRow <RecentlyViewedRow item={item} onDelete={handleDelete} />
item={item}
onPress={handlePress}
onDelete={handleDelete}
/>
), ),
[handlePress, handleDelete], [handleDelete],
); );
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []); 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 { Pressable } from "react-native";
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content"; import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
import { SwipeableRow } from "@/components/ui/swipeable-row"; import { SwipeableRow } from "@/components/ui/swipeable-row";
@@ -6,11 +7,9 @@ import type { RecentlyViewedItem } from "@/lib/recently-viewed";
export const RecentlyViewedRow = memo(function RecentlyViewedRow({ export const RecentlyViewedRow = memo(function RecentlyViewedRow({
item, item,
onPress,
onDelete, onDelete,
}: { }: {
item: RecentlyViewedItem; item: RecentlyViewedItem;
onPress: (item: RecentlyViewedItem) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
const accessibilityLabel = [ const accessibilityLabel = [
@@ -21,21 +20,30 @@ export const RecentlyViewedRow = memo(function RecentlyViewedRow({
.filter(Boolean) .filter(Boolean)
.join(", "); .join(", ");
const href = useMemo(
() =>
item.type === "person"
? (`/person/${item.id}` as `/person/${string}`)
: (`/title/${item.id}` as `/title/${string}`),
[item.id, item.type],
);
return ( return (
<SwipeableRow onDelete={() => onDelete(item.id)}> <SwipeableRow onDelete={() => onDelete(item.id)}>
<Pressable <Link href={href} asChild>
onPress={() => onPress(item)} <Pressable
accessibilityRole="button" accessibilityRole="link"
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
className="bg-background px-4 py-3" className="bg-background px-4 py-3"
style={({ pressed }) => ({ style={({ pressed }) => ({
borderBottomWidth: 0.5, borderBottomWidth: 0.5,
borderBottomColor: "rgba(255,255,255,0.08)", borderBottomColor: "rgba(255,255,255,0.08)",
opacity: pressed ? 0.7 : 1, opacity: pressed ? 0.7 : 1,
})} })}
> >
<RecentlyViewedRowContent item={item} /> <RecentlyViewedRowContent item={item} />
</Pressable> </Pressable>
</Link>
</SwipeableRow> </SwipeableRow>
); );
}); });
@@ -1,14 +1,14 @@
import { IconLoader, IconPlus } from "@tabler/icons-react-native"; 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 { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
export interface SearchResultItem { export interface SearchResultItem {
tmdbId: number; id?: string;
title: string; title: string;
type: "movie" | "tv" | "person"; type: "movie" | "tv" | "person";
posterPath?: string | null; posterPath?: string | null;
@@ -18,15 +18,11 @@ export interface SearchResultItem {
export const SearchResultRow = memo(function SearchResultRow({ export const SearchResultRow = memo(function SearchResultRow({
item, item,
onResolve,
onQuickAdd, onQuickAdd,
isResolving,
isAdding, isAdding,
}: { }: {
item: SearchResultItem; item: SearchResultItem;
onResolve: (item: SearchResultItem) => void; onQuickAdd: (id: string) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
isResolving: boolean;
isAdding: boolean; isAdding: boolean;
}) { }) {
const primary = useCSSVariable("--color-primary") as string; const primary = useCSSVariable("--color-primary") as string;
@@ -42,6 +38,71 @@ export const SearchResultRow = memo(function SearchResultRow({
.filter(Boolean) .filter(Boolean)
.join(", "); .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 ( return (
<View <View
className="flex-row items-center border-border border-b px-4 py-3" 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, borderBottomWidth: 0.5,
}} }}
> >
<Pressable {href ? (
onPress={() => onResolve(item)} <Link href={href} asChild>
disabled={isResolving} {rowContent}
accessibilityRole="button" </Link>
accessibilityLabel={accessibilityLabel} ) : (
className="flex-1 flex-row items-center" rowContent
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>
<View className="flex-1"> {item.type !== "person" && item.id && (
<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" && (
<Pressable <Pressable
onPress={() => onQuickAdd(item.tmdbId, item.type as "movie" | "tv")} onPress={() => onQuickAdd(item.id as string)}
disabled={isAdding || isResolving} disabled={isAdding}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Add ${item.title} to watchlist`} accessibilityLabel={`Add ${item.title} to watchlist`}
hitSlop={12} hitSlop={12}
@@ -123,10 +134,6 @@ export const SearchResultRow = memo(function SearchResultRow({
)} )}
</Pressable> </Pressable>
)} )}
{isResolving && (
<Spinner size="sm" colorClassName="accent-primary" className="ml-2" />
)}
</View> </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 { Pressable, View } from "react-native";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@@ -18,46 +18,49 @@ export function CastCard({
const accessibilityLabel = person.character const accessibilityLabel = person.character
? `${person.name} as ${person.character}` ? `${person.name} as ${person.character}`
: person.name; : person.name;
const { navigate } = useRouter();
return ( return (
<Pressable <Link
accessibilityRole="link" href={`/person/${person.personId}` as `/person/${string}`}
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
hitSlop={8} asChild
onPress={() => navigate(`/person/${person.personId}`)}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
> >
<View className="w-20 items-center"> <Pressable
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary"> accessibilityRole="link"
{person.profilePath && ( hitSlop={8}
<Image style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
source={{ uri: person.profilePath }} >
thumbHash={person.profileThumbHash} <View className="w-20 items-center">
recyclingKey={person.personId} <View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
className="h-full w-full" {person.profilePath && (
contentFit="cover" <Image
accessible={false} source={{ uri: person.profilePath }}
/> thumbHash={person.profileThumbHash}
)} recyclingKey={person.personId}
</View> className="h-full w-full"
<Text contentFit="cover"
numberOfLines={1} accessible={false}
maxFontSizeMultiplier={1.2} />
className="text-center font-medium font-sans text-foreground text-xs" )}
> </View>
{person.name}
</Text>
{person.character ? (
<Text <Text
numberOfLines={1} numberOfLines={1}
maxFontSizeMultiplier={1.0} maxFontSizeMultiplier={1.2}
className="text-center text-muted-foreground text-xs" className="text-center font-medium font-sans text-foreground text-xs"
> >
{person.character} {person.name}
</Text> </Text>
) : null} {person.character ? (
</View> <Text
</Pressable> 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"; type TitleStatus = "watchlist" | "in_progress" | "completed";
interface PosterCardProps { interface PosterCardProps {
id?: string; id: string;
tmdbId: number;
title: string; title: string;
type: "movie" | "tv"; type: "movie" | "tv";
posterPath: string | null; posterPath: string | null;
@@ -43,12 +42,7 @@ interface PosterCardProps {
userStatus?: TitleStatus | null; userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null; episodeProgress?: { watched: number; total: number } | null;
width?: number; width?: number;
onPress: ( onQuickAdd: (id: string) => void;
id: string | undefined,
tmdbId: number,
type: "movie" | "tv",
) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
isAdding?: boolean; isAdding?: boolean;
failedKey?: string | null; failedKey?: string | null;
onQuickAddFailed?: () => void; onQuickAddFailed?: () => void;
@@ -56,7 +50,6 @@ interface PosterCardProps {
export function PosterCard({ export function PosterCard({
id, id,
tmdbId,
title, title,
type, type,
posterPath, posterPath,
@@ -66,7 +59,6 @@ export function PosterCard({
userStatus, userStatus,
episodeProgress, episodeProgress,
width = 140, width = 140,
onPress,
onQuickAdd, onQuickAdd,
isAdding, isAdding,
failedKey, failedKey,
@@ -94,11 +86,11 @@ export function PosterCard({
}, [userStatus]); }, [userStatus]);
useEffect(() => { useEffect(() => {
if (failedKey === `${tmdbId}-${type}`) { if (failedKey === id) {
setLocalStatus(userStatus ?? null); setLocalStatus(userStatus ?? null);
onQuickAddFailed?.(); onQuickAddFailed?.();
} }
}, [failedKey, tmdbId, type, userStatus, onQuickAddFailed]); }, [failedKey, id, userStatus, onQuickAddFailed]);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [ transform: [
@@ -108,15 +100,11 @@ export function PosterCard({
], ],
})); }));
const handlePressAction = useCallback(() => {
onPress(id, tmdbId, type);
}, [onPress, id, tmdbId, type]);
const handleQuickAddPress = useCallback(() => { const handleQuickAddPress = useCallback(() => {
if (localStatus || isAdding) return; if (localStatus || isAdding) return;
setLocalStatus("watchlist"); setLocalStatus("watchlist");
onQuickAdd(tmdbId, type); onQuickAdd(id);
}, [localStatus, isAdding, onQuickAdd, tmdbId, type]); }, [localStatus, isAdding, onQuickAdd, id]);
const year = releaseDate?.slice(0, 4); const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5; const imageHeight = width * 1.5;
@@ -153,7 +141,7 @@ export function PosterCard({
thumbHash={posterThumbHash} thumbHash={posterThumbHash}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
contentFit="cover" contentFit="cover"
recyclingKey={`poster-${tmdbId}`} recyclingKey={`poster-${id}`}
transition={200} transition={200}
/> />
) : ( ) : (
@@ -271,120 +259,100 @@ export function PosterCard({
pressed.set(withSpring(0, { damping: 15, stiffness: 300 })); pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
}); });
// Cards with id: use context menu with navigation const titleHref = `/title/${id}` as `/title/${string}`;
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>
);
}
return ( return (
<GestureDetector gesture={pressGesture}> <ContextMenu.Root>
<Animated.View style={[animatedStyle, { width }]}> <ContextMenu.Trigger>
<View> <GestureDetector gesture={pressGesture}>
<Pressable <Animated.View style={[animatedStyle, { width }]}>
onPress={handlePressAction} <View>
accessibilityRole="button" <Link href={titleHref}>
accessibilityLabel={cardAccessibilityLabel} <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} <ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
</Pressable> <ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
{quickAddButton} </ContextMenu.Item>
</View> )}
</Animated.View> {type === "movie" && (
</GestureDetector> <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 { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast"; 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 * Use once per list parent instead of per-card to avoid creating
* a mutation observer for every mounted PosterCard. * a mutation observer for every mounted PosterCard.
*/ */
export function usePosterActions() { 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( const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({ orpc.titles.quickAdd.mutationOptions({
onSuccess: () => { 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( const handleQuickAdd = useCallback(
(tmdbId: number, type: "movie" | "tv") => { (id: string) => {
quickAddMutation.mutate({ tmdbId, type }); quickAddMutation.mutate({ id });
}, },
[quickAddMutation.mutate], [quickAddMutation.mutate],
); );
const addingKey = const addingKey =
quickAddMutation.isPending && quickAddMutation.variables quickAddMutation.isPending && quickAddMutation.variables
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}` ? quickAddMutation.variables.id
: null; : null;
const failedKey = const failedKey =
quickAddMutation.isError && quickAddMutation.variables quickAddMutation.isError && quickAddMutation.variables
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}` ? quickAddMutation.variables.id
: null; : null;
const resetError = useCallback(() => { const resetError = useCallback(() => {
quickAddMutation.reset(); quickAddMutation.reset();
}, [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 { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { import {
getEpisodeProgressByTmdbIds, getEpisodeProgressByTitleIds,
getUserStatusesByTmdbIds, getUserStatusesByTitleIds,
} from "@sofa/core/tracking"; } from "@sofa/core/tracking";
import { discover as discoverTmdb } from "@sofa/tmdb/client"; import { discover as discoverTmdb } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config"; import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image"; import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
import { getBrowseTitleIds } from "./browse-title-ids";
export const discover = os.discover export const discover = os.discover
.use(authed) .use(authed)
@@ -51,22 +47,23 @@ export const discover = os.discover
firstAirDate: (r.first_air_date as string | undefined) ?? null, firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? 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] = const [userStatuses, episodeProgress] =
lookups.length > 0 titleIds.length > 0
? [ ? [
getUserStatusesByTmdbIds(context.user.id, lookups), getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTmdbIds(context.user.id, lookups), getEpisodeProgressByTitleIds(context.user.id, titleIds),
] ]
: [{}, {}]; : [{}, {}];
+55 -36
View File
@@ -1,18 +1,14 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { import {
getEpisodeProgressByTmdbIds, getEpisodeProgressByTitleIds,
getUserStatusesByTmdbIds, getUserStatusesByTitleIds,
} from "@sofa/core/tracking"; } from "@sofa/core/tracking";
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client"; import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config"; import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image"; import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
import { getBrowseTitleIds } from "./browse-title-ids";
function requireTmdb() { function requireTmdb() {
if (!isTmdbConfigured()) { if (!isTmdbConfigured()) {
@@ -54,29 +50,51 @@ export const trending = os.explore.trending
(r) => (r) =>
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"), 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 ...(heroResult
? [ ? [
{ {
tmdbId: heroResult.id as number, tmdbId: heroResult.id as number,
type: heroResult.media_type as "movie" | "tv", 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 titleMap = ensureBrowseTitlesExist(allBrowseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
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 const hero = heroResult
? { ? {
id: titleIdsByLookup[ id: heroEntry?.id ?? "",
`${heroResult.id as number}-${heroResult.media_type as "movie" | "tv"}`
],
tmdbId: heroResult.id as number, tmdbId: heroResult.id as number,
type: heroResult.media_type as "movie" | "tv", type: heroResult.media_type as "movie" | "tv",
title: title:
@@ -90,12 +108,12 @@ export const trending = os.explore.trending
} }
: null; : null;
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type })); const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] = const [userStatuses, episodeProgress] =
lookups.length > 0 titleIds.length > 0
? [ ? [
getUserStatusesByTmdbIds(context.user.id, lookups), getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTmdbIds(context.user.id, lookups), getEpisodeProgressByTitleIds(context.user.id, titleIds),
] ]
: [{}, {}]; : [{}, {}];
@@ -127,22 +145,23 @@ export const popular = os.explore.popular
firstAirDate: (r.first_air_date as string | undefined) ?? null, firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | 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] = const [userStatuses, episodeProgress] =
lookups.length > 0 titleIds.length > 0
? [ ? [
getUserStatusesByTmdbIds(context.user.id, lookups), getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTmdbIds(context.user.id, lookups), getEpisodeProgressByTitleIds(context.user.id, titleIds),
] ]
: [{}, {}]; : [{}, {}];
+1 -14
View File
@@ -1,9 +1,5 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
fetchFullFilmography,
getOrFetchPerson,
getOrFetchPersonByTmdbId,
} from "@sofa/core/person";
import { getUserStatusesByTitleIds } from "@sofa/core/tracking"; import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -34,12 +30,3 @@ export const detail = os.people.detail
totalResults: allCredits.length, 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 { ORPCError } from "@orpc/server";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { ensureBrowsePersonsExist } from "@sofa/core/person";
import { import {
searchMovies, searchMovies,
searchMulti, searchMulti,
@@ -25,23 +27,36 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
if (type === "person") { if (type === "person") {
const personResults = await searchPerson(query, input.page); 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 { return {
results: (personResults.results ?? []).map((r) => ({ results: personItems.map((r) => ({
tmdbId: r.id, ...r,
type: "person" as const, id: personMap.get(r.tmdbId),
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,
})), })),
page: personResults.page ?? input.page, page: personResults.page ?? input.page,
totalPages: personResults.total_pages ?? 1, 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); .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 { return {
results: mapped, results,
page: raw.page ?? input.page, page: raw.page ?? input.page,
totalPages: raw.total_pages ?? 1, totalPages: raw.total_pages ?? 1,
totalResults: raw.total_results ?? 0, totalResults: raw.total_results ?? 0,
+14 -26
View File
@@ -1,10 +1,6 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { getRecommendationsForTitle } from "@sofa/core/discovery"; import { getRecommendationsForTitle } from "@sofa/core/discovery";
import { import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
ensureTvHydrated,
getOrFetchTitle,
getOrFetchTitleByTmdbId,
} from "@sofa/core/metadata";
import { import {
getUserStatusesByTitleIds, getUserStatusesByTitleIds,
getUserTitleInfo, getUserTitleInfo,
@@ -16,7 +12,7 @@ import {
} from "@sofa/core/tracking"; } from "@sofa/core/tracking";
import { db } from "@sofa/db/client"; import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers"; 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 { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -29,15 +25,6 @@ export const detail = os.titles.detail
return result; 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 export const updateStatus = os.titles.updateStatus
.use(authed) .use(authed)
.handler(({ input, context }) => { .handler(({ input, context }) => {
@@ -83,23 +70,24 @@ export const recommendations = os.titles.recommendations
return { recommendations: recs, userStatuses }; 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 export const quickAdd = os.titles.quickAdd
.use(authed) .use(authed)
.handler(async ({ input, context }) => { .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) { if (!title) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { throw new ORPCError("NOT_FOUND", { message: "Title not found" });
message: "Failed to import title",
});
} }
// Trigger full TMDB import if still a shell
getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(
() => {},
);
const existing = db const existing = db
.select() .select()
.from(userTitleStatus) .from(userTitleStatus)
-3
View File
@@ -16,14 +16,12 @@ import * as titles from "./procedures/titles";
export const implementedRouter = { export const implementedRouter = {
titles: { titles: {
detail: titles.detail, detail: titles.detail,
resolve: titles.resolve,
updateStatus: titles.updateStatus, updateStatus: titles.updateStatus,
updateRating: titles.updateRating, updateRating: titles.updateRating,
watchMovie: titles.watchMovie, watchMovie: titles.watchMovie,
watchAll: titles.watchAll, watchAll: titles.watchAll,
userInfo: titles.userInfo, userInfo: titles.userInfo,
recommendations: titles.recommendations, recommendations: titles.recommendations,
hydrateSeasons: titles.hydrateSeasons,
quickAdd: titles.quickAdd, quickAdd: titles.quickAdd,
}, },
episodes: { episodes: {
@@ -37,7 +35,6 @@ export const implementedRouter = {
}, },
people: { people: {
detail: people.detail, detail: people.detail,
resolve: people.resolve,
}, },
dashboard: { dashboard: {
stats: dashboard.stats, stats: dashboard.stats,
+7 -40
View File
@@ -8,11 +8,10 @@ import {
IconX, IconX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys"; 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 { useNavigate } from "@tanstack/react-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress"; import { useProgress } from "@/components/navigation-progress";
import { import {
Command, Command,
@@ -69,6 +68,7 @@ for (const entry of SHORTCUT_DESCRIPTIONS) {
} }
interface SearchResult { interface SearchResult {
id?: string;
tmdbId: number; tmdbId: number;
type: "movie" | "tv" | "person"; type: "movie" | "tv" | "person";
title: string; title: string;
@@ -144,51 +144,18 @@ export function CommandPalette() {
}; };
}, [debouncedQuery, results.length, setRecentSearches]); }, [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( const handleSelect = useCallback(
(result: SearchResult) => { (result: SearchResult) => {
if (!result.id) return;
setCommandPaletteOpen(false); setCommandPaletteOpen(false);
progress.start(); progress.start();
if (result.type === "person") { if (result.type === "person") {
resolvePersonMutation.mutate({ tmdbId: result.tmdbId }); void navigate({ to: "/people/$id", params: { id: result.id } });
} else { } else {
resolveTitleMutation.mutate({ void navigate({ to: "/titles/$id", params: { id: result.id } });
tmdbId: result.tmdbId,
type: result.type,
});
} }
}, },
[ [setCommandPaletteOpen, progress, navigate],
setCommandPaletteOpen,
progress,
resolvePersonMutation,
resolveTitleMutation,
],
); );
const handleRecentSearch = useCallback((q: string) => { const handleRecentSearch = useCallback((q: string) => {
@@ -251,7 +218,7 @@ export function CommandPalette() {
<CommandGroup heading="Results"> <CommandGroup heading="Results">
{results.map((r) => ( {results.map((r) => (
<CommandItem <CommandItem
key={`${r.type}-${r.tmdbId}`} key={r.id ?? `${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)} onSelect={() => handleSelect(r)}
className="flex items-center gap-3 py-2" className="flex items-center gap-3 py-2"
> >
@@ -3,7 +3,6 @@ import { Skeleton } from "@/components/ui/skeleton";
interface TitleGridItem { interface TitleGridItem {
id: string; id: string;
tmdbId: number;
type: string; type: string;
title: string; title: string;
posterPath: string | null; posterPath: string | null;
@@ -43,7 +42,6 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
> >
<TitleCard <TitleCard
id={t.id} id={t.id}
tmdbId={t.tmdbId}
type={t.type} type={t.type}
title={t.title} title={t.title}
posterPath={t.posterPath} posterPath={t.posterPath}
@@ -96,7 +96,7 @@ export function ExploreClient() {
<div className="space-y-10"> <div className="space-y-10">
{hero && ( {hero && (
<HeroBanner <HeroBanner
tmdbId={hero.tmdbId} id={hero.id}
type={hero.type} type={hero.type}
title={hero.title} title={hero.title}
overview={hero.overview} overview={hero.overview}
@@ -12,7 +12,7 @@ interface Genre {
} }
interface TitleRowItem { interface TitleRowItem {
tmdbId: number; id: string;
type: "movie" | "tv"; type: "movie" | "tv";
title: string; title: string;
posterPath: string | null; posterPath: string | null;
@@ -177,26 +177,21 @@ export function FilterableTitleRow({
> >
<div className="flex gap-4 px-6 py-2 sm:px-2"> <div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item: TitleRowItem, i: number) => ( {items.map((item: TitleRowItem, i: number) => (
<div <div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div <div
className="animate-stagger-item" className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties} style={{ "--stagger-index": i } as React.CSSProperties}
> >
<TitleCard <TitleCard
tmdbId={item.tmdbId} id={item.id}
type={item.type} type={item.type}
title={item.title} title={item.title}
posterPath={item.posterPath} posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash} posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate} releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage} voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]} userStatus={userStatuses[item.id]}
episodeProgress={ episodeProgress={episodeProgress[item.id]}
episodeProgress[`${item.tmdbId}-${item.type}`]
}
/> />
</div> </div>
</div> </div>
+13 -41
View File
@@ -4,15 +4,10 @@ import {
IconPlus, IconPlus,
IconStar, IconStar,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { orpc } from "@/lib/orpc/client";
interface HeroBannerProps { interface HeroBannerProps {
tmdbId: number; id: string;
type: "movie" | "tv"; type: "movie" | "tv";
title: string; title: string;
overview: string; overview: string;
@@ -21,34 +16,13 @@ interface HeroBannerProps {
} }
export function HeroBanner({ export function HeroBanner({
tmdbId, id,
type, type,
title, title,
overview, overview,
backdropPath, backdropPath,
voteAverage, voteAverage,
}: HeroBannerProps) { }: 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 ( return (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden"> <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"> <div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
@@ -103,28 +77,26 @@ export function HeroBanner({
Trending today Trending today
</span> </span>
</div> </div>
<button <Link
type="button" to="/titles/$id"
className="group/title cursor-pointer text-left" params={{ id }}
onClick={handleNavigate} className="group/title text-left"
disabled={resolveMutation.isPending}
> >
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl"> <h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
{title} {title}
</h2> </h2>
</button> </Link>
<p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm"> <p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm">
{overview} {overview}
</p> </p>
<button <Link
type="button" to="/titles/$id"
onClick={handleNavigate} params={{ id }}
disabled={resolveMutation.isPending} 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"
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"
> >
<IconPlus aria-hidden={true} className="size-4" /> <IconPlus aria-hidden={true} className="size-4" />
Add to Library Add to Library
</button> </Link>
</div> </div>
</div> </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"; import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
interface TitleRowItem { interface TitleRowItem {
tmdbId: number; id: string;
type: "movie" | "tv"; type: "movie" | "tv";
title: string; title: string;
posterPath: string | null; posterPath: string | null;
@@ -70,26 +70,21 @@ export function TitleRow({
> >
<div className="flex gap-4 px-6 py-2 sm:px-2"> <div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => ( {items.map((item, i) => (
<div <div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div <div
className="animate-stagger-item" className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties} style={{ "--stagger-index": i } as React.CSSProperties}
> >
<TitleCard <TitleCard
tmdbId={item.tmdbId} id={item.id}
type={item.type} type={item.type}
title={item.title} title={item.title}
posterPath={item.posterPath} posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash} posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate} releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage} voteAverage={item.voteAverage}
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]} userStatus={userStatuses?.[item.id]}
episodeProgress={ episodeProgress={episodeProgress?.[item.id]}
episodeProgress?.[`${item.tmdbId}-${item.type}`]
}
/> />
</div> </div>
</div> </div>
@@ -125,7 +125,6 @@ export function FilmographyGrid({
> >
<TitleCard <TitleCard
id={credit.titleId} id={credit.titleId}
tmdbId={credit.tmdbId}
type={credit.type} type={credit.type}
title={credit.title} title={credit.title}
posterPath={credit.posterPath} posterPath={credit.posterPath}
+9 -48
View File
@@ -9,11 +9,9 @@ import {
IconStarFilled, IconStarFilled,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query"; 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 { type MotionStyle, type MotionValue, motion } from "motion/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
Tooltip, Tooltip,
@@ -57,8 +55,7 @@ interface CardInnerProps {
} }
export interface TitleCardProps extends CardInnerProps { export interface TitleCardProps extends CardInnerProps {
id?: string; id: string;
tmdbId: number;
} }
const statusConfig = { const statusConfig = {
@@ -80,12 +77,10 @@ const statusConfig = {
} as const; } as const;
function QuickAddButton({ function QuickAddButton({
tmdbId, id,
type,
userStatus, userStatus,
}: { }: {
tmdbId: number; id: string;
type: "movie" | "tv";
userStatus?: TitleStatus | null; userStatus?: TitleStatus | null;
}) { }) {
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>( const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
@@ -112,7 +107,7 @@ function QuickAddButton({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (quickAddMutation.isPending || isAdded) return; if (quickAddMutation.isPending || isAdded) return;
quickAddMutation.mutate({ tmdbId, type }); quickAddMutation.mutate({ id });
} }
if (isAdded && config) { if (isAdded && config) {
@@ -292,7 +287,6 @@ function CardInner({
export function TitleCard({ export function TitleCard({
id, id,
tmdbId,
type, type,
title, title,
posterPath, posterPath,
@@ -303,21 +297,6 @@ export function TitleCard({
episodeProgress, episodeProgress,
}: TitleCardProps) { }: TitleCardProps) {
const tilt = useTiltEffect(); 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 = ( const cardContent = (
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}> <motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
@@ -341,28 +320,10 @@ export function TitleCard({
return ( return (
<div className="group relative"> <div className="group relative">
<QuickAddButton <QuickAddButton id={id} userStatus={userStatus} />
tmdbId={tmdbId} <Link to="/titles/$id" params={{ id }}>
type={type as "movie" | "tv"} {cardContent}
userStatus={userStatus} </Link>
/>
{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>
)}
</div> </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 <TitleCard
id={rec.id} id={rec.id}
tmdbId={rec.tmdbId}
type={rec.type} type={rec.type}
title={rec.title} title={rec.title}
posterPath={rec.posterPath} posterPath={rec.posterPath}
+3 -16
View File
@@ -1,6 +1,4 @@
import { createFileRoute, Link } from "@tanstack/react-router"; 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 { TitleActions } from "@/components/titles/title-actions";
import { TitleAvailability } from "@/components/titles/title-availability"; import { TitleAvailability } from "@/components/titles/title-availability";
import { TitleCast } from "@/components/titles/title-cast"; 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 { TitleKeyboardShortcuts } from "@/components/titles/title-keyboard-shortcuts";
import { TitleProvider } from "@/components/titles/title-provider"; import { TitleProvider } from "@/components/titles/title-provider";
import { TitleRecommendations } from "@/components/titles/title-recommendations"; import { TitleRecommendations } from "@/components/titles/title-recommendations";
import { import { TitleSeasons } from "@/components/titles/title-seasons";
SeasonsSkeleton,
TitleSeasons,
} from "@/components/titles/title-seasons";
import { TitleTheme } from "@/components/titles/title-theme"; import { TitleTheme } from "@/components/titles/title-theme";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
@@ -46,8 +41,7 @@ export const Route = createFileRoute("/_app/titles/$id")({
}); });
function TitleDetailPage() { function TitleDetailPage() {
const { title, seasons, needsHydration, availability, cast } = const { title, seasons, availability, cast } = Route.useLoaderData();
Route.useLoaderData();
const themeStyle = getThemeCssProperties(title.colorPalette); const themeStyle = getThemeCssProperties(title.colorPalette);
@@ -69,14 +63,7 @@ function TitleDetailPage() {
<TitleAvailability availability={availability} /> <TitleAvailability availability={availability} />
</TitleHero> </TitleHero>
{title.type === "tv" && needsHydration && ( {title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
<Suspense fallback={<SeasonsSkeleton />}>
<AsyncTitleSeasons titleId={title.id} tmdbId={title.tmdbId} />
</Suspense>
)}
{title.type === "tv" && !needsHydration && seasons.length > 0 && (
<TitleSeasons />
)}
<TitleCast cast={cast} titleType={title.type} /> <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: contents:
- content: >- - content: >-
Comprehensive health check covering database, TMDB connectivity, cron Comprehensive health check covering database, TMDB connectivity, cron
jobs, image cache, backups, and environment. Does not require jobs, image cache, backups, and environment. Admin only.
authentication.
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* 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"}]} /> <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: [] headings: []
contents: contents:
- content: >- - content: >-
Import a title by TMDB ID and add it to the user's watchlist in one Add a title to the user's watchlist and trigger a full TMDB import if
step. If the title already exists in the user's library, returns needed. If the title already exists in the user's library, returns
alreadyAdded: true. 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. */} {/* 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)" "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": { "colorPalette": {
"anyOf": [ "anyOf": [
{ {
@@ -366,6 +410,10 @@
"voteCount", "voteCount",
"status", "status",
"contentRating", "contentRating",
"imdbId",
"tvdbId",
"originalLanguage",
"runtimeMinutes",
"colorPalette", "colorPalette",
"trailerVideoKey", "trailerVideoKey",
"genres" "genres"
@@ -840,6 +888,10 @@
"BrowseItem": { "BrowseItem": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {
"type": "string",
"description": "Internal title ID"
},
"tmdbId": { "tmdbId": {
"type": "number", "type": "number",
"description": "TMDB numeric ID" "description": "TMDB numeric ID"
@@ -912,6 +964,7 @@
} }
}, },
"required": [ "required": [
"id",
"tmdbId", "tmdbId",
"type", "type",
"title", "title",
@@ -1544,11 +1597,7 @@
"items": { "items": {
"$ref": "#/components/schemas/Season" "$ref": "#/components/schemas/Season"
}, },
"description": "TV seasons (empty for movies or unhydrated shows)" "description": "TV seasons (empty for movies)"
},
"needsHydration": {
"type": "boolean",
"description": "Whether season/episode data needs to be fetched from TMDB before tracking"
}, },
"availability": { "availability": {
"type": "array", "type": "array",
@@ -1612,7 +1661,6 @@
"required": [ "required": [
"title", "title",
"seasons", "seasons",
"needsHydration",
"availability", "availability",
"cast" "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": { "/titles/{id}/status": {
"put": { "put": {
"operationId": "titles.updateStatus", "operationId": "titles.updateStatus",
@@ -2162,11 +2083,11 @@
] ]
} }
}, },
"/titles/{id}/hydrate-seasons": { "/titles/{id}/quick-add": {
"post": { "post": {
"operationId": "titles.hydrateSeasons", "operationId": "titles.quickAdd",
"summary": "Hydrate TV seasons", "summary": "Quick add title to library",
"description": "Fetch full season and episode data from TMDB for a TV show. Required before tracking individual episodes.", "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": [ "tags": [
"Titles" "Titles"
], ],
@@ -2178,98 +2099,16 @@
"schema": { "schema": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "Internal title ID" "description": "Internal UUIDv7 identifier"
} }
} }
], ],
"requestBody": { "requestBody": {
"required": true, "required": false,
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "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"
} }
} }
} }
@@ -2300,8 +2139,8 @@
} }
} }
}, },
"500": { "404": {
"description": "500", "description": "404",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -2313,14 +2152,14 @@
"const": true "const": true
}, },
"code": { "code": {
"const": "INTERNAL_SERVER_ERROR" "const": "NOT_FOUND"
}, },
"status": { "status": {
"const": 500 "const": 404
}, },
"message": { "message": {
"type": "string", "type": "string",
"default": "Failed to import title from TMDB" "default": "Title not found"
}, },
"data": {} "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": { "/dashboard/stats": {
"get": { "get": {
"operationId": "dashboard.stats", "operationId": "dashboard.stats",
@@ -3464,6 +3185,10 @@
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {
"type": "string",
"description": "Internal title ID"
},
"tmdbId": { "tmdbId": {
"type": "number", "type": "number",
"description": "TMDB numeric ID" "description": "TMDB numeric ID"
@@ -3500,6 +3225,7 @@
} }
}, },
"required": [ "required": [
"id",
"tmdbId", "tmdbId",
"type", "type",
"title", "title",
@@ -4027,6 +3753,10 @@
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {
"type": "string",
"description": "Internal title ID (present for movie/tv results, absent for people)"
},
"tmdbId": { "tmdbId": {
"type": "number", "type": "number",
"description": "TMDB numeric ID" "description": "TMDB numeric ID"
@@ -4595,7 +4325,7 @@
"get": { "get": {
"operationId": "system.health", "operationId": "system.health",
"summary": "Get system health report", "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": [ "tags": [
"System" "System"
], ],
+5 -53
View File
@@ -14,8 +14,6 @@ import {
DiscoverOutput, DiscoverOutput,
FilenameParam, FilenameParam,
GenresOutput, GenresOutput,
HydrateSeasonsInput,
HydrateSeasonsOutput,
IdParam, IdParam,
IntegrationOutput, IntegrationOutput,
IntegrationsListOutput, IntegrationsListOutput,
@@ -24,7 +22,6 @@ import {
PageParam, PageParam,
PaginatedInput, PaginatedInput,
PersonDetailOutput, PersonDetailOutput,
PersonResolveOutput,
PopularOutput, PopularOutput,
ProviderParam, ProviderParam,
PublicInfoOutput, PublicInfoOutput,
@@ -40,9 +37,6 @@ import {
TelemetryOutput, TelemetryOutput,
TitleDetailOutput, TitleDetailOutput,
TitleRecommendationsOutput, TitleRecommendationsOutput,
TitleResolveOutput,
TmdbIdParam,
TmdbIdTypeParam,
ToggleRegistrationInput, ToggleRegistrationInput,
ToggleTelemetryInput, ToggleTelemetryInput,
ToggleUpdateCheckInput, ToggleUpdateCheckInput,
@@ -80,21 +74,6 @@ export const contract = {
.errors({ .errors({
NOT_FOUND: { message: "Title not found" }, 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 updateStatus: oc
.route({ .route({
method: "PUT", method: "PUT",
@@ -164,33 +143,21 @@ export const contract = {
}) })
.input(IdParam) .input(IdParam)
.output(TitleRecommendationsOutput), .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 quickAdd: oc
.route({ .route({
method: "POST", method: "POST",
path: "/titles/quick-add", path: "/titles/{id}/quick-add",
tags: ["Titles"], tags: ["Titles"],
summary: "Quick add title to library", summary: "Quick add title to library",
description: 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: successDescription:
"Title ID and whether it was already in the library", "Title ID and whether it was already in the library",
}) })
.input(TmdbIdTypeParam) .input(IdParam)
.output(QuickAddOutput) .output(QuickAddOutput)
.errors({ .errors({
INTERNAL_SERVER_ERROR: { message: "Failed to import title from TMDB" }, NOT_FOUND: { message: "Title not found" },
}), }),
}, },
episodes: { episodes: {
@@ -267,21 +234,6 @@ export const contract = {
.errors({ .errors({
NOT_FOUND: { message: "Person not found" }, 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: { dashboard: {
stats: oc stats: oc
@@ -461,7 +413,7 @@ export const contract = {
tags: ["System"], tags: ["System"],
summary: "Get system health report", summary: "Get system health report",
description: 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", successDescription: "Full system health report",
}) })
.output(SystemHealthOutput), .output(SystemHealthOutput),
+19 -55
View File
@@ -10,9 +10,6 @@ export const ProviderParam = z.object({
.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]) .enum(["plex", "jellyfin", "emby", "sonarr", "radarr"])
.describe("Media server provider type"), .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({ export const FilenameParam = z.object({
filename: z.string().min(1).describe("Backup filename"), filename: z.string().min(1).describe("Backup filename"),
}); });
@@ -24,13 +21,6 @@ export const TrendingTypeParam = z.object({
.enum(["all", "movie", "tv"]) .enum(["all", "movie", "tv"])
.describe("Trending category: all, movie, or 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 ────────────────────────────────────────────── // ─── Pagination ──────────────────────────────────────────────
/** Page param for TMDB-backed endpoints (fixed ~20 items/page from TMDB) */ /** 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" }); .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 ────────────────────────────────── // ─── Search / Discover inputs ──────────────────────────────────
export const SearchInput = z export const SearchInput = z
@@ -394,6 +377,16 @@ export const ResolvedTitleSchema = z
.string() .string()
.nullable() .nullable()
.describe("Content rating (e.g. PG-13, TV-MA)"), .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(), colorPalette: ColorPaletteSchema.nullable(),
trailerVideoKey: z trailerVideoKey: z
.string() .string()
@@ -451,10 +444,7 @@ export const PersonCreditSchema = z
/** Reusable TMDB browse result (trending / popular / discover items) */ /** Reusable TMDB browse result (trending / popular / discover items) */
export const TmdbBrowseItem = z export const TmdbBrowseItem = z
.object({ .object({
id: z id: z.string().describe("Internal title ID"),
.string()
.optional()
.describe("Internal title ID when the title already exists locally"),
tmdbId: z.number().describe("TMDB numeric ID"), tmdbId: z.number().describe("TMDB numeric ID"),
type: mediaType, type: mediaType,
title: z.string().describe("Display title"), title: z.string().describe("Display title"),
@@ -514,14 +504,7 @@ const BrowseOutput = z
export const TitleDetailOutput = z export const TitleDetailOutput = z
.object({ .object({
title: ResolvedTitleSchema, title: ResolvedTitleSchema,
seasons: z seasons: z.array(SeasonSchema).describe("TV seasons (empty for movies)"),
.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",
),
availability: z availability: z
.array(AvailabilityOfferSchema) .array(AvailabilityOfferSchema)
.describe("Streaming availability offers"), .describe("Streaming availability offers"),
@@ -532,12 +515,6 @@ export const TitleDetailOutput = z
"Full title details with seasons, cast, and streaming availability", "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 export const UserInfoOutput = z
.object({ .object({
status: z status: z
@@ -581,12 +558,6 @@ export const PersonDetailOutput = z
"Person profile with paginated filmography and user's statuses for their titles", "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 ───────────────────────────────────────── // ─── Dashboard outputs ─────────────────────────────────────────
export const DashboardStatsOutput = z export const DashboardStatsOutput = z
@@ -694,10 +665,7 @@ export const TrendingOutput = z
items: z.array(TmdbBrowseItem).describe("Trending titles"), items: z.array(TmdbBrowseItem).describe("Trending titles"),
hero: z hero: z
.object({ .object({
id: z id: z.string().describe("Internal title ID"),
.string()
.optional()
.describe("Internal title ID when the title already exists locally"),
tmdbId: z.number().describe("TMDB numeric ID"), tmdbId: z.number().describe("TMDB numeric ID"),
type: mediaType, type: mediaType,
title: z.string().describe("Display title"), title: z.string().describe("Display title"),
@@ -735,6 +703,12 @@ export const SearchOutput = z
results: z.array( results: z.array(
z z
.object({ .object({
id: z
.string()
.optional()
.describe(
"Internal title ID (present for movie/tv results, absent for people)",
),
tmdbId: z.number().describe("TMDB numeric ID"), tmdbId: z.number().describe("TMDB numeric ID"),
type: z.enum(["movie", "tv", "person"]).describe("Result type"), type: z.enum(["movie", "tv", "person"]).describe("Result type"),
title: z.string().describe("Title or person name"), title: z.string().describe("Title or person name"),
@@ -1121,16 +1095,6 @@ export const AuthConfigOutput = z
description: "Authentication provider configuration", 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 // 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; 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>; type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */ /** In-flight import promises keyed by `${tmdbId}-${type}` — coalesces concurrent calls */
const inflightImports = new Map<number, ImportResult>(); const inflightImports = new Map<string, ImportResult>();
export function getOrFetchTitleByTmdbId( export function getOrFetchTitleByTmdbId(
tmdbId: number, tmdbId: number,
type: "movie" | "tv", type: "movie" | "tv",
): ImportResult { ): ImportResult {
const inflight = inflightImports.get(tmdbId); const key = `${tmdbId}-${type}`;
const inflight = inflightImports.get(key);
if (inflight) { 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; return inflight;
} }
const promise = _getOrFetchTitleByTmdbId(tmdbId, type).finally(() => { const promise = _getOrFetchTitleByTmdbId(tmdbId, type).finally(() => {
inflightImports.delete(tmdbId); inflightImports.delete(key);
}) as ImportResult; }) as ImportResult;
inflightImports.set(tmdbId, promise); inflightImports.set(key, promise);
return promise; return promise;
} }
@@ -180,12 +207,65 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
const existing = db const existing = db
.select() .select()
.from(titles) .from(titles)
.where(eq(titles.tmdbId, tmdbId)) .where(and(eq(titles.tmdbId, tmdbId), eq(titles.type, type)))
.get(); .get();
if (existing) { if (existing) {
// For TV shows, check if seasons/episodes were actually loaded. // Shell title — upgrade to full import
// They may be missing if a prior fetch failed or the title was created if (!existing.lastFetchedAt) {
// as a shell by the recommendations system (lastFetchedAt: null). 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") { if (existing.type === "tv") {
const hasSeason = db const hasSeason = db
.select({ id: seasons.id }) .select({ id: seasons.id })
@@ -195,40 +275,18 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
.get(); .get();
if (!hasSeason) { if (!hasSeason) {
const show = await getTvDetails(tmdbId); 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 ?? []); upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
refreshAvailability(existing.id).catch((err) => fireAndForgetEnrichment(
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(
existing.id, existing.id,
show.poster_path, show.poster_path,
show.backdrop_path, show.backdrop_path,
"tv", "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 db.select().from(titles).where(eq(titles.id, existing.id)).get();
} }
} }
return existing; return existing;
} }
@@ -251,26 +309,20 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
voteCount: movie.vote_count, voteCount: movie.vote_count,
status: movie.status, status: movie.status,
contentRating: extractMovieContentRating(movie), contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now, lastFetchedAt: now,
}, },
tmdbId, tmdbId,
); );
if (!row) return undefined; if (!row) return undefined;
upsertGenres(row.id, movie.genres ?? []); upsertGenres(row.id, movie.genres ?? []);
refreshAvailability(row.id).catch((err) => fireAndForgetEnrichment(
log.debug("Availability enrichment failed:", err), row.id,
); movie.poster_path,
refreshRecommendations(row.id).catch((err) => movie.backdrop_path,
log.debug("Recommendations enrichment failed:", err), "movie",
);
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),
); );
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`); log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
return row; return row;
@@ -293,6 +345,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
voteCount: show.vote_count, voteCount: show.vote_count,
status: show.status, status: show.status,
contentRating: extractTvContentRating(show), contentRating: extractTvContentRating(show),
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: now, lastFetchedAt: now,
}, },
tmdbId, tmdbId,
@@ -301,21 +355,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
upsertGenres(row.id, show.genres ?? []); upsertGenres(row.id, show.genres ?? []);
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons); await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
refreshAvailability(row.id).catch((err) => fireAndForgetEnrichment(row.id, show.poster_path, show.backdrop_path, "tv");
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),
);
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`); log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
return row; return row;
} }
@@ -340,6 +380,9 @@ export async function refreshTitle(titleId: string) {
voteCount: movie.vote_count, voteCount: movie.vote_count,
status: movie.status, status: movie.status,
contentRating: extractMovieContentRating(movie), contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now, lastFetchedAt: now,
}); });
upsertGenres(titleId, movie.genres ?? []); upsertGenres(titleId, movie.genres ?? []);
@@ -358,6 +401,8 @@ export async function refreshTitle(titleId: string) {
status: show.status, status: show.status,
contentRating: extractTvContentRating(show), contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null, tvdbId: show.external_ids?.tvdb_id ?? null,
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: now, lastFetchedAt: now,
}); });
upsertGenres(titleId, show.genres ?? []); upsertGenres(titleId, show.genres ?? []);
@@ -387,125 +432,135 @@ export async function refreshTvChildren(
tmdbId: number, tmdbId: number,
numberOfSeasons: 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(); const now = new Date();
for (let sn = 1; sn <= numberOfSeasons; sn++) { for (let i = 0; i < seasonNumbers.length; i++) {
// Rate-limit: 250ms between TMDB calls const result = fetched[i];
if (sn > 1) await delay(250); const sn = seasonNumbers[i];
if (result.status === "rejected") {
log.error(
`Failed to fetch season ${sn} for TMDB ${tmdbId}:`,
result.reason,
);
continue;
}
try { const seasonData = result.value;
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 seasonRow = db const existingSeason = db
.insert(seasons) .select({
.values({ id: seasons.id,
titleId, posterPath: seasons.posterPath,
seasonNumber: seasonData.season_number, })
.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, name: seasonData.name,
overview: seasonData.overview, overview: seasonData.overview,
posterPath: seasonData.poster_path, posterPath: seasonData.poster_path,
airDate: seasonData.air_date, airDate: seasonData.air_date,
lastFetchedAt: now, lastFetchedAt: now,
}) },
.onConflictDoUpdate({ })
target: [seasons.titleId, seasons.seasonNumber], .returning()
set: { .get();
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
},
})
.returning()
.get();
// Snapshot existing episode still paths before upsert so we can detect changes // Snapshot existing episode still paths before upsert so we can detect changes
const oldEpStills = new Map( const oldEpStills = new Map(
db db
.select({ .select({
episodeNumber: episodes.episodeNumber, episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath, stillPath: episodes.stillPath,
}) })
.from(episodes) .from(episodes)
.where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id)) .where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id))
.all() .all()
.map((e) => [e.episodeNumber, e.stillPath] as const), .map((e) => [e.episodeNumber, e.stillPath] as const),
); );
// Batch all episode upserts in a single transaction per season // Batch all episode upserts in a single transaction per season
const eps = seasonData.episodes ?? []; const eps = seasonData.episodes ?? [];
if (eps.length > 0) { if (eps.length > 0) {
db.transaction((tx) => { db.transaction((tx) => {
for (const ep of eps) { for (const ep of eps) {
tx.insert(episodes) tx.insert(episodes)
.values({ .values({
seasonId: seasonRow.id, seasonId: seasonRow.id,
episodeNumber: ep.episode_number, 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, name: ep.name,
overview: ep.overview, overview: ep.overview,
stillPath: ep.still_path, stillPath: ep.still_path,
airDate: ep.air_date, airDate: ep.air_date,
runtimeMinutes: ep.runtime, 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(); .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). * Ensure a TV title is fully hydrated (seasons/episodes fetched from TMDB).
* Returns the hydrated seasons data. * Returns the hydrated seasons data.
*/ */
export async function ensureTvHydrated( export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
titleId: string,
tmdbId: number,
): Promise<Season[]> {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return []; if (!title || title.type !== "tv") return [];
const { tmdbId } = title;
// Shell title: fetch details + children // Shell title: fetch details + children
if (!title.lastFetchedAt) { if (!title.lastFetchedAt) {
try { try {
@@ -814,6 +868,8 @@ export async function ensureTvHydrated(
backdropPath: show.backdrop_path, backdropPath: show.backdrop_path,
status: show.status, status: show.status,
contentRating: extractTvContentRating(show), contentRating: extractTvContentRating(show),
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
}); });
upsertGenres(titleId, show.genres ?? []); upsertGenres(titleId, show.genres ?? []);
@@ -942,20 +998,12 @@ function readAvailability(
export async function getOrFetchTitle(id: string): Promise<{ export async function getOrFetchTitle(id: string): Promise<{
title: ResolvedTitle; title: ResolvedTitle;
seasons: Season[]; seasons: Season[];
needsHydration: boolean;
availability: AvailabilityOffer[]; availability: AvailabilityOffer[];
cast: CastMember[]; cast: CastMember[];
} | null> { } | null> {
let title = db.select().from(titles).where(eq(titles.id, id)).get(); let title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return null; 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 this is a shell movie title, fetch full details now (movies are fast)
if (title.type === "movie" && !title.lastFetchedAt) { if (title.type === "movie" && !title.lastFetchedAt) {
try { try {
@@ -972,6 +1020,9 @@ export async function getOrFetchTitle(id: string): Promise<{
voteCount: movie.vote_count, voteCount: movie.vote_count,
status: movie.status, status: movie.status,
contentRating: extractMovieContentRating(movie), contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
}); });
upsertGenres(id, movie.genres ?? []); 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 // Read enrichment data, then backfill anything missing
let availability = readAvailability(title.id, title.title); let availability = readAvailability(title.id, title.title);
@@ -1028,6 +1086,10 @@ export async function getOrFetchTitle(id: string): Promise<{
voteCount: title.voteCount, voteCount: title.voteCount,
status: title.status, status: title.status,
contentRating: title.contentRating, contentRating: title.contentRating,
imdbId: title.imdbId,
tvdbId: title.tvdbId,
originalLanguage: title.originalLanguage,
runtimeMinutes: title.runtimeMinutes,
colorPalette: palette, colorPalette: palette,
trailerVideoKey: title.trailerVideoKey, trailerVideoKey: title.trailerVideoKey,
genres: titleGenreRows.map((r) => r.name), genres: titleGenreRows.map((r) => r.name),
@@ -1036,7 +1098,6 @@ export async function getOrFetchTitle(id: string): Promise<{
return { return {
title: resolvedTitle, title: resolvedTitle,
seasons: titleSeasons, seasons: titleSeasons,
needsHydration: needsTvHydration,
availability, availability,
cast, cast,
}; };
@@ -1175,6 +1236,128 @@ async function syncTitleArt(
} }
} }
function delay(ms: number) { // ─── Browse batch upsert ─────────────────────────────────────
return new Promise((resolve) => setTimeout(resolve, ms));
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; 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(); .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( export function getUserStatusesByTitleIds(
userId: string, userId: string,
titleIds: string[], titleIds: string[],
@@ -464,16 +431,15 @@ export function getUserStatusesByTitleIds(
return result; return result;
} }
export function getEpisodeProgressByTmdbIds( export function getEpisodeProgressByTitleIds(
userId: string, userId: string,
tmdbIds: { tmdbId: number; type: string }[], titleIds: string[],
): Record<string, { watched: number; total: number }> { ): Record<string, { watched: number; total: number }> {
const tvIds = tmdbIds.filter((t) => t.type === "tv").map((t) => t.tmdbId); if (titleIds.length === 0) return {};
if (tvIds.length === 0) return {};
const rows = db const rows = db
.select({ .select({
tmdbId: titles.tmdbId, titleId: titles.id,
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as( totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as(
"totalEpisodes", "totalEpisodes",
), ),
@@ -492,14 +458,14 @@ export function getEpisodeProgressByTmdbIds(
eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.userId, userId),
), ),
) )
.where(and(inArray(titles.tmdbId, tvIds), eq(titles.type, "tv"))) .where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
.groupBy(titles.tmdbId) .groupBy(titles.id)
.all(); .all();
const result: Record<string, { watched: number; total: number }> = {}; const result: Record<string, { watched: number; total: number }> = {};
for (const row of rows) { for (const row of rows) {
if (row.watchedEpisodes > 0) { if (row.watchedEpisodes > 0) {
result[`${row.tmdbId}-tv`] = { result[row.titleId] = {
watched: row.watchedEpisodes, watched: row.watchedEpisodes,
total: row.totalEpisodes, 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"), voteCount: int("voteCount"),
status: text("status"), status: text("status"),
contentRating: text("contentRating"), contentRating: text("contentRating"),
imdbId: text("imdbId"),
originalLanguage: text("originalLanguage"),
runtimeMinutes: int("runtimeMinutes"),
colorPalette: text("colorPalette"), colorPalette: text("colorPalette"),
trailerVideoKey: text("trailerVideoKey"), trailerVideoKey: text("trailerVideoKey"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }), lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
}, },
(table) => [ (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_releaseDate").on(table.type, table.releaseDate),
index("titles_type_firstAirDate").on(table.type, table.firstAirDate), index("titles_type_firstAirDate").on(table.type, table.firstAirDate),
index("titles_lastFetchedAt").on(table.lastFetchedAt), index("titles_lastFetchedAt").on(table.lastFetchedAt),