mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
feat(core): add pagination to explore, search, person, and library endpoints
Convert trending, search, people.detail, and dashboard.library to page-based responses (`page` / `totalPages`) so clients can load results incrementally. Update `@sofa/core` discovery and person services to accept a `page` / `limit` input and slice results accordingly. Add a new DB migration to persist the data needed to back paginated filmography queries. On the web, introduce a `useInfiniteScroll` hook and wire up `useInfiniteQuery` in the explore, person detail, and filterable title row components with an intersection-observer sentinel. On native, switch the same screens from `useQuery` to `useInfiniteQuery` with `onEndReached` / `ListFooterComponent` loading indicators. Also replace remaining `FlatList` usages with `FlashList` in the home and title detail screens.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
|
|
||||||
@@ -11,8 +11,13 @@ import { orpc } from "@/lib/orpc";
|
|||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
|
||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
const trending = useQuery(
|
const trending = useInfiniteQuery(
|
||||||
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
|
orpc.explore.trending.infiniteOptions({
|
||||||
|
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const popularMovies = useQuery(
|
const popularMovies = useQuery(
|
||||||
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||||
@@ -37,7 +42,28 @@ export default function ExploreScreen() {
|
|||||||
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const heroItem = trending.data?.hero;
|
const heroItem = trending.data?.pages[0]?.hero ?? null;
|
||||||
|
|
||||||
|
const trendingItems = useMemo(
|
||||||
|
() => trending.data?.pages.flatMap((p) => p.items) ?? [],
|
||||||
|
[trending.data?.pages],
|
||||||
|
);
|
||||||
|
const trendingStatuses = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...(trending.data?.pages.map((p) => p.userStatuses) ?? []),
|
||||||
|
) as Record<string, "watchlist" | "in_progress" | "completed">,
|
||||||
|
[trending.data?.pages],
|
||||||
|
);
|
||||||
|
const trendingProgress = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...(trending.data?.pages.map((p) => p.episodeProgress) ?? []),
|
||||||
|
) as Record<string, { watched: number; total: number }>,
|
||||||
|
[trending.data?.pages],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -68,9 +94,9 @@ export default function ExploreScreen() {
|
|||||||
title="Trending Today"
|
title="Trending Today"
|
||||||
icon={IconFlame}
|
icon={IconFlame}
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
defaultItems={trending.data?.items ?? []}
|
defaultItems={trendingItems}
|
||||||
defaultUserStatuses={trending.data?.userStatuses ?? {}}
|
defaultUserStatuses={trendingStatuses}
|
||||||
defaultEpisodeProgress={trending.data?.episodeProgress ?? {}}
|
defaultEpisodeProgress={trendingProgress}
|
||||||
isLoading={trending.isPending}
|
isLoading={trending.isPending}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import {
|
import {
|
||||||
IconBooks,
|
IconBooks,
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
@@ -6,7 +7,7 @@ import {
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { FlatList, RefreshControl, ScrollView, View } from "react-native";
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
|
|
||||||
import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card";
|
import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card";
|
||||||
@@ -18,6 +19,10 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
|
||||||
|
const horizontalListStyle = { overflow: "visible" as const };
|
||||||
|
const statsListContentStyle = { gap: 12, paddingHorizontal: 16 };
|
||||||
|
const continueWatchingContentStyle = { gap: 12, paddingHorizontal: 16 };
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
export default function DashboardScreen() {
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
authClient.useSession();
|
authClient.useSession();
|
||||||
@@ -26,7 +31,7 @@ export default function DashboardScreen() {
|
|||||||
const continueWatching = useQuery(
|
const continueWatching = useQuery(
|
||||||
orpc.dashboard.continueWatching.queryOptions(),
|
orpc.dashboard.continueWatching.queryOptions(),
|
||||||
);
|
);
|
||||||
const library = useQuery(orpc.dashboard.library.queryOptions());
|
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
|
||||||
const recommendations = useQuery(
|
const recommendations = useQuery(
|
||||||
orpc.dashboard.recommendations.queryOptions(),
|
orpc.dashboard.recommendations.queryOptions(),
|
||||||
);
|
);
|
||||||
@@ -52,6 +57,21 @@ export default function DashboardScreen() {
|
|||||||
[stats.data],
|
[stats.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderStatItem = useCallback(
|
||||||
|
({ item }: { item: (typeof statsData)[number] }) => (
|
||||||
|
<StatsCard label={item.label} value={item.value} />
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const renderContinueWatchingItem = useCallback(
|
||||||
|
({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: NonNullable<typeof continueWatching.data>["items"][number];
|
||||||
|
}) => <ContinueWatchingCard item={item} />,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
@@ -72,16 +92,14 @@ export default function DashboardScreen() {
|
|||||||
<View className="gap-8">
|
<View className="gap-8">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
<FlatList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={statsData}
|
data={statsData}
|
||||||
keyExtractor={(item) => item.label}
|
keyExtractor={(item) => item.label}
|
||||||
renderItem={({ item }) => (
|
renderItem={renderStatItem}
|
||||||
<StatsCard label={item.label} value={item.value} />
|
contentContainerStyle={statsListContentStyle}
|
||||||
)}
|
style={horizontalListStyle}
|
||||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
|
||||||
style={{ overflow: "visible" }}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -91,14 +109,14 @@ export default function DashboardScreen() {
|
|||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} />
|
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} />
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={continueWatching.data?.items ?? []}
|
data={continueWatching.data?.items ?? []}
|
||||||
keyExtractor={(item) => item.title.id}
|
keyExtractor={(item) => item.title.id}
|
||||||
renderItem={({ item }) => <ContinueWatchingCard item={item} />}
|
renderItem={renderContinueWatchingItem}
|
||||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
contentContainerStyle={continueWatchingContentStyle}
|
||||||
style={{ overflow: "visible" }}
|
style={horizontalListStyle}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import {
|
||||||
|
skipToken,
|
||||||
|
useInfiniteQuery,
|
||||||
|
useMutation,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { ActivityIndicator, View } from "react-native";
|
||||||
import Animated, { FadeIn } from "react-native-reanimated";
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
|
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
|
||||||
import {
|
import {
|
||||||
@@ -22,9 +26,16 @@ export default function SearchScreen() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const debouncedQuery = useDebounce(query.trim(), 300);
|
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||||
|
|
||||||
const searchResults = useQuery({
|
const searchResults = useInfiniteQuery({
|
||||||
...orpc.search.queryOptions({ input: { query: debouncedQuery } }),
|
...orpc.search.infiniteOptions({
|
||||||
enabled: debouncedQuery.length > 0,
|
input:
|
||||||
|
debouncedQuery.length > 0
|
||||||
|
? (pageParam: number) => ({ query: debouncedQuery, page: pageParam })
|
||||||
|
: skipToken,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track which item is currently being resolved/added
|
// Track which item is currently being resolved/added
|
||||||
@@ -98,15 +109,17 @@ export default function SearchScreen() {
|
|||||||
// Memoize mapped results to maintain stable references
|
// Memoize mapped results to maintain stable references
|
||||||
const allResults = useMemo<SearchResultItem[]>(
|
const allResults = useMemo<SearchResultItem[]>(
|
||||||
() =>
|
() =>
|
||||||
searchResults.data?.results?.map((r) => ({
|
searchResults.data?.pages.flatMap((page) =>
|
||||||
tmdbId: r.tmdbId,
|
page.results.map((r) => ({
|
||||||
title: r.title,
|
tmdbId: r.tmdbId,
|
||||||
type: r.type,
|
title: r.title,
|
||||||
posterPath: r.posterPath,
|
type: r.type,
|
||||||
profilePath: r.profilePath,
|
posterPath: r.posterPath,
|
||||||
releaseDate: r.releaseDate,
|
profilePath: r.profilePath,
|
||||||
})) ?? [],
|
releaseDate: r.releaseDate,
|
||||||
[searchResults.data?.results],
|
})),
|
||||||
|
) ?? [],
|
||||||
|
[searchResults.data?.pages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
@@ -161,6 +174,22 @@ export default function SearchScreen() {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
onEndReached={() => {
|
||||||
|
if (
|
||||||
|
searchResults.hasNextPage &&
|
||||||
|
!searchResults.isFetchingNextPage
|
||||||
|
) {
|
||||||
|
searchResults.fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListFooterComponent={
|
||||||
|
searchResults.isFetchingNextPage ? (
|
||||||
|
<View className="items-center py-4">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ import {
|
|||||||
IconMovie,
|
IconMovie,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
@@ -59,12 +64,35 @@ export default function PersonDetailScreen() {
|
|||||||
|
|
||||||
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
|
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
|
||||||
|
|
||||||
const { data, isPending, isError } = useQuery(
|
const {
|
||||||
orpc.people.detail.queryOptions({ input: { id } }),
|
data,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery(
|
||||||
|
orpc.people.detail.infiniteOptions({
|
||||||
|
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const person = data?.person;
|
const person = data?.pages[0]?.person;
|
||||||
const filmography = data?.filmography ?? [];
|
const filmography = useMemo(
|
||||||
|
() => data?.pages.flatMap((p) => p.filmography) ?? [],
|
||||||
|
[data?.pages],
|
||||||
|
);
|
||||||
|
const userStatuses = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...(data?.pages.map((p) => p.userStatuses) ?? []),
|
||||||
|
) as Record<string, "watchlist" | "in_progress" | "completed">,
|
||||||
|
[data?.pages],
|
||||||
|
);
|
||||||
|
|
||||||
const personName = person?.name;
|
const personName = person?.name;
|
||||||
const personProfilePath = person?.profilePath ?? null;
|
const personProfilePath = person?.profilePath ?? null;
|
||||||
@@ -94,7 +122,7 @@ export default function PersonDetailScreen() {
|
|||||||
posterThumbHash={credit.posterThumbHash}
|
posterThumbHash={credit.posterThumbHash}
|
||||||
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
||||||
voteAverage={credit.voteAverage}
|
voteAverage={credit.voteAverage}
|
||||||
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
|
userStatus={userStatuses[credit.titleId] ?? null}
|
||||||
width={undefined}
|
width={undefined}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onQuickAdd={handleQuickAdd}
|
onQuickAdd={handleQuickAdd}
|
||||||
@@ -102,7 +130,7 @@ export default function PersonDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
[columnWidth, data?.userStatuses, handlePress, handleQuickAdd, addingKey],
|
[columnWidth, userStatuses, handlePress, handleQuickAdd, addingKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
@@ -285,6 +313,19 @@ export default function PersonDetailScreen() {
|
|||||||
paddingHorizontal: FILMOGRAPHY_PADDING,
|
paddingHorizontal: FILMOGRAPHY_PADDING,
|
||||||
}}
|
}}
|
||||||
ListHeaderComponent={listHeader}
|
ListHeaderComponent={listHeader}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className="items-center py-4">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import {
|
import {
|
||||||
IconBrandAppstore,
|
IconBrandAppstore,
|
||||||
IconBrandGooglePlay,
|
IconBrandGooglePlay,
|
||||||
@@ -14,8 +15,8 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
import * as WebBrowser from "expo-web-browser";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { FlatList, Platform, Pressable, ScrollView, View } from "react-native";
|
import { Platform, Pressable, ScrollView, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
@@ -40,6 +41,9 @@ import { queryClient } from "@/lib/query-client";
|
|||||||
import { addRecentlyViewed } from "@/lib/recently-viewed";
|
import { addRecentlyViewed } from "@/lib/recently-viewed";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
const castListContentStyle = { gap: 12, paddingHorizontal: 16 };
|
||||||
|
const castListStyle = { overflow: "visible" as const };
|
||||||
|
|
||||||
export default function TitleDetailScreen() {
|
export default function TitleDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -175,6 +179,10 @@ export default function TitleDetailScreen() {
|
|||||||
})),
|
})),
|
||||||
[recommendations.data],
|
[recommendations.data],
|
||||||
);
|
);
|
||||||
|
const renderCastItem = useCallback(
|
||||||
|
({ item }: { item: (typeof cast)[number] }) => <CastCard person={item} />,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const hydratedTitleId = useRef<string | null>(null);
|
const hydratedTitleId = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -597,14 +605,14 @@ export default function TitleDetailScreen() {
|
|||||||
iconColor={titleAccent}
|
iconColor={titleAccent}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={cast}
|
data={cast}
|
||||||
keyExtractor={(item, index) => `${item.id}-${index}`}
|
keyExtractor={(item, index) => `${item.id}-${index}`}
|
||||||
renderItem={({ item }) => <CastCard person={item} />}
|
renderItem={renderCastItem}
|
||||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
contentContainerStyle={castListContentStyle}
|
||||||
style={{ overflow: "visible" }}
|
style={castListStyle}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FlatList } from "react-native";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
||||||
import { usePosterActions } from "@/hooks/use-poster-actions";
|
import { usePosterActions } from "@/hooks/use-poster-actions";
|
||||||
@@ -28,10 +29,34 @@ export function HorizontalPosterRow({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
|
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
|
||||||
|
const keyExtractor = useCallback(
|
||||||
|
(item: PosterRowItem) => item.id ?? `${item.tmdbId}-${item.type}`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
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}
|
||||||
|
posterThumbHash={item.posterThumbHash}
|
||||||
|
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||||
|
voteAverage={item.voteAverage}
|
||||||
|
userStatus={item.userStatus}
|
||||||
|
episodeProgress={item.episodeProgress}
|
||||||
|
onPress={handlePress}
|
||||||
|
onQuickAdd={handleQuickAdd}
|
||||||
|
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[addingKey, handlePress, handleQuickAdd],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={[1, 2, 3, 4]}
|
data={[1, 2, 3, 4]}
|
||||||
@@ -44,28 +69,12 @@ export function HorizontalPosterRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={items}
|
data={items}
|
||||||
keyExtractor={(item) => item.id ?? `${item.tmdbId}-${item.type}`}
|
keyExtractor={keyExtractor}
|
||||||
renderItem={({ item }) => (
|
renderItem={renderItem}
|
||||||
<PosterCard
|
|
||||||
id={item.id}
|
|
||||||
tmdbId={item.tmdbId}
|
|
||||||
title={item.title}
|
|
||||||
type={item.type as "movie" | "tv"}
|
|
||||||
posterPath={item.posterPath}
|
|
||||||
posterThumbHash={item.posterThumbHash}
|
|
||||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
|
||||||
voteAverage={item.voteAverage}
|
|
||||||
userStatus={item.userStatus}
|
|
||||||
episodeProgress={item.episodeProgress}
|
|
||||||
onPress={handlePress}
|
|
||||||
onQuickAdd={handleQuickAdd}
|
|
||||||
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
contentContainerStyle={listContentStyle}
|
contentContainerStyle={listContentStyle}
|
||||||
style={listStyle}
|
style={listStyle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Icon } from "@tabler/icons-react-native";
|
import type { Icon } from "@tabler/icons-react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -42,23 +42,48 @@ export function FilterableTitleRow({
|
|||||||
}) {
|
}) {
|
||||||
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||||
|
|
||||||
const discover = useQuery({
|
const discover = useInfiniteQuery({
|
||||||
...orpc.discover.queryOptions({
|
...orpc.discover.infiniteOptions({
|
||||||
input: { type: mediaType, genreId: selectedGenre ?? 0 },
|
input:
|
||||||
|
selectedGenre != null
|
||||||
|
? (pageParam: number) => ({
|
||||||
|
type: mediaType,
|
||||||
|
genreId: selectedGenre,
|
||||||
|
page: pageParam,
|
||||||
|
})
|
||||||
|
: skipToken,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
}),
|
}),
|
||||||
enabled: selectedGenre !== null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawItems =
|
const discoverItems = useMemo(
|
||||||
selectedGenre === null ? defaultItems : (discover.data?.items ?? []);
|
() => discover.data?.pages.flatMap((p) => p.items) ?? [],
|
||||||
|
[discover.data?.pages],
|
||||||
|
);
|
||||||
|
const discoverStatuses = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...(discover.data?.pages.map((p) => p.userStatuses) ?? []),
|
||||||
|
) as Record<string, TitleStatus>,
|
||||||
|
[discover.data?.pages],
|
||||||
|
);
|
||||||
|
const discoverProgress = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...(discover.data?.pages.map((p) => p.episodeProgress) ?? []),
|
||||||
|
) as Record<string, { watched: number; total: number }>,
|
||||||
|
[discover.data?.pages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawItems = selectedGenre === null ? defaultItems : discoverItems;
|
||||||
const userStatuses =
|
const userStatuses =
|
||||||
selectedGenre === null
|
selectedGenre === null ? defaultUserStatuses : discoverStatuses;
|
||||||
? defaultUserStatuses
|
|
||||||
: (discover.data?.userStatuses ?? {});
|
|
||||||
const episodeProgress =
|
const episodeProgress =
|
||||||
selectedGenre === null
|
selectedGenre === null ? defaultEpisodeProgress : discoverProgress;
|
||||||
? defaultEpisodeProgress
|
|
||||||
: (discover.data?.episodeProgress ?? {});
|
|
||||||
const showLoading =
|
const showLoading =
|
||||||
isLoading || (selectedGenre !== null && discover.isPending);
|
isLoading || (selectedGenre !== null && discover.isPending);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
getContinueWatchingFeed,
|
getContinueWatchingFeed,
|
||||||
getNewAvailableFeed,
|
getLibraryFeed,
|
||||||
getRecommendationsFeed,
|
getRecommendationsFeed,
|
||||||
getUserStats,
|
getUserStats,
|
||||||
getWatchCount,
|
getWatchCount,
|
||||||
@@ -44,9 +44,14 @@ export const continueWatching = os.dashboard.continueWatching
|
|||||||
|
|
||||||
export const library = os.dashboard.library
|
export const library = os.dashboard.library
|
||||||
.use(authed)
|
.use(authed)
|
||||||
.handler(({ context }) => {
|
.handler(({ input, context }) => {
|
||||||
const feed = getNewAvailableFeed(context.user.id);
|
const {
|
||||||
const items = feed.slice(0, 10).map((t) => ({
|
items: feed,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
totalResults,
|
||||||
|
} = getLibraryFeed(context.user.id, input.page, input.limit);
|
||||||
|
const items = feed.map((t) => ({
|
||||||
id: t.titleId,
|
id: t.titleId,
|
||||||
tmdbId: t.tmdbId,
|
tmdbId: t.tmdbId,
|
||||||
type: t.type,
|
type: t.type,
|
||||||
@@ -58,7 +63,7 @@ export const library = os.dashboard.library
|
|||||||
voteAverage: t.voteAverage,
|
voteAverage: t.voteAverage,
|
||||||
userStatus: t.userStatus,
|
userStatus: t.userStatus,
|
||||||
}));
|
}));
|
||||||
return { items };
|
return { items, page, totalPages, totalResults };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const recommendations = os.dashboard.recommendations
|
export const recommendations = os.dashboard.recommendations
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ export const discover = os.discover
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await discoverTmdb(input.type, {
|
const results = await discoverTmdb(
|
||||||
sort_by: "popularity.desc",
|
input.type,
|
||||||
"vote_count.gte": "50",
|
{
|
||||||
with_genres: String(input.genreId),
|
sort_by: "popularity.desc",
|
||||||
});
|
"vote_count.gte": "50",
|
||||||
|
with_genres: String(input.genreId),
|
||||||
|
},
|
||||||
|
input.page,
|
||||||
|
);
|
||||||
|
|
||||||
type DiscoverResult = NonNullable<typeof results.results>[number] & {
|
type DiscoverResult = NonNullable<typeof results.results>[number] & {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -61,5 +65,12 @@ export const discover = os.discover
|
|||||||
]
|
]
|
||||||
: [{}, {}];
|
: [{}, {}];
|
||||||
|
|
||||||
return { items, userStatuses, episodeProgress };
|
return {
|
||||||
|
items,
|
||||||
|
userStatuses,
|
||||||
|
episodeProgress,
|
||||||
|
page: results.page ?? input.page,
|
||||||
|
totalPages: results.total_pages ?? 1,
|
||||||
|
totalResults: results.total_results ?? 0,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const trending = os.explore.trending
|
|||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
requireTmdb();
|
requireTmdb();
|
||||||
|
|
||||||
const data = await getTrending(input.type, "day");
|
const data = await getTrending(input.type, "day", input.page);
|
||||||
const results = (data.results ?? []) as Record<string, unknown>[];
|
const results = (data.results ?? []) as Record<string, unknown>[];
|
||||||
|
|
||||||
const baseItems = results
|
const baseItems = results
|
||||||
@@ -83,7 +83,15 @@ export const trending = os.explore.trending
|
|||||||
]
|
]
|
||||||
: [{}, {}];
|
: [{}, {}];
|
||||||
|
|
||||||
return { items, hero, userStatuses, episodeProgress };
|
return {
|
||||||
|
items,
|
||||||
|
hero,
|
||||||
|
userStatuses,
|
||||||
|
episodeProgress,
|
||||||
|
page: (data as { page?: number }).page ?? input.page,
|
||||||
|
totalPages: (data as { total_pages?: number }).total_pages ?? 1,
|
||||||
|
totalResults: (data as { total_results?: number }).total_results ?? 0,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const popular = os.explore.popular
|
export const popular = os.explore.popular
|
||||||
@@ -91,7 +99,7 @@ export const popular = os.explore.popular
|
|||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
requireTmdb();
|
requireTmdb();
|
||||||
|
|
||||||
const data = await getPopular(input.type);
|
const data = await getPopular(input.type, input.page);
|
||||||
const baseItems = ((data.results ?? []) as Record<string, unknown>[])
|
const baseItems = ((data.results ?? []) as Record<string, unknown>[])
|
||||||
.filter((r) => r.poster_path)
|
.filter((r) => r.poster_path)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
@@ -118,7 +126,14 @@ export const popular = os.explore.popular
|
|||||||
]
|
]
|
||||||
: [{}, {}];
|
: [{}, {}];
|
||||||
|
|
||||||
return { items, userStatuses, episodeProgress };
|
return {
|
||||||
|
items,
|
||||||
|
userStatuses,
|
||||||
|
episodeProgress,
|
||||||
|
page: data.page ?? input.page,
|
||||||
|
totalPages: data.total_pages ?? 1,
|
||||||
|
totalResults: data.total_results ?? 0,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const genres = os.explore.genres
|
export const genres = os.explore.genres
|
||||||
|
|||||||
@@ -15,13 +15,24 @@ export const detail = os.people.detail
|
|||||||
if (!person)
|
if (!person)
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
|
||||||
|
|
||||||
const filmography = await fetchFullFilmography(person.id);
|
const allCredits = await fetchFullFilmography(person.id);
|
||||||
|
|
||||||
|
const start = (input.page - 1) * input.limit;
|
||||||
|
const pageCredits = allCredits.slice(start, start + input.limit);
|
||||||
|
|
||||||
const userStatuses = getUserStatusesByTitleIds(
|
const userStatuses = getUserStatusesByTitleIds(
|
||||||
context.user.id,
|
context.user.id,
|
||||||
filmography.map((c) => c.titleId),
|
pageCredits.map((c) => c.titleId),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { person, filmography, userStatuses };
|
return {
|
||||||
|
person,
|
||||||
|
filmography: pageCredits,
|
||||||
|
userStatuses,
|
||||||
|
page: input.page,
|
||||||
|
totalPages: Math.max(1, Math.ceil(allCredits.length / input.limit)),
|
||||||
|
totalResults: allCredits.length,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resolve = os.people.resolve
|
export const resolve = os.people.resolve
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
|
|||||||
|
|
||||||
const query = input.query.trim();
|
const query = input.query.trim();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return { results: [] };
|
return { results: [], page: 1, totalPages: 0, totalResults: 0 };
|
||||||
}
|
}
|
||||||
const type = input.type ?? null;
|
const type = input.type ?? null;
|
||||||
|
|
||||||
if (type === "person") {
|
if (type === "person") {
|
||||||
const personResults = await searchPerson(query);
|
const personResults = await searchPerson(query, input.page);
|
||||||
return {
|
return {
|
||||||
results: (personResults.results ?? []).map((r) => ({
|
results: (personResults.results ?? []).map((r) => ({
|
||||||
tmdbId: r.id,
|
tmdbId: r.id,
|
||||||
@@ -43,15 +43,18 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
|
|||||||
.map((k) => k.title ?? (k as { name?: string }).name)
|
.map((k) => k.title ?? (k as { name?: string }).name)
|
||||||
.filter((s): s is string => !!s) as string[]) ?? null,
|
.filter((s): s is string => !!s) as string[]) ?? null,
|
||||||
})),
|
})),
|
||||||
|
page: personResults.page ?? input.page,
|
||||||
|
totalPages: personResults.total_pages ?? 1,
|
||||||
|
totalResults: personResults.total_results ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw =
|
const raw =
|
||||||
type === "movie"
|
type === "movie"
|
||||||
? await searchMovies(query)
|
? await searchMovies(query, input.page)
|
||||||
: type === "tv"
|
: type === "tv"
|
||||||
? await searchTv(query)
|
? await searchTv(query, input.page)
|
||||||
: await searchMulti(query);
|
: await searchMulti(query, input.page);
|
||||||
|
|
||||||
type SearchResult = {
|
type SearchResult = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -105,5 +108,10 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
|
|||||||
})
|
})
|
||||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
|
||||||
return { results: mapped };
|
return {
|
||||||
|
results: mapped,
|
||||||
|
page: raw.page ?? input.page,
|
||||||
|
totalPages: raw.total_pages ?? 1,
|
||||||
|
totalResults: raw.total_results ?? 0,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import { IconBooks } from "@tabler/icons-react";
|
import { IconBooks } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import { FeedSection } from "./feed-section";
|
import { FeedSection } from "./feed-section";
|
||||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||||
|
|
||||||
export function LibrarySection() {
|
export function LibrarySection() {
|
||||||
const { data, isPending } = useQuery(orpc.dashboard.library.queryOptions());
|
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
|
useInfiniteQuery(
|
||||||
|
orpc.dashboard.library.infiniteOptions({
|
||||||
|
input: (pageParam: number) => ({ page: pageParam }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentinelRef = useInfiniteScroll({
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
});
|
||||||
|
|
||||||
if (isPending) return <TitleGridSectionSkeleton />;
|
if (isPending) return <TitleGridSectionSkeleton />;
|
||||||
|
|
||||||
const items = data?.items ?? [];
|
const items = data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,6 +33,8 @@ export function LibrarySection() {
|
|||||||
icon={<IconBooks className="size-5 text-primary" />}
|
icon={<IconBooks className="size-5 text-primary" />}
|
||||||
>
|
>
|
||||||
<TitleGrid items={items} />
|
<TitleGrid items={items} />
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{isFetchingNextPage && <TitleGridSectionSkeleton />}
|
||||||
</FeedSection>
|
</FeedSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import { FilterableTitleRow } from "./filterable-title-row";
|
import { FilterableTitleRow } from "./filterable-title-row";
|
||||||
@@ -33,14 +34,32 @@ function ExploreSkeletons() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeMaps<T>(
|
||||||
|
...maps: (Record<string, T> | undefined)[]
|
||||||
|
): Record<string, T> {
|
||||||
|
return Object.assign({}, ...maps.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
export function ExploreClient() {
|
export function ExploreClient() {
|
||||||
const { data: trending, isPending: trendingPending } = useQuery(
|
const {
|
||||||
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
|
data: trendingData,
|
||||||
|
isPending: trendingPending,
|
||||||
|
fetchNextPage: fetchNextTrending,
|
||||||
|
hasNextPage: hasNextTrending,
|
||||||
|
isFetchingNextPage: isFetchingNextTrending,
|
||||||
|
} = useInfiniteQuery(
|
||||||
|
orpc.explore.trending.infiniteOptions({
|
||||||
|
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const { data: popularMovies, isPending: moviesPending } = useQuery(
|
|
||||||
|
const { data: popularMoviesData, isPending: moviesPending } = useQuery(
|
||||||
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||||
);
|
);
|
||||||
const { data: popularTv, isPending: tvPending } = useQuery(
|
const { data: popularTvData, isPending: tvPending } = useQuery(
|
||||||
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
|
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
|
||||||
);
|
);
|
||||||
const { data: movieGenreData } = useQuery(
|
const { data: movieGenreData } = useQuery(
|
||||||
@@ -52,46 +71,60 @@ export function ExploreClient() {
|
|||||||
|
|
||||||
const isPending = trendingPending || moviesPending || tvPending;
|
const isPending = trendingPending || moviesPending || tvPending;
|
||||||
|
|
||||||
|
const trendingItems = useMemo(
|
||||||
|
() => trendingData?.pages.flatMap((p) => p.items) ?? [],
|
||||||
|
[trendingData?.pages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hero = trendingData?.pages[0]?.hero ?? null;
|
||||||
|
|
||||||
if (isPending) return <ExploreSkeletons />;
|
if (isPending) return <ExploreSkeletons />;
|
||||||
|
|
||||||
// Merge user statuses and episode progress from all responses
|
// Merge user statuses and episode progress from all responses
|
||||||
const userStatuses = {
|
const userStatuses = mergeMaps(
|
||||||
...trending?.userStatuses,
|
...(trendingData?.pages.map((p) => p.userStatuses) ?? []),
|
||||||
...popularMovies?.userStatuses,
|
popularMoviesData?.userStatuses,
|
||||||
...popularTv?.userStatuses,
|
popularTvData?.userStatuses,
|
||||||
};
|
);
|
||||||
const episodeProgress = {
|
const episodeProgress = mergeMaps(
|
||||||
...trending?.episodeProgress,
|
...(trendingData?.pages.map((p) => p.episodeProgress) ?? []),
|
||||||
...popularMovies?.episodeProgress,
|
popularMoviesData?.episodeProgress,
|
||||||
...popularTv?.episodeProgress,
|
popularTvData?.episodeProgress,
|
||||||
};
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{trending?.hero && (
|
{hero && (
|
||||||
<HeroBanner
|
<HeroBanner
|
||||||
tmdbId={trending.hero.tmdbId}
|
tmdbId={hero.tmdbId}
|
||||||
type={trending.hero.type}
|
type={hero.type}
|
||||||
title={trending.hero.title}
|
title={hero.title}
|
||||||
overview={trending.hero.overview}
|
overview={hero.overview}
|
||||||
backdropPath={trending.hero.backdropPath}
|
backdropPath={hero.backdropPath}
|
||||||
voteAverage={trending.hero.voteAverage}
|
voteAverage={hero.voteAverage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TitleRow
|
<div>
|
||||||
heading="Trending Today"
|
<TitleRow
|
||||||
icon={<IconFlame aria-hidden={true} className="size-5 text-primary" />}
|
heading="Trending Today"
|
||||||
items={(trending?.items ?? []).slice(0, 20)}
|
icon={
|
||||||
userStatuses={userStatuses}
|
<IconFlame aria-hidden={true} className="size-5 text-primary" />
|
||||||
episodeProgress={episodeProgress}
|
}
|
||||||
/>
|
items={trendingItems}
|
||||||
|
userStatuses={userStatuses}
|
||||||
|
episodeProgress={episodeProgress}
|
||||||
|
onEndReached={fetchNextTrending}
|
||||||
|
hasNextPage={hasNextTrending}
|
||||||
|
isFetchingNextPage={isFetchingNextTrending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FilterableTitleRow
|
<FilterableTitleRow
|
||||||
heading="Popular Movies"
|
heading="Popular Movies"
|
||||||
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
|
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
defaultItems={(popularMovies?.items ?? []).slice(0, 20)}
|
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
|
||||||
genres={movieGenreData?.genres ?? []}
|
genres={movieGenreData?.genres ?? []}
|
||||||
userStatuses={userStatuses}
|
userStatuses={userStatuses}
|
||||||
episodeProgress={episodeProgress}
|
episodeProgress={episodeProgress}
|
||||||
@@ -103,7 +136,7 @@ export function ExploreClient() {
|
|||||||
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
|
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
|
||||||
}
|
}
|
||||||
mediaType="tv"
|
mediaType="tv"
|
||||||
defaultItems={(popularTv?.items ?? []).slice(0, 20)}
|
defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
|
||||||
genres={tvGenreData?.genres ?? []}
|
genres={tvGenreData?.genres ?? []}
|
||||||
userStatuses={userStatuses}
|
userStatuses={userStatuses}
|
||||||
episodeProgress={episodeProgress}
|
episodeProgress={episodeProgress}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { skipToken, useQuery } from "@tanstack/react-query";
|
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
|
||||||
interface Genre {
|
interface Genre {
|
||||||
@@ -43,25 +44,56 @@ export function FilterableTitleRow({
|
|||||||
episodeProgress: initialProgress = {},
|
episodeProgress: initialProgress = {},
|
||||||
}: FilterableTitleRowProps) {
|
}: FilterableTitleRowProps) {
|
||||||
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||||
const { data: discoverData, isLoading: isPending } = useQuery(
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
orpc.discover.queryOptions({
|
const {
|
||||||
|
data: discoverData,
|
||||||
|
isPending,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery(
|
||||||
|
orpc.discover.infiniteOptions({
|
||||||
input:
|
input:
|
||||||
selectedGenre != null
|
selectedGenre != null
|
||||||
? { type: mediaType, genreId: selectedGenre }
|
? (pageParam: number) => ({
|
||||||
|
type: mediaType,
|
||||||
|
genreId: selectedGenre,
|
||||||
|
page: pageParam,
|
||||||
|
})
|
||||||
: skipToken,
|
: skipToken,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const items =
|
const discoverItems = useMemo(
|
||||||
selectedGenre === null ? defaultItems : (discoverData?.items ?? []);
|
() => discoverData?.pages.flatMap((p) => p.items) ?? [],
|
||||||
|
[discoverData?.pages],
|
||||||
|
);
|
||||||
|
const discoverStatuses = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...(discoverData?.pages.map((p) => p.userStatuses) ?? []),
|
||||||
|
) as Record<string, TitleStatus>,
|
||||||
|
[discoverData?.pages],
|
||||||
|
);
|
||||||
|
const discoverProgress = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...(discoverData?.pages.map((p) => p.episodeProgress) ?? []),
|
||||||
|
) as Record<string, { watched: number; total: number }>,
|
||||||
|
[discoverData?.pages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = selectedGenre !== null && isPending;
|
||||||
|
const items = selectedGenre === null ? defaultItems : discoverItems;
|
||||||
const userStatuses =
|
const userStatuses =
|
||||||
selectedGenre === null
|
selectedGenre === null ? initialStatuses : discoverStatuses;
|
||||||
? initialStatuses
|
|
||||||
: (discoverData?.userStatuses ?? {});
|
|
||||||
const episodeProgress =
|
const episodeProgress =
|
||||||
selectedGenre === null
|
selectedGenre === null ? initialProgress : discoverProgress;
|
||||||
? initialProgress
|
|
||||||
: (discoverData?.episodeProgress ?? {});
|
|
||||||
|
|
||||||
function toggleGenre(genreId: number) {
|
function toggleGenre(genreId: number) {
|
||||||
setSelectedGenre(genreId === selectedGenre ? null : genreId);
|
setSelectedGenre(genreId === selectedGenre ? null : genreId);
|
||||||
@@ -98,7 +130,7 @@ export function FilterableTitleRow({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Loading skeleton */}
|
{/* Loading skeleton */}
|
||||||
{isPending && (
|
{isLoading && (
|
||||||
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
|
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -113,22 +145,38 @@ export function FilterableTitleRow({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!isPending && selectedGenre !== null && items.length === 0 && (
|
{!isLoading && selectedGenre !== null && items.length === 0 && (
|
||||||
<p className="py-8 text-center text-muted-foreground text-sm">
|
<p className="py-8 text-center text-muted-foreground text-sm">
|
||||||
No titles found for this genre.
|
No titles found for this genre.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Title cards */}
|
{/* Title cards */}
|
||||||
{!isPending && items.length > 0 && (
|
{!isLoading && items.length > 0 && (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
key={selectedGenre ?? "default"}
|
key={selectedGenre ?? "default"}
|
||||||
scrollFade
|
scrollFade
|
||||||
hideScrollbar
|
hideScrollbar
|
||||||
className="-mx-6 sm:-mx-2"
|
className="-mx-6 sm:-mx-2"
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
onScrollEnd={() => {
|
||||||
|
const viewport = scrollRef.current;
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedGenre === null ||
|
||||||
|
!viewport ||
|
||||||
|
!hasNextPage ||
|
||||||
|
isFetchingNextPage ||
|
||||||
|
!hasReachedHorizontalEnd(viewport)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNextPage();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-4 px-6 py-2 sm:px-2">
|
<div className="flex gap-4 px-6 py-2 sm:px-2">
|
||||||
{items.slice(0, 20).map((item: TitleRowItem, i: number) => (
|
{items.map((item: TitleRowItem, i: number) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.type}-${item.tmdbId}`}
|
key={`${item.type}-${item.tmdbId}`}
|
||||||
className="w-[140px] shrink-0 sm:w-[160px]"
|
className="w-[140px] shrink-0 sm:w-[160px]"
|
||||||
@@ -153,6 +201,11 @@ export function FilterableTitleRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<div className="flex shrink-0 items-center px-4">
|
||||||
|
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
import { TitleCard } from "@/components/title-card";
|
import { TitleCard } from "@/components/title-card";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
|
||||||
|
|
||||||
interface TitleRowItem {
|
interface TitleRowItem {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
@@ -18,6 +20,9 @@ interface TitleRowProps {
|
|||||||
items: TitleRowItem[];
|
items: TitleRowItem[];
|
||||||
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||||
episodeProgress?: Record<string, { watched: number; total: number }>;
|
episodeProgress?: Record<string, { watched: number; total: number }>;
|
||||||
|
onEndReached?: () => void;
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TitleRow({
|
export function TitleRow({
|
||||||
@@ -26,7 +31,12 @@ export function TitleRow({
|
|||||||
items,
|
items,
|
||||||
userStatuses,
|
userStatuses,
|
||||||
episodeProgress,
|
episodeProgress,
|
||||||
|
onEndReached,
|
||||||
|
hasNextPage = false,
|
||||||
|
isFetchingNextPage = false,
|
||||||
}: TitleRowProps) {
|
}: TitleRowProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +47,27 @@ export function TitleRow({
|
|||||||
{heading}
|
{heading}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea scrollFade hideScrollbar className="-mx-6 sm:-mx-2">
|
<ScrollArea
|
||||||
|
scrollFade
|
||||||
|
hideScrollbar
|
||||||
|
className="-mx-6 sm:-mx-2"
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
onScrollEnd={() => {
|
||||||
|
const viewport = scrollRef.current;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!viewport ||
|
||||||
|
!onEndReached ||
|
||||||
|
!hasNextPage ||
|
||||||
|
isFetchingNextPage ||
|
||||||
|
!hasReachedHorizontalEnd(viewport)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEndReached();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex gap-4 px-6 py-2 sm:px-2">
|
<div className="flex gap-4 px-6 py-2 sm:px-2">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -64,6 +94,11 @@ export function TitleRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<div className="flex shrink-0 items-center px-4">
|
||||||
|
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMemo } from "react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import { FilmographyGrid } from "./filmography-grid";
|
import { FilmographyGrid } from "./filmography-grid";
|
||||||
import { PersonHero } from "./person-hero";
|
import { PersonHero } from "./person-hero";
|
||||||
@@ -28,27 +29,50 @@ export function PersonDetailSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonDetailResponse {
|
|
||||||
person: ResolvedPerson;
|
|
||||||
filmography: PersonCredit[];
|
|
||||||
userStatuses: Record<string, "watchlist" | "in_progress" | "completed">;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PersonDetailClient({ id }: { id: string }) {
|
export function PersonDetailClient({ id }: { id: string }) {
|
||||||
const { data, isPending } = useQuery(
|
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
orpc.people.detail.queryOptions({ input: { id } }),
|
useInfiniteQuery(
|
||||||
|
orpc.people.detail.infiniteOptions({
|
||||||
|
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentinelRef = useInfiniteScroll({
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const person = data?.pages[0]?.person;
|
||||||
|
const filmography = useMemo(
|
||||||
|
() => data?.pages.flatMap((p) => p.filmography) ?? [],
|
||||||
|
[data?.pages],
|
||||||
|
);
|
||||||
|
const userStatuses = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
...(data?.pages.map((p) => p.userStatuses) ?? []),
|
||||||
|
) as Record<string, "watchlist" | "in_progress" | "completed">,
|
||||||
|
[data?.pages],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPending) return <PersonDetailSkeleton />;
|
if (isPending) return <PersonDetailSkeleton />;
|
||||||
if (!data) return null;
|
if (!person) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<PersonHero person={data.person} />
|
<PersonHero person={person} />
|
||||||
<FilmographyGrid
|
<FilmographyGrid credits={filmography} userStatuses={userStatuses} />
|
||||||
credits={data.filmography}
|
<div ref={sentinelRef} />
|
||||||
userStatuses={data.userStatuses}
|
{isFetchingNextPage && (
|
||||||
/>
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function hasReachedHorizontalEnd(element: HTMLElement, threshold = 24) {
|
||||||
|
if (element.scrollWidth <= element.clientWidth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
element.scrollLeft + element.clientWidth >= element.scrollWidth - threshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfiniteScroll({
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
}: {
|
||||||
|
fetchNextPage: () => void;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
}) {
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el || !hasNextPage) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
return sentinelRef;
|
||||||
|
}
|
||||||
@@ -7,8 +7,17 @@ import { orpc } from "@/lib/orpc/client";
|
|||||||
|
|
||||||
export const Route = createFileRoute("/_app/people/$id")({
|
export const Route = createFileRoute("/_app/people/$id")({
|
||||||
loader: async ({ params, context }) => {
|
loader: async ({ params, context }) => {
|
||||||
await context.queryClient.ensureQueryData(
|
await context.queryClient.ensureInfiniteQueryData(
|
||||||
orpc.people.detail.queryOptions({ input: { id: params.id } }),
|
orpc.people.detail.infiniteOptions({
|
||||||
|
input: (pageParam: number) => ({
|
||||||
|
id: params.id,
|
||||||
|
page: pageParam,
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
head: ({ loaderData: _loaderData, params: _params }) => {
|
head: ({ loaderData: _loaderData, params: _params }) => {
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"!node_modules",
|
"!node_modules",
|
||||||
"!dist",
|
"!dist",
|
||||||
"!build",
|
"!build",
|
||||||
"!drizzle",
|
"!packages/db/drizzle",
|
||||||
"!packages/tmdb/src/schema.d.ts",
|
"!packages/tmdb/src/schema.d.ts",
|
||||||
"!**/routeTree.gen.ts",
|
"!**/routeTree.gen.ts",
|
||||||
"!apps/native/.expo/types/**/*.ts",
|
"!apps/native/.expo/types/**/*.ts",
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ _openapi:
|
|||||||
structuredData:
|
structuredData:
|
||||||
headings: []
|
headings: []
|
||||||
contents:
|
contents:
|
||||||
- content: Fetch all titles in the user's library with their tracking statuses.
|
- content: >-
|
||||||
|
Fetch paginated titles in the user's library with their tracking
|
||||||
|
statuses.
|
||||||
---
|
---
|
||||||
|
|
||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
Fetch all titles in the user's library with their tracking statuses.
|
Fetch paginated titles in the user's library with their tracking statuses.
|
||||||
|
|
||||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/dashboard/library","method":"get"}]} />
|
<APIPage document={"./public/openapi.json"} operations={[{"path":"/dashboard/library","method":"get"}]} />
|
||||||
@@ -8,12 +8,12 @@ _openapi:
|
|||||||
headings: []
|
headings: []
|
||||||
contents:
|
contents:
|
||||||
- content: >-
|
- content: >-
|
||||||
Fetch a person's profile and filmography. Imports from TMDB on first
|
Fetch a person's profile and paginated filmography. Imports from TMDB
|
||||||
access if not yet cached locally.
|
on first access if not yet cached locally.
|
||||||
---
|
---
|
||||||
|
|
||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
Fetch a person's profile and filmography. Imports from TMDB on first access if not yet cached locally.
|
Fetch a person's profile and paginated filmography. Imports from TMDB on first access if not yet cached locally.
|
||||||
|
|
||||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/people/{id}","method":"get"}]} />
|
<APIPage document={"./public/openapi.json"} operations={[{"path":"/people/{id}","method":"get"}]} />
|
||||||
+215
-13
@@ -2584,7 +2584,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"operationId": "people.detail",
|
"operationId": "people.detail",
|
||||||
"summary": "Get person details",
|
"summary": "Get person details",
|
||||||
"description": "Fetch a person's profile and filmography. Imports from TMDB on first access if not yet cached locally.",
|
"description": "Fetch a person's profile and paginated filmography. Imports from TMDB on first access if not yet cached locally.",
|
||||||
"tags": [
|
"tags": [
|
||||||
"People"
|
"People"
|
||||||
],
|
],
|
||||||
@@ -2598,11 +2598,39 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"description": "Internal UUIDv7 identifier"
|
"description": "Internal UUIDv7 identifier"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 500,
|
||||||
|
"default": 1,
|
||||||
|
"description": "Page number (1-indexed)"
|
||||||
|
},
|
||||||
|
"allowEmptyValue": true,
|
||||||
|
"allowReserved": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"default": 20,
|
||||||
|
"description": "Results per page (1-100, default 20)"
|
||||||
|
},
|
||||||
|
"allowEmptyValue": true,
|
||||||
|
"allowReserved": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Person profile, filmography credits, and user statuses for their titles",
|
"description": "Person profile, paginated filmography credits, and user statuses for their titles",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -2616,7 +2644,7 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/PersonCredit"
|
"$ref": "#/components/schemas/PersonCredit"
|
||||||
},
|
},
|
||||||
"description": "All credits for this person"
|
"description": "Credits for this person (paginated)"
|
||||||
},
|
},
|
||||||
"userStatuses": {
|
"userStatuses": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -2631,14 +2659,29 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Map of title ID to the user's tracking status"
|
"description": "Map of title ID to the user's tracking status"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Current page number"
|
||||||
|
},
|
||||||
|
"totalPages": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of pages available"
|
||||||
|
},
|
||||||
|
"totalResults": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of results across all pages"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"person",
|
"person",
|
||||||
"filmography",
|
"filmography",
|
||||||
"userStatuses"
|
"userStatuses",
|
||||||
|
"page",
|
||||||
|
"totalPages",
|
||||||
|
"totalResults"
|
||||||
],
|
],
|
||||||
"description": "Person profile with filmography and user's statuses for their titles"
|
"description": "Person profile with paginated filmography and user's statuses for their titles"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3043,13 +3086,41 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"operationId": "dashboard.library",
|
"operationId": "dashboard.library",
|
||||||
"summary": "Get user library",
|
"summary": "Get user library",
|
||||||
"description": "Fetch all titles in the user's library with their tracking statuses.",
|
"description": "Fetch paginated titles in the user's library with their tracking statuses.",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Dashboard"
|
"Dashboard"
|
||||||
],
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 500,
|
||||||
|
"default": 1,
|
||||||
|
"description": "Page number (1-indexed)"
|
||||||
|
},
|
||||||
|
"allowEmptyValue": true,
|
||||||
|
"allowReserved": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"default": 20,
|
||||||
|
"description": "Results per page (1-100, default 20)"
|
||||||
|
},
|
||||||
|
"allowEmptyValue": true,
|
||||||
|
"allowReserved": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Library items with user statuses",
|
"description": "Paginated library items with user statuses",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -3164,12 +3235,27 @@
|
|||||||
],
|
],
|
||||||
"description": "A library item with user status"
|
"description": "A library item with user status"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Current page number"
|
||||||
|
},
|
||||||
|
"totalPages": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of pages available"
|
||||||
|
},
|
||||||
|
"totalResults": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of results across all pages"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"items"
|
"items",
|
||||||
|
"page",
|
||||||
|
"totalPages",
|
||||||
|
"totalResults"
|
||||||
],
|
],
|
||||||
"description": "All titles in the user's library"
|
"description": "Paginated titles in the user's library"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3342,6 +3428,20 @@
|
|||||||
"explode": true,
|
"explode": true,
|
||||||
"allowEmptyValue": true,
|
"allowEmptyValue": true,
|
||||||
"allowReserved": true
|
"allowReserved": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 500,
|
||||||
|
"default": 1,
|
||||||
|
"description": "Page number (1-indexed)"
|
||||||
|
},
|
||||||
|
"allowEmptyValue": true,
|
||||||
|
"allowReserved": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3449,13 +3549,28 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Map of title ID to episode watch progress"
|
"description": "Map of title ID to episode watch progress"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Current page number"
|
||||||
|
},
|
||||||
|
"totalPages": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of pages available"
|
||||||
|
},
|
||||||
|
"totalResults": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of results across all pages"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"items",
|
"items",
|
||||||
"hero",
|
"hero",
|
||||||
"userStatuses",
|
"userStatuses",
|
||||||
"episodeProgress"
|
"episodeProgress",
|
||||||
|
"page",
|
||||||
|
"totalPages",
|
||||||
|
"totalResults"
|
||||||
],
|
],
|
||||||
"description": "Trending titles with hero spotlight and user statuses"
|
"description": "Trending titles with hero spotlight and user statuses"
|
||||||
}
|
}
|
||||||
@@ -3554,6 +3669,20 @@
|
|||||||
"explode": true,
|
"explode": true,
|
||||||
"allowEmptyValue": true,
|
"allowEmptyValue": true,
|
||||||
"allowReserved": true
|
"allowReserved": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 500,
|
||||||
|
"default": 1,
|
||||||
|
"description": "Page number (1-indexed)"
|
||||||
|
},
|
||||||
|
"allowEmptyValue": true,
|
||||||
|
"allowReserved": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3605,12 +3734,27 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Map of title ID to episode watch progress"
|
"description": "Map of title ID to episode watch progress"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Current page number"
|
||||||
|
},
|
||||||
|
"totalPages": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of pages available"
|
||||||
|
},
|
||||||
|
"totalResults": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of results across all pages"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"items",
|
"items",
|
||||||
"userStatuses",
|
"userStatuses",
|
||||||
"episodeProgress"
|
"episodeProgress",
|
||||||
|
"page",
|
||||||
|
"totalPages",
|
||||||
|
"totalResults"
|
||||||
],
|
],
|
||||||
"description": "Browse results with user tracking statuses and episode progress"
|
"description": "Browse results with user tracking statuses and episode progress"
|
||||||
}
|
}
|
||||||
@@ -3854,6 +3998,20 @@
|
|||||||
"explode": true,
|
"explode": true,
|
||||||
"allowEmptyValue": true,
|
"allowEmptyValue": true,
|
||||||
"allowReserved": true
|
"allowReserved": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 500,
|
||||||
|
"default": 1,
|
||||||
|
"description": "Page number (1-indexed)"
|
||||||
|
},
|
||||||
|
"allowEmptyValue": true,
|
||||||
|
"allowReserved": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3992,10 +4150,25 @@
|
|||||||
],
|
],
|
||||||
"description": "A search result (movie, TV show, or person)"
|
"description": "A search result (movie, TV show, or person)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Current page number"
|
||||||
|
},
|
||||||
|
"totalPages": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of pages available"
|
||||||
|
},
|
||||||
|
"totalResults": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of results across all pages"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"results"
|
"results",
|
||||||
|
"page",
|
||||||
|
"totalPages",
|
||||||
|
"totalResults"
|
||||||
],
|
],
|
||||||
"description": "Search results from TMDB"
|
"description": "Search results from TMDB"
|
||||||
}
|
}
|
||||||
@@ -4107,6 +4280,20 @@
|
|||||||
},
|
},
|
||||||
"allowEmptyValue": true,
|
"allowEmptyValue": true,
|
||||||
"allowReserved": true
|
"allowReserved": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 500,
|
||||||
|
"default": 1,
|
||||||
|
"description": "Page number (1-indexed)"
|
||||||
|
},
|
||||||
|
"allowEmptyValue": true,
|
||||||
|
"allowReserved": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -4158,12 +4345,27 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Map of title ID to episode watch progress"
|
"description": "Map of title ID to episode watch progress"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Current page number"
|
||||||
|
},
|
||||||
|
"totalPages": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of pages available"
|
||||||
|
},
|
||||||
|
"totalResults": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total number of results across all pages"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"items",
|
"items",
|
||||||
"userStatuses",
|
"userStatuses",
|
||||||
"episodeProgress"
|
"episodeProgress",
|
||||||
|
"page",
|
||||||
|
"totalPages",
|
||||||
|
"totalResults"
|
||||||
],
|
],
|
||||||
"description": "Browse results with user tracking statuses and episode progress"
|
"description": "Browse results with user tracking statuses and episode progress"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
IntegrationsListOutput,
|
IntegrationsListOutput,
|
||||||
LibraryOutput,
|
LibraryOutput,
|
||||||
MediaTypeParam,
|
MediaTypeParam,
|
||||||
|
PageParam,
|
||||||
|
PaginatedInput,
|
||||||
PersonDetailOutput,
|
PersonDetailOutput,
|
||||||
PersonResolveOutput,
|
PersonResolveOutput,
|
||||||
PopularOutput,
|
PopularOutput,
|
||||||
@@ -256,11 +258,11 @@ export const contract = {
|
|||||||
tags: ["People"],
|
tags: ["People"],
|
||||||
summary: "Get person details",
|
summary: "Get person details",
|
||||||
description:
|
description:
|
||||||
"Fetch a person's profile and filmography. Imports from TMDB on first access if not yet cached locally.",
|
"Fetch a person's profile and paginated filmography. Imports from TMDB on first access if not yet cached locally.",
|
||||||
successDescription:
|
successDescription:
|
||||||
"Person profile, filmography credits, and user statuses for their titles",
|
"Person profile, paginated filmography credits, and user statuses for their titles",
|
||||||
})
|
})
|
||||||
.input(IdParam)
|
.input(IdParam.merge(PaginatedInput))
|
||||||
.output(PersonDetailOutput)
|
.output(PersonDetailOutput)
|
||||||
.errors({
|
.errors({
|
||||||
NOT_FOUND: { message: "Person not found" },
|
NOT_FOUND: { message: "Person not found" },
|
||||||
@@ -312,9 +314,10 @@ export const contract = {
|
|||||||
tags: ["Dashboard"],
|
tags: ["Dashboard"],
|
||||||
summary: "Get user library",
|
summary: "Get user library",
|
||||||
description:
|
description:
|
||||||
"Fetch all titles in the user's library with their tracking statuses.",
|
"Fetch paginated titles in the user's library with their tracking statuses.",
|
||||||
successDescription: "Library items with user statuses",
|
successDescription: "Paginated library items with user statuses",
|
||||||
})
|
})
|
||||||
|
.input(PaginatedInput)
|
||||||
.output(LibraryOutput),
|
.output(LibraryOutput),
|
||||||
recommendations: oc
|
recommendations: oc
|
||||||
.route({
|
.route({
|
||||||
@@ -351,7 +354,7 @@ export const contract = {
|
|||||||
"Fetch today's trending movies and/or TV shows from TMDB, including a featured hero title and the user's statuses.",
|
"Fetch today's trending movies and/or TV shows from TMDB, including a featured hero title and the user's statuses.",
|
||||||
successDescription: "Trending items, hero spotlight, and user statuses",
|
successDescription: "Trending items, hero spotlight, and user statuses",
|
||||||
})
|
})
|
||||||
.input(TrendingTypeParam)
|
.input(TrendingTypeParam.merge(PageParam))
|
||||||
.output(TrendingOutput)
|
.output(TrendingOutput)
|
||||||
.errors({
|
.errors({
|
||||||
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
|
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
|
||||||
@@ -366,7 +369,7 @@ export const contract = {
|
|||||||
"Fetch currently popular movies or TV shows from TMDB with the user's tracking statuses.",
|
"Fetch currently popular movies or TV shows from TMDB with the user's tracking statuses.",
|
||||||
successDescription: "Popular items with user statuses",
|
successDescription: "Popular items with user statuses",
|
||||||
})
|
})
|
||||||
.input(MediaTypeParam)
|
.input(MediaTypeParam.merge(PageParam))
|
||||||
.output(PopularOutput)
|
.output(PopularOutput)
|
||||||
.errors({
|
.errors({
|
||||||
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
|
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
|
||||||
|
|||||||
@@ -31,6 +31,43 @@ export const TmdbIdTypeParam = z
|
|||||||
})
|
})
|
||||||
.meta({ description: "TMDB ID and media type pair for resolving titles" });
|
.meta({ description: "TMDB ID and media type pair for resolving titles" });
|
||||||
|
|
||||||
|
// ─── Pagination ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Page param for TMDB-backed endpoints (fixed ~20 items/page from TMDB) */
|
||||||
|
export const PageParam = z.object({
|
||||||
|
page: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(500)
|
||||||
|
.default(1)
|
||||||
|
.describe("Page number (1-indexed)"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Page + limit for locally-paginated endpoints */
|
||||||
|
export const PaginatedInput = z.object({
|
||||||
|
page: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(500)
|
||||||
|
.default(1)
|
||||||
|
.describe("Page number (1-indexed)"),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.default(20)
|
||||||
|
.describe("Results per page (1-100, default 20)"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PaginationMeta = z.object({
|
||||||
|
page: z.number().describe("Current page number"),
|
||||||
|
totalPages: z.number().describe("Total number of pages available"),
|
||||||
|
totalResults: z.number().describe("Total number of results across all pages"),
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Title inputs ──────────────────────────────────────────────
|
// ─── Title inputs ──────────────────────────────────────────────
|
||||||
|
|
||||||
export const UpdateStatusInput = z
|
export const UpdateStatusInput = z
|
||||||
@@ -83,6 +120,7 @@ export const SearchInput = z
|
|||||||
.optional()
|
.optional()
|
||||||
.describe("Optional type filter to narrow results"),
|
.describe("Optional type filter to narrow results"),
|
||||||
})
|
})
|
||||||
|
.merge(PageParam)
|
||||||
.meta({ description: "Search query with optional type filter" });
|
.meta({ description: "Search query with optional type filter" });
|
||||||
|
|
||||||
export const DiscoverInput = z
|
export const DiscoverInput = z
|
||||||
@@ -90,6 +128,7 @@ export const DiscoverInput = z
|
|||||||
type: z.enum(["movie", "tv"]).describe("Media type to discover"),
|
type: z.enum(["movie", "tv"]).describe("Media type to discover"),
|
||||||
genreId: z.number().int().describe("TMDB genre ID to filter by"),
|
genreId: z.number().int().describe("TMDB genre ID to filter by"),
|
||||||
})
|
})
|
||||||
|
.merge(PageParam)
|
||||||
.meta({ description: "Genre-based discovery filters" });
|
.meta({ description: "Genre-based discovery filters" });
|
||||||
|
|
||||||
// ─── Watch history input ──────────────────────────────────────
|
// ─── Watch history input ──────────────────────────────────────
|
||||||
@@ -460,6 +499,7 @@ const BrowseOutput = z
|
|||||||
userStatuses: userStatusMap,
|
userStatuses: userStatusMap,
|
||||||
episodeProgress: episodeProgressMap,
|
episodeProgress: episodeProgressMap,
|
||||||
})
|
})
|
||||||
|
.merge(PaginationMeta)
|
||||||
.meta({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Browse results with user tracking statuses and episode progress",
|
"Browse results with user tracking statuses and episode progress",
|
||||||
@@ -528,12 +568,13 @@ export const PersonDetailOutput = z
|
|||||||
person: PersonSchema,
|
person: PersonSchema,
|
||||||
filmography: z
|
filmography: z
|
||||||
.array(PersonCreditSchema)
|
.array(PersonCreditSchema)
|
||||||
.describe("All credits for this person"),
|
.describe("Credits for this person (paginated)"),
|
||||||
userStatuses: userStatusMap,
|
userStatuses: userStatusMap,
|
||||||
})
|
})
|
||||||
|
.merge(PaginationMeta)
|
||||||
.meta({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Person profile with filmography and user's statuses for their titles",
|
"Person profile with paginated filmography and user's statuses for their titles",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PersonResolveOutput = z
|
export const PersonResolveOutput = z
|
||||||
@@ -631,7 +672,8 @@ export const LibraryOutput = z
|
|||||||
.meta({ description: "A library item with user status" }),
|
.meta({ description: "A library item with user status" }),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
.meta({ description: "All titles in the user's library" });
|
.merge(PaginationMeta)
|
||||||
|
.meta({ description: "Paginated titles in the user's library" });
|
||||||
|
|
||||||
export const DashboardRecommendationsOutput = z
|
export const DashboardRecommendationsOutput = z
|
||||||
.object({
|
.object({
|
||||||
@@ -660,6 +702,7 @@ export const TrendingOutput = z
|
|||||||
userStatuses: userStatusMap,
|
userStatuses: userStatusMap,
|
||||||
episodeProgress: episodeProgressMap,
|
episodeProgress: episodeProgressMap,
|
||||||
})
|
})
|
||||||
|
.merge(PaginationMeta)
|
||||||
.meta({
|
.meta({
|
||||||
description: "Trending titles with hero spotlight and user statuses",
|
description: "Trending titles with hero spotlight and user statuses",
|
||||||
});
|
});
|
||||||
@@ -717,6 +760,7 @@ export const SearchOutput = z
|
|||||||
.meta({ description: "A search result (movie, TV show, or person)" }),
|
.meta({ description: "A search result (movie, TV show, or person)" }),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
.merge(PaginationMeta)
|
||||||
.meta({ description: "Search results from TMDB" });
|
.meta({ description: "Search results from TMDB" });
|
||||||
|
|
||||||
// ─── Discover output ───────────────────────────────────────────
|
// ─── Discover output ───────────────────────────────────────────
|
||||||
@@ -1098,5 +1142,6 @@ export type ResolvedPerson = z.infer<typeof PersonSchema>;
|
|||||||
export type ResolvedTitle = z.infer<typeof ResolvedTitleSchema>;
|
export type ResolvedTitle = z.infer<typeof ResolvedTitleSchema>;
|
||||||
export type Season = z.infer<typeof SeasonSchema>;
|
export type Season = z.infer<typeof SeasonSchema>;
|
||||||
export type SystemHealthData = z.infer<typeof SystemHealthSchema>;
|
export type SystemHealthData = z.infer<typeof SystemHealthSchema>;
|
||||||
|
export type PaginationInfo = z.infer<typeof PaginationMeta>;
|
||||||
export type TimePeriod = z.infer<typeof WatchHistoryInput>["period"];
|
export type TimePeriod = z.infer<typeof WatchHistoryInput>["period"];
|
||||||
export type UpdateCheckResult = z.infer<typeof UpdateCheckResultSchema>;
|
export type UpdateCheckResult = z.infer<typeof UpdateCheckResultSchema>;
|
||||||
|
|||||||
@@ -395,6 +395,53 @@ export function getNewAvailableFeed(userId: string, days = 14) {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLibraryFeed(userId: string, page = 1, limit = 20) {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const availabilityFilter = sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`;
|
||||||
|
const joinCondition = and(
|
||||||
|
eq(userTitleStatus.titleId, titles.id),
|
||||||
|
eq(userTitleStatus.userId, userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [{ count }] = db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(titles)
|
||||||
|
.innerJoin(userTitleStatus, joinCondition)
|
||||||
|
.where(availabilityFilter)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.select({
|
||||||
|
titleId: titles.id,
|
||||||
|
title: titles.title,
|
||||||
|
type: titles.type,
|
||||||
|
tmdbId: titles.tmdbId,
|
||||||
|
posterPath: titles.posterPath,
|
||||||
|
posterThumbHash: titles.posterThumbHash,
|
||||||
|
releaseDate: titles.releaseDate,
|
||||||
|
firstAirDate: titles.firstAirDate,
|
||||||
|
voteAverage: titles.voteAverage,
|
||||||
|
popularity: titles.popularity,
|
||||||
|
userStatus: userTitleStatus.status,
|
||||||
|
})
|
||||||
|
.from(titles)
|
||||||
|
.innerJoin(userTitleStatus, joinCondition)
|
||||||
|
.where(availabilityFilter)
|
||||||
|
.orderBy(desc(titles.popularity))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const totalResults = count ?? 0;
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
totalPages: Math.max(1, Math.ceil(totalResults / limit)),
|
||||||
|
totalResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getRecommendationsFeed(userId: string) {
|
export function getRecommendationsFeed(userId: string) {
|
||||||
// Get recommendations from user's highly-rated or completed titles
|
// Get recommendations from user's highly-rated or completed titles
|
||||||
const userCompletedOrRated = db
|
const userCompletedOrRated = db
|
||||||
|
|||||||
+121
-83
@@ -1,13 +1,14 @@
|
|||||||
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
|
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
|
||||||
import { db } from "@sofa/db/client";
|
import { db } from "@sofa/db/client";
|
||||||
import { eq, inArray } from "@sofa/db/helpers";
|
import { eq, inArray } from "@sofa/db/helpers";
|
||||||
import { persons, titleCast, titles } from "@sofa/db/schema";
|
import { personFilmography, persons, titles } from "@sofa/db/schema";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { generatePersonThumbHash } from "./thumbhash";
|
import { generatePersonThumbHash } from "./thumbhash";
|
||||||
|
|
||||||
const log = createLogger("person");
|
const log = createLogger("person");
|
||||||
|
const FILMOGRAPHY_STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
async function ensureProfileThumbHash(
|
async function ensureProfileThumbHash(
|
||||||
person: Pick<
|
person: Pick<
|
||||||
@@ -180,13 +181,14 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
|
|||||||
releaseDate: titles.releaseDate,
|
releaseDate: titles.releaseDate,
|
||||||
firstAirDate: titles.firstAirDate,
|
firstAirDate: titles.firstAirDate,
|
||||||
voteAverage: titles.voteAverage,
|
voteAverage: titles.voteAverage,
|
||||||
character: titleCast.character,
|
character: personFilmography.character,
|
||||||
department: titleCast.department,
|
department: personFilmography.department,
|
||||||
job: titleCast.job,
|
job: personFilmography.job,
|
||||||
})
|
})
|
||||||
.from(titleCast)
|
.from(personFilmography)
|
||||||
.innerJoin(titles, eq(titleCast.titleId, titles.id))
|
.innerJoin(titles, eq(personFilmography.titleId, titles.id))
|
||||||
.where(eq(titleCast.personId, personId))
|
.where(eq(personFilmography.personId, personId))
|
||||||
|
.orderBy(personFilmography.displayOrder)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
@@ -205,16 +207,9 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFullFilmography(
|
async function syncPersonFilmography(
|
||||||
personId: string,
|
person: Pick<typeof persons.$inferSelect, "id" | "tmdbId">,
|
||||||
): Promise<PersonCredit[]> {
|
) {
|
||||||
const person = db
|
|
||||||
.select()
|
|
||||||
.from(persons)
|
|
||||||
.where(eq(persons.id, personId))
|
|
||||||
.get();
|
|
||||||
if (!person) return [];
|
|
||||||
|
|
||||||
const credits = await getPersonCombinedCredits(person.tmdbId);
|
const credits = await getPersonCombinedCredits(person.tmdbId);
|
||||||
|
|
||||||
// Schema types combined credits as movie-only; TV entries also carry
|
// Schema types combined credits as movie-only; TV entries also carry
|
||||||
@@ -233,56 +228,53 @@ export async function fetchFullFilmography(
|
|||||||
(c) => c.media_type === "movie" || c.media_type === "tv",
|
(c) => c.media_type === "movie" || c.media_type === "tv",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (validCast.length === 0 && validCrew.length === 0) return [];
|
const allEntries = [...validCast, ...validCrew];
|
||||||
|
|
||||||
// Collect all unique TMDB IDs from both cast and crew
|
|
||||||
const allEntries = [...validCast.map((c) => c), ...validCrew.map((c) => c)];
|
|
||||||
const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
|
const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
|
||||||
|
|
||||||
// Batch prefetch existing titles (1 query)
|
const existingTitles =
|
||||||
const existingTitles = db
|
tmdbIds.length > 0
|
||||||
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
? db
|
||||||
.from(titles)
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||||
.where(inArray(titles.tmdbId, tmdbIds))
|
.from(titles)
|
||||||
.all();
|
.where(inArray(titles.tmdbId, tmdbIds))
|
||||||
|
.all()
|
||||||
|
: [];
|
||||||
const titleIdMap = new Map<number, string>(
|
const titleIdMap = new Map<number, string>(
|
||||||
existingTitles.map((t) => [t.tmdbId, t.id]),
|
existingTitles.map((title) => [title.tmdbId, title.id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Batch insert missing titles in a transaction
|
const newEntries = allEntries.filter((entry) => !titleIdMap.has(entry.id));
|
||||||
const newEntries = allEntries.filter((c) => !titleIdMap.has(c.id));
|
|
||||||
if (newEntries.length > 0) {
|
if (newEntries.length > 0) {
|
||||||
const insertedTmdbIds = new Set<number>();
|
const insertedTmdbIds = new Set<number>();
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
for (const c of newEntries) {
|
for (const entry of newEntries) {
|
||||||
if (insertedTmdbIds.has(c.id)) continue;
|
if (insertedTmdbIds.has(entry.id)) continue;
|
||||||
insertedTmdbIds.add(c.id);
|
insertedTmdbIds.add(entry.id);
|
||||||
const row = tx
|
const row = tx
|
||||||
.insert(titles)
|
.insert(titles)
|
||||||
.values({
|
.values({
|
||||||
tmdbId: c.id,
|
tmdbId: entry.id,
|
||||||
type: c.media_type as "movie" | "tv",
|
type: entry.media_type as "movie" | "tv",
|
||||||
title: c.title ?? c.name ?? "Unknown",
|
title: entry.title ?? entry.name ?? "Unknown",
|
||||||
overview: c.overview,
|
overview: entry.overview,
|
||||||
releaseDate: c.release_date,
|
releaseDate: entry.release_date,
|
||||||
firstAirDate: c.first_air_date,
|
firstAirDate: entry.first_air_date,
|
||||||
posterPath: c.poster_path,
|
posterPath: entry.poster_path,
|
||||||
backdropPath: c.backdrop_path,
|
backdropPath: entry.backdrop_path,
|
||||||
popularity: c.popularity,
|
popularity: entry.popularity,
|
||||||
voteAverage: c.vote_average,
|
voteAverage: entry.vote_average,
|
||||||
voteCount: c.vote_count,
|
voteCount: entry.vote_count,
|
||||||
lastFetchedAt: null,
|
lastFetchedAt: null,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
if (row) titleIdMap.set(c.id, row.id);
|
if (row) titleIdMap.set(entry.id, row.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// One fallback query for any that conflicted
|
|
||||||
const stillMissing = [...insertedTmdbIds].filter(
|
const stillMissing = [...insertedTmdbIds].filter(
|
||||||
(id) => !titleIdMap.has(id),
|
(tmdbId) => !titleIdMap.has(tmdbId),
|
||||||
);
|
);
|
||||||
if (stillMissing.length > 0) {
|
if (stillMissing.length > 0) {
|
||||||
const fallbacks = db
|
const fallbacks = db
|
||||||
@@ -290,48 +282,94 @@ export async function fetchFullFilmography(
|
|||||||
.from(titles)
|
.from(titles)
|
||||||
.where(inArray(titles.tmdbId, stillMissing))
|
.where(inArray(titles.tmdbId, stillMissing))
|
||||||
.all();
|
.all();
|
||||||
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
|
for (const fallback of fallbacks) {
|
||||||
|
titleIdMap.set(fallback.tmdbId, fallback.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build results from map (0 queries)
|
const nextRows: (typeof personFilmography.$inferInsert)[] = [];
|
||||||
const results: PersonCredit[] = [];
|
let displayOrder = 0;
|
||||||
for (const c of validCast) {
|
|
||||||
const tid = titleIdMap.get(c.id);
|
for (const credit of validCast) {
|
||||||
if (!tid) continue;
|
const titleId = titleIdMap.get(credit.id);
|
||||||
results.push({
|
if (!titleId) continue;
|
||||||
titleId: tid,
|
|
||||||
tmdbId: c.id,
|
nextRows.push({
|
||||||
type: c.media_type as "movie" | "tv",
|
personId: person.id,
|
||||||
title: c.title ?? c.name ?? "Unknown",
|
titleId,
|
||||||
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
|
character: credit.character ?? null,
|
||||||
posterThumbHash: null,
|
|
||||||
releaseDate: c.release_date ?? null,
|
|
||||||
firstAirDate: c.first_air_date ?? null,
|
|
||||||
voteAverage: c.vote_average,
|
|
||||||
character: c.character ?? null,
|
|
||||||
department: "Acting",
|
department: "Acting",
|
||||||
job: null,
|
job: null,
|
||||||
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
displayOrder++;
|
||||||
for (const c of validCrew) {
|
|
||||||
const tid = titleIdMap.get(c.id);
|
|
||||||
if (!tid) continue;
|
|
||||||
results.push({
|
|
||||||
titleId: tid,
|
|
||||||
tmdbId: c.id,
|
|
||||||
type: c.media_type as "movie" | "tv",
|
|
||||||
title: c.title ?? c.name ?? "Unknown",
|
|
||||||
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
|
|
||||||
posterThumbHash: null,
|
|
||||||
releaseDate: c.release_date ?? null,
|
|
||||||
firstAirDate: c.first_air_date ?? null,
|
|
||||||
voteAverage: c.vote_average,
|
|
||||||
character: null,
|
|
||||||
department: c.department ?? "Crew",
|
|
||||||
job: c.job ?? null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
for (const credit of validCrew) {
|
||||||
|
const titleId = titleIdMap.get(credit.id);
|
||||||
|
if (!titleId) continue;
|
||||||
|
|
||||||
|
nextRows.push({
|
||||||
|
personId: person.id,
|
||||||
|
titleId,
|
||||||
|
character: null,
|
||||||
|
department: credit.department ?? "Crew",
|
||||||
|
job: credit.job ?? null,
|
||||||
|
displayOrder,
|
||||||
|
});
|
||||||
|
displayOrder++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.delete(personFilmography)
|
||||||
|
.where(eq(personFilmography.personId, person.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
for (const row of nextRows) {
|
||||||
|
tx.insert(personFilmography).values(row).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.update(persons)
|
||||||
|
.set({ filmographyLastFetchedAt: now })
|
||||||
|
.where(eq(persons.id, person.id))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFullFilmography(
|
||||||
|
personId: string,
|
||||||
|
): Promise<PersonCredit[]> {
|
||||||
|
const person = db
|
||||||
|
.select()
|
||||||
|
.from(persons)
|
||||||
|
.where(eq(persons.id, personId))
|
||||||
|
.get();
|
||||||
|
if (!person) return [];
|
||||||
|
|
||||||
|
const localFilmography = getLocalFilmography(personId);
|
||||||
|
const isFresh =
|
||||||
|
person.filmographyLastFetchedAt &&
|
||||||
|
person.filmographyLastFetchedAt.getTime() >
|
||||||
|
Date.now() - FILMOGRAPHY_STALE_MS;
|
||||||
|
|
||||||
|
if (isFresh) {
|
||||||
|
return localFilmography;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncPersonFilmography(person);
|
||||||
|
return getLocalFilmography(personId);
|
||||||
|
} catch (err) {
|
||||||
|
if (person.filmographyLastFetchedAt) {
|
||||||
|
log.warn(
|
||||||
|
`Failed to refresh filmography for person ${personId}, falling back to cached DB rows:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return localFilmography;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@sofa/db/test-utils";
|
} from "@sofa/db/test-utils";
|
||||||
import {
|
import {
|
||||||
getContinueWatchingFeed,
|
getContinueWatchingFeed,
|
||||||
|
getLibraryFeed,
|
||||||
getNewAvailableFeed,
|
getNewAvailableFeed,
|
||||||
getRecommendationsFeed,
|
getRecommendationsFeed,
|
||||||
getRecommendationsForTitle,
|
getRecommendationsForTitle,
|
||||||
@@ -359,3 +360,86 @@ describe("getRecommendationsForTitle", () => {
|
|||||||
expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]);
|
expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── getLibraryFeed ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getLibraryFeed", () => {
|
||||||
|
test("returns first page with correct pagination metadata", () => {
|
||||||
|
insertUser();
|
||||||
|
for (let i = 1; i <= 25; i++) {
|
||||||
|
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||||
|
insertStatus("user-1", `m${i}`, "watchlist");
|
||||||
|
insertAvailabilityOffer(`m${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = getLibraryFeed("user-1", 1, 20);
|
||||||
|
expect(result.items).toHaveLength(20);
|
||||||
|
expect(result.page).toBe(1);
|
||||||
|
expect(result.totalResults).toBe(25);
|
||||||
|
expect(result.totalPages).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns second page with remaining items", () => {
|
||||||
|
insertUser();
|
||||||
|
for (let i = 1; i <= 25; i++) {
|
||||||
|
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||||
|
insertStatus("user-1", `m${i}`, "watchlist");
|
||||||
|
insertAvailabilityOffer(`m${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = getLibraryFeed("user-1", 2, 20);
|
||||||
|
expect(result.items).toHaveLength(5);
|
||||||
|
expect(result.page).toBe(2);
|
||||||
|
expect(result.totalResults).toBe(25);
|
||||||
|
expect(result.totalPages).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom limit respected", () => {
|
||||||
|
insertUser();
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||||
|
insertStatus("user-1", `m${i}`, "watchlist");
|
||||||
|
insertAvailabilityOffer(`m${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = getLibraryFeed("user-1", 1, 5);
|
||||||
|
expect(result.items).toHaveLength(5);
|
||||||
|
expect(result.totalPages).toBe(2);
|
||||||
|
expect(result.totalResults).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty page beyond total returns empty items", () => {
|
||||||
|
insertUser();
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||||
|
insertStatus("user-1", `m${i}`, "watchlist");
|
||||||
|
insertAvailabilityOffer(`m${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = getLibraryFeed("user-1", 2, 20);
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.page).toBe(2);
|
||||||
|
expect(result.totalResults).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pages are disjoint and cover all items", () => {
|
||||||
|
insertUser();
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||||
|
insertStatus("user-1", `m${i}`, "watchlist");
|
||||||
|
insertAvailabilityOffer(`m${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page1 = getLibraryFeed("user-1", 1, 2);
|
||||||
|
const page2 = getLibraryFeed("user-1", 2, 2);
|
||||||
|
|
||||||
|
expect(page1.items).toHaveLength(2);
|
||||||
|
expect(page2.items).toHaveLength(2);
|
||||||
|
|
||||||
|
const allIds = [
|
||||||
|
...page1.items.map((i) => i.tmdbId),
|
||||||
|
...page2.items.map((i) => i.tmdbId),
|
||||||
|
].sort();
|
||||||
|
expect(allIds).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
test,
|
test,
|
||||||
} from "bun:test";
|
} from "bun:test";
|
||||||
import { eq } from "@sofa/db/helpers";
|
import { eq } from "@sofa/db/helpers";
|
||||||
import { persons } from "@sofa/db/schema";
|
import { personFilmography, persons } from "@sofa/db/schema";
|
||||||
import { clearAllTables, testDb } from "@sofa/db/test-utils";
|
import { clearAllTables, testDb } from "@sofa/db/test-utils";
|
||||||
|
|
||||||
const TINY_PNG = Buffer.from(
|
const TINY_PNG = Buffer.from(
|
||||||
@@ -29,13 +29,37 @@ let nextPersonDetails = {
|
|||||||
popularity: 10,
|
popularity: 10,
|
||||||
imdb_id: null,
|
imdb_id: null,
|
||||||
};
|
};
|
||||||
|
let combinedCreditsCalls = 0;
|
||||||
|
let nextCombinedCredits = {
|
||||||
|
cast: [
|
||||||
|
{
|
||||||
|
id: 501,
|
||||||
|
media_type: "movie",
|
||||||
|
title: "Cached Movie",
|
||||||
|
name: undefined,
|
||||||
|
overview: "Overview",
|
||||||
|
release_date: "2024-01-01",
|
||||||
|
first_air_date: undefined,
|
||||||
|
poster_path: "/poster.png",
|
||||||
|
backdrop_path: "/backdrop.png",
|
||||||
|
popularity: 5,
|
||||||
|
vote_average: 7.5,
|
||||||
|
vote_count: 42,
|
||||||
|
character: "Lead",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
crew: [],
|
||||||
|
};
|
||||||
|
|
||||||
mock.module("@sofa/tmdb/client", () => ({
|
mock.module("@sofa/tmdb/client", () => ({
|
||||||
getPersonDetails: async () => nextPersonDetails,
|
getPersonDetails: async () => nextPersonDetails,
|
||||||
getPersonCombinedCredits: async () => ({ cast: [] }),
|
getPersonCombinedCredits: async () => {
|
||||||
|
combinedCreditsCalls++;
|
||||||
|
return nextCombinedCredits;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getOrFetchPerson } from "../src/person";
|
import { fetchFullFilmography, getOrFetchPerson } from "../src/person";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearAllTables();
|
clearAllTables();
|
||||||
@@ -53,6 +77,27 @@ beforeEach(() => {
|
|||||||
popularity: 10,
|
popularity: 10,
|
||||||
imdb_id: null,
|
imdb_id: null,
|
||||||
};
|
};
|
||||||
|
combinedCreditsCalls = 0;
|
||||||
|
nextCombinedCredits = {
|
||||||
|
cast: [
|
||||||
|
{
|
||||||
|
id: 501,
|
||||||
|
media_type: "movie",
|
||||||
|
title: "Cached Movie",
|
||||||
|
name: undefined,
|
||||||
|
overview: "Overview",
|
||||||
|
release_date: "2024-01-01",
|
||||||
|
first_air_date: undefined,
|
||||||
|
poster_path: "/poster.png",
|
||||||
|
backdrop_path: "/backdrop.png",
|
||||||
|
popularity: 5,
|
||||||
|
vote_average: 7.5,
|
||||||
|
vote_count: 42,
|
||||||
|
character: "Lead",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
crew: [],
|
||||||
|
};
|
||||||
spyOn(globalThis, "fetch").mockImplementation((async (
|
spyOn(globalThis, "fetch").mockImplementation((async (
|
||||||
_input: string | URL | Request,
|
_input: string | URL | Request,
|
||||||
_init?: RequestInit,
|
_init?: RequestInit,
|
||||||
@@ -124,3 +169,36 @@ describe("getOrFetchPerson", () => {
|
|||||||
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
|
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchFullFilmography", () => {
|
||||||
|
test("stores and reuses synced filmography rows from the database", async () => {
|
||||||
|
testDb
|
||||||
|
.insert(persons)
|
||||||
|
.values({
|
||||||
|
id: "p1",
|
||||||
|
tmdbId: 100,
|
||||||
|
name: "Cached Person",
|
||||||
|
lastFetchedAt: new Date(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const first = await fetchFullFilmography("p1");
|
||||||
|
const second = await fetchFullFilmography("p1");
|
||||||
|
const storedPerson = testDb
|
||||||
|
.select()
|
||||||
|
.from(persons)
|
||||||
|
.where(eq(persons.id, "p1"))
|
||||||
|
.get();
|
||||||
|
const storedCredits = testDb
|
||||||
|
.select()
|
||||||
|
.from(personFilmography)
|
||||||
|
.where(eq(personFilmography.personId, "p1"))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
expect(first).toHaveLength(1);
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(combinedCreditsCalls).toBe(1);
|
||||||
|
expect(storedPerson?.filmographyLastFetchedAt).toBeInstanceOf(Date);
|
||||||
|
expect(storedCredits).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, test } from "bun:test";
|
import { beforeEach, describe, expect, test } from "bun:test";
|
||||||
import { persons, titleCast } from "@sofa/db/schema";
|
import { personFilmography, persons } from "@sofa/db/schema";
|
||||||
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
||||||
import { getLocalFilmography } from "../src/person";
|
import { getLocalFilmography } from "../src/person";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ function insertPerson(id: string, tmdbId: number, name: string) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertCastEntry(
|
function insertFilmographyEntry(
|
||||||
titleId: string,
|
titleId: string,
|
||||||
personId: string,
|
personId: string,
|
||||||
overrides: {
|
overrides: {
|
||||||
@@ -23,7 +23,7 @@ function insertCastEntry(
|
|||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
testDb
|
testDb
|
||||||
.insert(titleCast)
|
.insert(personFilmography)
|
||||||
.values({
|
.values({
|
||||||
titleId,
|
titleId,
|
||||||
personId,
|
personId,
|
||||||
@@ -31,7 +31,6 @@ function insertCastEntry(
|
|||||||
department: overrides.department ?? "Acting",
|
department: overrides.department ?? "Acting",
|
||||||
job: overrides.job ?? null,
|
job: overrides.job ?? null,
|
||||||
displayOrder: overrides.displayOrder ?? 0,
|
displayOrder: overrides.displayOrder ?? 0,
|
||||||
lastFetchedAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
@@ -41,8 +40,8 @@ describe("getLocalFilmography", () => {
|
|||||||
insertTitle({ id: "m1", tmdbId: 1, title: "Movie One" });
|
insertTitle({ id: "m1", tmdbId: 1, title: "Movie One" });
|
||||||
insertTitle({ id: "m2", tmdbId: 2, title: "Movie Two" });
|
insertTitle({ id: "m2", tmdbId: 2, title: "Movie Two" });
|
||||||
insertPerson("p1", 100, "Test Actor");
|
insertPerson("p1", 100, "Test Actor");
|
||||||
insertCastEntry("m1", "p1", { character: "Hero" });
|
insertFilmographyEntry("m1", "p1", { character: "Hero" });
|
||||||
insertCastEntry("m2", "p1", { character: "Sidekick" });
|
insertFilmographyEntry("m2", "p1", { character: "Sidekick" });
|
||||||
|
|
||||||
const filmography = getLocalFilmography("p1");
|
const filmography = getLocalFilmography("p1");
|
||||||
expect(filmography).toHaveLength(2);
|
expect(filmography).toHaveLength(2);
|
||||||
@@ -55,7 +54,7 @@ describe("getLocalFilmography", () => {
|
|||||||
test("includes crew roles", () => {
|
test("includes crew roles", () => {
|
||||||
insertTitle({ id: "m1", tmdbId: 1, title: "Directed Movie" });
|
insertTitle({ id: "m1", tmdbId: 1, title: "Directed Movie" });
|
||||||
insertPerson("p1", 100, "Director Person");
|
insertPerson("p1", 100, "Director Person");
|
||||||
insertCastEntry("m1", "p1", {
|
insertFilmographyEntry("m1", "p1", {
|
||||||
character: null,
|
character: null,
|
||||||
department: "Directing",
|
department: "Directing",
|
||||||
job: "Director",
|
job: "Director",
|
||||||
@@ -77,8 +76,8 @@ describe("getLocalFilmography", () => {
|
|||||||
insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "A Movie" });
|
insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "A Movie" });
|
||||||
insertTitle({ id: "tv1", tmdbId: 2, type: "tv", title: "A Show" });
|
insertTitle({ id: "tv1", tmdbId: 2, type: "tv", title: "A Show" });
|
||||||
insertPerson("p1", 100, "Versatile Actor");
|
insertPerson("p1", 100, "Versatile Actor");
|
||||||
insertCastEntry("m1", "p1");
|
insertFilmographyEntry("m1", "p1");
|
||||||
insertCastEntry("tv1", "p1");
|
insertFilmographyEntry("tv1", "p1");
|
||||||
|
|
||||||
const filmography = getLocalFilmography("p1");
|
const filmography = getLocalFilmography("p1");
|
||||||
const types = filmography.map((f) => f.type).sort();
|
const types = filmography.map((f) => f.type).sort();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `personFilmography` (
|
||||||
|
`id` text PRIMARY KEY,
|
||||||
|
`personId` text NOT NULL,
|
||||||
|
`titleId` text NOT NULL,
|
||||||
|
`character` text,
|
||||||
|
`department` text DEFAULT 'Acting' NOT NULL,
|
||||||
|
`job` text,
|
||||||
|
`displayOrder` integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT `fk_personFilmography_personId_persons_id_fk` FOREIGN KEY (`personId`) REFERENCES `persons`(`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_personFilmography_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `persons` ADD `filmographyLastFetchedAt` integer;--> statement-breakpoint
|
||||||
|
CREATE INDEX `personFilmography_personId_displayOrder` ON `personFilmography` (`personId`,`displayOrder`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `personFilmography_titleId` ON `personFilmography` (`titleId`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -348,6 +348,9 @@ export const persons = sqliteTable(
|
|||||||
popularity: real("popularity"),
|
popularity: real("popularity"),
|
||||||
imdbId: text("imdbId"),
|
imdbId: text("imdbId"),
|
||||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||||
|
filmographyLastFetchedAt: int("filmographyLastFetchedAt", {
|
||||||
|
mode: "timestamp",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex("persons_tmdbId_unique").on(table.tmdbId),
|
uniqueIndex("persons_tmdbId_unique").on(table.tmdbId),
|
||||||
@@ -387,6 +390,30 @@ export const titleCast = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const personFilmography = sqliteTable(
|
||||||
|
"personFilmography",
|
||||||
|
{
|
||||||
|
id: uuidPk(),
|
||||||
|
personId: text("personId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => persons.id, { onDelete: "cascade" }),
|
||||||
|
titleId: text("titleId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => titles.id, { onDelete: "cascade" }),
|
||||||
|
character: text("character"),
|
||||||
|
department: text("department").notNull().default("Acting"),
|
||||||
|
job: text("job"),
|
||||||
|
displayOrder: int("displayOrder").notNull().default(0),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("personFilmography_personId_displayOrder").on(
|
||||||
|
table.personId,
|
||||||
|
table.displayOrder,
|
||||||
|
),
|
||||||
|
index("personFilmography_titleId").on(table.titleId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Integrations ───────────────────────────────────────────────────
|
// ─── Integrations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const integrations = sqliteTable(
|
export const integrations = sqliteTable(
|
||||||
|
|||||||
+10
-12
@@ -303,31 +303,29 @@ export async function getSimilar(tmdbId: number, type: "movie" | "tv") {
|
|||||||
export async function getTrending(
|
export async function getTrending(
|
||||||
mediaType: "all" | "movie" | "tv",
|
mediaType: "all" | "movie" | "tv",
|
||||||
timeWindow: "day" | "week" = "day",
|
timeWindow: "day" | "week" = "day",
|
||||||
|
page = 1,
|
||||||
) {
|
) {
|
||||||
const opts = {
|
// TMDB trending supports `page` but the generated schema omits it, so widen the query type.
|
||||||
params: { path: { time_window: timeWindow } },
|
const query = { page } as Record<string, unknown>;
|
||||||
} as const;
|
|
||||||
|
|
||||||
if (mediaType === "movie") {
|
if (mediaType === "movie") {
|
||||||
const { data, error } = await client.GET(
|
const { data, error } = await client.GET(
|
||||||
"/3/trending/movie/{time_window}",
|
"/3/trending/movie/{time_window}",
|
||||||
opts,
|
{ params: { path: { time_window: timeWindow }, query } },
|
||||||
);
|
);
|
||||||
if (error) throw new Error("TMDB API error: trending/movie");
|
if (error) throw new Error("TMDB API error: trending/movie");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
if (mediaType === "tv") {
|
if (mediaType === "tv") {
|
||||||
const { data, error } = await client.GET(
|
const { data, error } = await client.GET("/3/trending/tv/{time_window}", {
|
||||||
"/3/trending/tv/{time_window}",
|
params: { path: { time_window: timeWindow }, query },
|
||||||
opts,
|
});
|
||||||
);
|
|
||||||
if (error) throw new Error("TMDB API error: trending/tv");
|
if (error) throw new Error("TMDB API error: trending/tv");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
const { data, error } = await client.GET(
|
const { data, error } = await client.GET("/3/trending/all/{time_window}", {
|
||||||
"/3/trending/all/{time_window}",
|
params: { path: { time_window: timeWindow }, query },
|
||||||
opts,
|
});
|
||||||
);
|
|
||||||
if (error) throw new Error("TMDB API error: trending/all");
|
if (error) throw new Error("TMDB API error: trending/all");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user