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