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