mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat: add filtering and sorting across library, explore, and upcoming (#21)
* feat: add filtering and sorting across library, explore, and upcoming Add comprehensive filtering and sorting to all list views on both web and mobile. Library (new dedicated page): - New /library route (web) with URL search param persistence - New Library tab (5th tab, mobile) with FlashList grid - Filters: status (multi-select), type, genre (library-only), user rating range, release year (decade presets + custom), content rating, available to stream - Sort by: title, date added, release date, popularity, user rating, TMDB rating - Text search within collection - Filter popover (web) / bottom sheet with Apply (mobile) - Remove old dashboard.library endpoint in favor of library.list Explore/Discover: - New Discover section on Explore page with inline filter controls - TMDB filters: genre (now optional), year range, min rating, sort order, original language, streaming provider - Watch provider list endpoint (WATCH_REGION env var, default "US") - Default to popular content when no filters selected Upcoming: - Type filter (All/Movies/TV Shows) and status filter (All/Watching/Watchlist) - Toggle chips on both web (URL params) and mobile - Backend: mediaType and statusFilter params on upcoming endpoint Navigation: - Library added to web nav bar (desktop + mobile tab bar) - Library added as 5th tab on mobile (between Home and Explore) - Dashboard library section simplified to compact preview with "See all" link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add user streaming platform subscriptions and redesign filter UI Replace the denormalized availabilityOffers table with a normalized platforms + titleAvailability + userPlatforms schema. Users can now declare which streaming services they subscribe to, enabling personalized "On your services" availability display and filtered library/explore results. Backend: new platforms table seeded from TMDB provider data on startup, new API endpoints (platforms.list, account.platforms, account.updatePlatforms), title detail annotates availability with isUserSubscribed flag, library "on my services" filter checks user's subscribed platforms. Web UI: post-registration onboarding page for platform selection, streaming services settings section, title detail split into "On your services" / "Also available on", explore dropdown uses platforms table with active filter highlighting. Library filters redesigned from cluttered popover to clean collapsible inline strip with consolidated dropdowns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address race conditions, stale state, and edge cases in platforms feature - Fix debounced autosave race in streaming services settings: use ref for latest selection + counter guard so only the most recent save updates UI. Clean up timers on unmount. - Fix explore provider dropdown desyncing from query filter when switching Movie/TV type: reset selectedPlatformId alongside providerId. - Fix platformIdsExist rejecting duplicate valid IDs by deduplicating input before comparing against DB results. - Fix stale platform metadata: always upsert name/logo on TMDB refresh instead of skipping when platform already exists. - Fix invisible ratingMax filter: clear ratingMax when user changes ratingMin since the UI only exposes a single rating dropdown now. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: clear stale saved-state timer and preserve falsy filter values - Clear previous savedTimerRef timeout before scheduling a new one in streaming-services-section, preventing an older timer from hiding the "Saved" indicator too early on rapid successive saves - Replace `value || undefined` with explicit empty-value check in library filter handler so numeric 0 (e.g. ratingMin=0) is not incorrectly dropped Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add TanStack Form and migrate auth + change password forms Set up TanStack Form v1 composition layer with createFormHook, pre-bound field components (TextField, TextareaField, CheckboxField, SwitchField, SubmitButton), and migrate the two traditional submit-based forms: - AuthForm: replace 4 useState calls with useAppForm, use form.Field for each input while preserving motion animation layout - ChangePasswordDialog: replace 6 useState calls with useAppForm + Zod schema validation (cross-field password match, min length), per-field error display, form.reset() on dialog close Also adds @tanstack/react-form to the monorepo catalog and updates both web and native apps to reference it via catalog:. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -122,7 +122,7 @@ Cross-package imports:
|
||||
- **JSX text** → `<Trans>` from `@lingui/react/macro`
|
||||
- **Strings in hooks/components** → `const { t } = useLingui()` from `@lingui/react/macro`, then `` t`string` ``
|
||||
- **Strings outside React** (plain modules) → `import { msg } from "@lingui/core/macro"` + `import { i18n } from "@sofa/i18n"`, then ``i18n._(msg`string`)``
|
||||
- **Pluralization** → `import { plural } from "@lingui/core/macro"`, use inside `t`: `` t`${plural(count, { one: "# item", other: "# items" })}` ``
|
||||
- **Pluralization** → `import { plural } from "@lingui/core/macro"`. Use standalone when it's the whole string: `plural(count, { one: "# item", other: "# items" })`. Nest inside `t` only when there's surrounding text: `` t`You have ${plural(count, { one: "# item", other: "# items" })} in your cart` ``
|
||||
- **DO NOT use** `t(i18n)` — it's deprecated in v5 and removed in v6. Use `i18n._(msg`...`)` instead.
|
||||
- **Date/number formatting** → use `formatDate`, `formatRelativeTime`, `formatNumber`, `formatBytes` from `@sofa/i18n/format` (Intl-based, locale-aware). Never use `date-fns` in app code.
|
||||
- **Native Intl polyfills** → `@formatjs/intl-*` polyfills loaded in `apps/native/src/lib/intl-polyfills.ts` (strict dependency order, with locale data for all 6 languages).
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@sofa/i18n": "workspace:*",
|
||||
"@tabler/icons-react-native": "3.40.0",
|
||||
"@tanstack/query-async-storage-persister": "5.95.2",
|
||||
"@tanstack/react-form": "1.28.5",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "5.95.2",
|
||||
"better-auth": "catalog:",
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function DashboardScreen() {
|
||||
}),
|
||||
);
|
||||
const continueWatching = useQuery(orpc.dashboard.continueWatching.queryOptions());
|
||||
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
|
||||
const library = useQuery(orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }));
|
||||
const recommendations = useQuery(orpc.dashboard.recommendations.queryOptions());
|
||||
|
||||
const isRefreshing =
|
||||
@@ -75,6 +75,7 @@ export default function DashboardScreen() {
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||
}, []);
|
||||
|
||||
const hasLibrary = (library.data?.items?.length ?? 0) > 0;
|
||||
@@ -188,7 +189,11 @@ export default function DashboardScreen() {
|
||||
{/* Library */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(350)}>
|
||||
<View className="px-4">
|
||||
<SectionHeader title={t`In Your Library`} icon={IconBooks} />
|
||||
<SectionHeader
|
||||
title={t`In Your Library`}
|
||||
icon={IconBooks}
|
||||
onSeeAll={() => push("/(tabs)/(library)")}
|
||||
/>
|
||||
</View>
|
||||
{library.isPending ? (
|
||||
<HorizontalPosterRow items={[]} isLoading />
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { IconCalendarEvent } from "@tabler/icons-react-native";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { RefreshControl, SectionList, View } from "react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, RefreshControl, SectionList, View } from "react-native";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||
|
||||
@@ -12,10 +12,39 @@ import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
|
||||
|
||||
const contentContainerStyle = { paddingBottom: 24 };
|
||||
|
||||
function FilterChip({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onPress();
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
className={`rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-sans text-xs font-medium ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpcomingScreen() {
|
||||
const { t } = useLingui();
|
||||
const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl");
|
||||
@@ -23,6 +52,9 @@ export default function UpcomingScreen() {
|
||||
const backgroundColor = useCSSVariable("--color-background") as string;
|
||||
const mutedColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const [mediaType, setMediaType] = useState<"all" | "movie" | "tv">("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "watching" | "watchlist">("all");
|
||||
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isRefetching } =
|
||||
useInfiniteQuery(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
@@ -30,6 +62,8 @@ export default function UpcomingScreen() {
|
||||
days: 90,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
mediaType: mediaType !== "all" ? mediaType : undefined,
|
||||
statusFilter: statusFilter !== "all" ? [statusFilter] : undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
@@ -73,6 +107,42 @@ export default function UpcomingScreen() {
|
||||
{t`Upcoming`}
|
||||
</Stack.Screen.Title>
|
||||
<Stack.Screen.BackButton displayMode="minimal" />
|
||||
<View className="flex-row flex-wrap gap-2 px-4 pt-2 pb-1">
|
||||
<View className="flex-row gap-1.5">
|
||||
<FilterChip
|
||||
label={t`All`}
|
||||
isSelected={mediaType === "all"}
|
||||
onPress={() => setMediaType("all")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`Movies`}
|
||||
isSelected={mediaType === "movie"}
|
||||
onPress={() => setMediaType("movie")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`TV Shows`}
|
||||
isSelected={mediaType === "tv"}
|
||||
onPress={() => setMediaType("tv")}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-row gap-1.5">
|
||||
<FilterChip
|
||||
label={t`All`}
|
||||
isSelected={statusFilter === "all"}
|
||||
onPress={() => setStatusFilter("all")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`Watching`}
|
||||
isSelected={statusFilter === "watching"}
|
||||
onPress={() => setStatusFilter("watching")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`Watchlist`}
|
||||
isSelected={statusFilter === "watchlist"}
|
||||
onPress={() => setStatusFilter("watchlist")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{!isPending && allItems.length === 0 ? (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
import { TabStack } from "@/components/navigation/tab-stack";
|
||||
|
||||
export default function LibraryLayout() {
|
||||
const { t } = useLingui();
|
||||
return <TabStack title={t`Library`} />;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import {
|
||||
IconAdjustmentsHorizontal,
|
||||
IconAlertTriangle,
|
||||
IconBooks,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack } from "expo-router";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, RefreshControl, View } from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
import type { LibraryFilters } from "@/components/library/filter-sheet";
|
||||
import { FilterSheet } from "@/components/library/filter-sheet";
|
||||
import { SortMenu } from "@/components/library/sort-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PosterCard } from "@/components/ui/poster-card";
|
||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { useTitleActions } from "@/hooks/use-title-actions";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
|
||||
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
const NUM_COLUMNS = 2;
|
||||
const HORIZONTAL_PADDING = 16;
|
||||
|
||||
const gridContentContainerStyle = {
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 16,
|
||||
};
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const { t } = useLingui();
|
||||
const foregroundColor = useCSSVariable("--color-foreground") as string;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search.trim(), 300);
|
||||
|
||||
const [filters, setFilters] = useState<LibraryFilters>({});
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<SortBy>("added_at");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
|
||||
const handleSortChange = useCallback((newSortBy: SortBy, newDirection: SortDirection) => {
|
||||
setSortBy(newSortBy);
|
||||
setSortDirection(newDirection);
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = useCallback((newFilters: LibraryFilters) => {
|
||||
setFilters(newFilters);
|
||||
}, []);
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filters.statuses && filters.statuses.length > 0) count++;
|
||||
if (filters.type) count++;
|
||||
if (filters.genreId !== undefined) count++;
|
||||
if (filters.ratingMin !== undefined || filters.ratingMax !== undefined) count++;
|
||||
if (filters.yearMin !== undefined || filters.yearMax !== undefined) count++;
|
||||
if (filters.contentRating) count++;
|
||||
if (filters.onMyServices) count++;
|
||||
return count;
|
||||
}, [filters]);
|
||||
|
||||
const libraryQuery = useInfiniteQuery({
|
||||
...orpc.library.list.infiniteOptions({
|
||||
input: (pageParam: number) => ({
|
||||
search: debouncedSearch.length > 0 ? debouncedSearch : undefined,
|
||||
statuses:
|
||||
filters.statuses && filters.statuses.length > 0
|
||||
? (filters.statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[])
|
||||
: undefined,
|
||||
type: filters.type as "movie" | "tv" | undefined,
|
||||
genreId: filters.genreId,
|
||||
ratingMin: filters.ratingMin,
|
||||
ratingMax: filters.ratingMax,
|
||||
yearMin: filters.yearMin,
|
||||
yearMax: filters.yearMax,
|
||||
contentRating: filters.contentRating,
|
||||
onMyServices: filters.onMyServices,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
page: pageParam,
|
||||
limit: 20,
|
||||
}),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 20,
|
||||
}),
|
||||
});
|
||||
|
||||
const { quickAdd } = useTitleActions();
|
||||
const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]);
|
||||
const addingId = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
|
||||
|
||||
const allItems = useMemo(
|
||||
() => libraryQuery.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[libraryQuery.data?.pages],
|
||||
);
|
||||
|
||||
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage;
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||
}, []);
|
||||
|
||||
type LibraryItem = (typeof allItems)[number];
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: LibraryItem }) => (
|
||||
<View className="flex-1 px-1.5 pb-3">
|
||||
<PosterCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
type={item.type as "movie" | "tv"}
|
||||
posterPath={item.posterPath}
|
||||
posterThumbHash={item.posterThumbHash}
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={item.userStatus}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
isAdding={addingId === item.id}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[handleQuickAdd, addingId],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: LibraryItem) => item.id, []);
|
||||
|
||||
const filterButton = (
|
||||
<View className="flex-row items-center gap-4">
|
||||
<SortMenu sortBy={sortBy} sortDirection={sortDirection} onSortChange={handleSortChange} />
|
||||
<Pressable
|
||||
onPress={() => setFilterOpen(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t`Filters`}
|
||||
hitSlop={8}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<ScaledIcon icon={IconAdjustmentsHorizontal} size={22} color={foregroundColor} />
|
||||
{activeFilterCount > 0 && (
|
||||
<View className="bg-primary -mt-2 -ml-1.5 size-4 items-center justify-center rounded-full">
|
||||
<Text className="text-primary-foreground text-[10px] font-bold">
|
||||
{activeFilterCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View collapsable={false} className="bg-background flex-1">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerRight: () => filterButton,
|
||||
}}
|
||||
/>
|
||||
<Stack.SearchBar
|
||||
placeholder={t`Search your library...`}
|
||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||
onCancelButtonPress={() => setSearch("")}
|
||||
onClose={() => setSearch("")}
|
||||
hideWhenScrolling={false}
|
||||
placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined}
|
||||
allowToolbarIntegration={process.env.EXPO_OS === "ios" ? true : undefined}
|
||||
/>
|
||||
|
||||
{libraryQuery.isPending ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Spinner colorClassName="accent-primary" />
|
||||
</View>
|
||||
) : libraryQuery.isError ? (
|
||||
<EmptyState
|
||||
icon={IconAlertTriangle}
|
||||
title={t`Something went wrong`}
|
||||
description={t`Could not load your library`}
|
||||
actionLabel={t`Retry`}
|
||||
onAction={() => libraryQuery.refetch()}
|
||||
/>
|
||||
) : allItems.length === 0 ? (
|
||||
<Animated.View entering={FadeIn.duration(300)} className="flex-1">
|
||||
{debouncedSearch.length > 0 ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text className="text-muted-foreground text-base">
|
||||
<Trans>No results for "{debouncedSearch}"</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : activeFilterCount > 0 ? (
|
||||
<EmptyState
|
||||
icon={IconAdjustmentsHorizontal}
|
||||
title={t`No matching titles`}
|
||||
description={t`Try adjusting your filters`}
|
||||
actionLabel={t`Clear filters`}
|
||||
onAction={() => setFilters({})}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconBooks}
|
||||
title={t`Your library is empty`}
|
||||
description={t`Start tracking movies and shows`}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
) : (
|
||||
<FlashList
|
||||
data={allItems}
|
||||
numColumns={NUM_COLUMNS}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={gridContentContainerStyle}
|
||||
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||
onEndReached={() => {
|
||||
if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) {
|
||||
libraryQuery.fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
libraryQuery.isFetchingNextPage ? (
|
||||
<View className="items-center py-4">
|
||||
<Spinner />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FilterSheet
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
filters={filters}
|
||||
onApply={handleApplyFilters}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -477,7 +477,7 @@ export default function TitleDetailScreen() {
|
||||
contentContainerStyle={titleAvailabilityContentStyle}
|
||||
>
|
||||
{availability.map((offer) => (
|
||||
<View key={`${offer.providerId}-${offer.offerType}`} className="items-center">
|
||||
<View key={`${offer.platformId}-${offer.offerType}`} className="items-center">
|
||||
{offer.logoPath && (
|
||||
<Image
|
||||
source={{ uri: offer.logoPath }}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconAdjustmentsHorizontal, IconStarFilled, IconTag } from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import { SelectModal } from "@/components/ui/select-modal";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export interface LibraryFilters {
|
||||
statuses?: string[];
|
||||
type?: string;
|
||||
genreId?: number;
|
||||
ratingMin?: number;
|
||||
ratingMax?: number;
|
||||
yearMin?: number;
|
||||
yearMax?: number;
|
||||
contentRating?: string;
|
||||
onMyServices?: boolean;
|
||||
}
|
||||
|
||||
interface FilterSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filters: LibraryFilters;
|
||||
onApply: (filters: LibraryFilters) => void;
|
||||
}
|
||||
|
||||
const DECADES = [
|
||||
{ label: "2020s", yearMin: 2020, yearMax: 2029 },
|
||||
{ label: "2010s", yearMin: 2010, yearMax: 2019 },
|
||||
{ label: "2000s", yearMin: 2000, yearMax: 2009 },
|
||||
{ label: "90s", yearMin: 1990, yearMax: 1999 },
|
||||
{ label: "80s", yearMin: 1980, yearMax: 1989 },
|
||||
] as const;
|
||||
|
||||
const CONTENT_RATINGS = [
|
||||
"G",
|
||||
"PG",
|
||||
"PG-13",
|
||||
"R",
|
||||
"NC-17",
|
||||
"TV-Y",
|
||||
"TV-Y7",
|
||||
"TV-G",
|
||||
"TV-PG",
|
||||
"TV-14",
|
||||
"TV-MA",
|
||||
];
|
||||
|
||||
function Chip({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onPress();
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
className={`rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-sans text-xs font-medium ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<Text className="text-muted-foreground mb-2 text-xs font-medium tracking-wider uppercase">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function RatingStar({ filled, onPress }: { filled: boolean; onPress: () => void }) {
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
const mutedColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onPress();
|
||||
}}
|
||||
hitSlop={4}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ScaledIcon icon={IconStarFilled} size={24} color={filled ? primaryColor : mutedColor} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSheet({ open, onOpenChange, filters, onApply }: FilterSheetProps) {
|
||||
const { t } = useLingui();
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
const { bottom: safeBottom } = useSafeAreaInsets();
|
||||
|
||||
// Local draft state — reset from applied filters when sheet opens.
|
||||
// Uses React's "adjusting state during render" pattern (react.dev/learn/
|
||||
// you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)
|
||||
// to avoid useEffect + setState cascade.
|
||||
const [local, setLocal] = useState<LibraryFilters>(() => ({ ...filters }));
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open && !prevOpen) {
|
||||
setLocal({ ...filters });
|
||||
}
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
}
|
||||
|
||||
const handleClose = () => onOpenChange(false);
|
||||
|
||||
// Genre modal
|
||||
const [genreModalOpen, setGenreModalOpen] = useState(false);
|
||||
// Content rating modal
|
||||
const [contentRatingModalOpen, setContentRatingModalOpen] = useState(false);
|
||||
|
||||
const { data: genreData } = useQuery(orpc.library.genres.queryOptions());
|
||||
|
||||
const genreOptions = useMemo(
|
||||
() => [
|
||||
{ value: "", label: t`All genres` },
|
||||
...(genreData?.genres.map((g) => ({ value: String(g.id), label: g.name })) ?? []),
|
||||
],
|
||||
[genreData, t],
|
||||
);
|
||||
|
||||
const contentRatingOptions = useMemo(
|
||||
() => [{ value: "", label: t`All` }, ...CONTENT_RATINGS.map((r) => ({ value: r, label: r }))],
|
||||
[t],
|
||||
);
|
||||
|
||||
const statuses = useMemo(
|
||||
() => [
|
||||
{ value: "in_watchlist", label: t`Watchlist` },
|
||||
{ value: "watching", label: t`Watching` },
|
||||
{ value: "caught_up", label: t`Caught Up` },
|
||||
{ value: "completed", label: t`Completed` },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const types = useMemo(
|
||||
() => [
|
||||
{ value: undefined as string | undefined, label: t`All` },
|
||||
{ value: "movie" as string | undefined, label: t`Movie` },
|
||||
{ value: "tv" as string | undefined, label: t`TV` },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
function toggleStatus(status: string) {
|
||||
const current = local.statuses ?? [];
|
||||
const next = current.includes(status)
|
||||
? current.filter((s) => s !== status)
|
||||
: [...current, status];
|
||||
setLocal((prev) => ({ ...prev, statuses: next.length > 0 ? next : undefined }));
|
||||
}
|
||||
|
||||
const activeDecade = DECADES.find(
|
||||
(d) => local.yearMin === d.yearMin && local.yearMax === d.yearMax,
|
||||
);
|
||||
|
||||
function selectDecade(decade: (typeof DECADES)[number]) {
|
||||
if (activeDecade === decade) {
|
||||
setLocal((prev) => ({ ...prev, yearMin: undefined, yearMax: undefined }));
|
||||
} else {
|
||||
setLocal((prev) => ({ ...prev, yearMin: decade.yearMin, yearMax: decade.yearMax }));
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setLocal({});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
onApply(local);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
const selectedGenreLabel =
|
||||
genreData?.genres.find((g) => g.id === local.genreId)?.name ?? t`All genres`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal visible={open} transparent animationType="slide" onRequestClose={() => handleClose()}>
|
||||
<Pressable className="flex-1 justify-end bg-black/60" onPress={() => handleClose()}>
|
||||
<Pressable
|
||||
className="bg-card max-h-[85%] rounded-t-2xl"
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="border-border/50 flex-row items-center border-b px-5 py-4">
|
||||
<ScaledIcon icon={IconAdjustmentsHorizontal} size={20} color={mutedFgColor} />
|
||||
<Text className="text-foreground ml-2 text-base font-medium">{t`Filters`}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="px-5 py-4" showsVerticalScrollIndicator={false} bounces={false}>
|
||||
<View className="gap-5 pb-4">
|
||||
{/* Status */}
|
||||
<View>
|
||||
<SectionLabel>{t`Status`}</SectionLabel>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{statuses.map((s) => (
|
||||
<Chip
|
||||
key={s.value}
|
||||
label={s.label}
|
||||
isSelected={(local.statuses ?? []).includes(s.value)}
|
||||
onPress={() => toggleStatus(s.value)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Type */}
|
||||
<View>
|
||||
<SectionLabel>{t`Type`}</SectionLabel>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{types.map((typ) => (
|
||||
<Chip
|
||||
key={typ.value ?? "all"}
|
||||
label={typ.label}
|
||||
isSelected={local.type === typ.value}
|
||||
onPress={() => setLocal((prev) => ({ ...prev, type: typ.value }))}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Genre */}
|
||||
<View>
|
||||
<SectionLabel>{t`Genre`}</SectionLabel>
|
||||
<Pressable
|
||||
onPress={() => setGenreModalOpen(true)}
|
||||
className="bg-secondary flex-row items-center rounded-xl px-4 py-3"
|
||||
>
|
||||
<ScaledIcon icon={IconTag} size={16} color={mutedFgColor} />
|
||||
<Text className="text-foreground ml-2 flex-1 text-sm">
|
||||
{selectedGenreLabel}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-xs">{"\u203A"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
<View>
|
||||
<SectionLabel>{t`Rating`}</SectionLabel>
|
||||
<View className="gap-2">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-muted-foreground w-8 text-xs">{t`Min`}</Text>
|
||||
<View className="flex-row gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<RatingStar
|
||||
key={`min-${star}`}
|
||||
filled={star <= (local.ratingMin ?? 0)}
|
||||
onPress={() =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
ratingMin: prev.ratingMin === star ? undefined : star,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-muted-foreground w-8 text-xs">{t`Max`}</Text>
|
||||
<View className="flex-row gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<RatingStar
|
||||
key={`max-${star}`}
|
||||
filled={star <= (local.ratingMax ?? 0)}
|
||||
onPress={() =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
ratingMax: prev.ratingMax === star ? undefined : star,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Year */}
|
||||
<View>
|
||||
<SectionLabel>{t`Year`}</SectionLabel>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{DECADES.map((decade) => (
|
||||
<Chip
|
||||
key={decade.label}
|
||||
label={decade.label}
|
||||
isSelected={activeDecade === decade}
|
||||
onPress={() => selectDecade(decade)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content Rating */}
|
||||
<View>
|
||||
<SectionLabel>{t`Content Rating`}</SectionLabel>
|
||||
<Pressable
|
||||
onPress={() => setContentRatingModalOpen(true)}
|
||||
className="bg-secondary flex-row items-center rounded-xl px-4 py-3"
|
||||
>
|
||||
<Text className="text-foreground flex-1 text-sm">
|
||||
{local.contentRating ?? t`All`}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-xs">{"\u203A"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Streaming */}
|
||||
<View>
|
||||
<SectionLabel>{t`Streaming`}</SectionLabel>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-foreground text-sm">{t`On my services`}</Text>
|
||||
<Switch
|
||||
value={local.onMyServices ?? false}
|
||||
onValueChange={(checked) =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
onMyServices: checked || undefined,
|
||||
}))
|
||||
}
|
||||
accessibilityLabel={t`On my services`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom buttons */}
|
||||
<View
|
||||
className="border-border/50 flex-row gap-3 border-t px-5 py-4"
|
||||
style={{ paddingBottom: Math.max(16, safeBottom) }}
|
||||
>
|
||||
<Button variant="secondary" onPress={clearAll} className="flex-1">
|
||||
<ButtonLabel>{t`Clear`}</ButtonLabel>
|
||||
</Button>
|
||||
<Button onPress={applyFilters} className="flex-1">
|
||||
<ButtonLabel>{t`Apply`}</ButtonLabel>
|
||||
</Button>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
<SelectModal
|
||||
label={t`Genre`}
|
||||
icon={IconTag}
|
||||
selection={local.genreId !== undefined ? String(local.genreId) : ""}
|
||||
options={genreOptions}
|
||||
open={genreModalOpen}
|
||||
onOpenChange={setGenreModalOpen}
|
||||
onSelect={(value) =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
genreId: value === "" ? undefined : Number(value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectModal
|
||||
label={t`Content Rating`}
|
||||
selection={local.contentRating ?? ""}
|
||||
options={contentRatingOptions}
|
||||
open={contentRatingModalOpen}
|
||||
onOpenChange={setContentRatingModalOpen}
|
||||
onSelect={(value) =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
contentRating: value === "" ? undefined : value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconArrowsSort } from "@tabler/icons-react-native";
|
||||
import { Pressable } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
|
||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
interface SortOption {
|
||||
sortBy: SortBy;
|
||||
sortDirection: SortDirection;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SortMenuProps {
|
||||
sortBy: SortBy;
|
||||
sortDirection: SortDirection;
|
||||
onSortChange: (sortBy: SortBy, sortDirection: SortDirection) => void;
|
||||
}
|
||||
|
||||
export function SortMenu({ sortBy, sortDirection, onSortChange }: SortMenuProps) {
|
||||
const { t } = useLingui();
|
||||
const foregroundColor = useCSSVariable("--color-foreground") as string;
|
||||
|
||||
const sortOptions: SortOption[] = [
|
||||
{ sortBy: "added_at", sortDirection: "desc", label: t`Date Added` },
|
||||
{ sortBy: "title", sortDirection: "asc", label: t`Title A-Z` },
|
||||
{ sortBy: "title", sortDirection: "desc", label: t`Title Z-A` },
|
||||
{ sortBy: "release_date", sortDirection: "desc", label: t`Release Date` },
|
||||
{ sortBy: "user_rating", sortDirection: "desc", label: t`User Rating` },
|
||||
{ sortBy: "vote_average", sortDirection: "desc", label: t`TMDB Rating` },
|
||||
{ sortBy: "popularity", sortDirection: "desc", label: t`Popularity` },
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Pressable
|
||||
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t`Sort`}
|
||||
hitSlop={8}
|
||||
>
|
||||
<ScaledIcon icon={IconArrowsSort} size={22} color={foregroundColor} />
|
||||
</Pressable>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
{sortOptions.map((option) => {
|
||||
const isSelected = option.sortBy === sortBy && option.sortDirection === sortDirection;
|
||||
return (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`${option.sortBy}-${option.sortDirection}`}
|
||||
value={isSelected ? "on" : "off"}
|
||||
onValueChange={() => onSortChange(option.sortBy, option.sortDirection)}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean
|
||||
<NativeTabs.Trigger.Label>{t`Home`}</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(library)" disableTransparentOnScrollEdge>
|
||||
<NativeTabs.Trigger.Label>{t`Library`}</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="books.vertical" md="local_library" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
|
||||
<NativeTabs.Trigger.Label>{t`Explore`}</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { registerJobScheduleProvider } from "@sofa/core/system-health";
|
||||
import { closeDatabase, isDatabaseAccessBlocked } from "@sofa/db/client";
|
||||
import { runMigrations } from "@sofa/db/migrate";
|
||||
import { recoverStaleImportJobs } from "@sofa/db/queries/imports";
|
||||
import { seedPlatforms } from "@sofa/db/seed-platforms";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { getJobSchedules, startJobs, stopJobs } from "./cron";
|
||||
@@ -36,6 +37,9 @@ await ensureBackupDir();
|
||||
// Run database migrations
|
||||
runMigrations();
|
||||
|
||||
// Seed streaming platforms (idempotent upsert)
|
||||
seedPlatforms();
|
||||
|
||||
// Recover import jobs left in running/pending state from a previous crash
|
||||
const recoveredJobs = recoverStaleImportJobs();
|
||||
if (recoveredJobs > 0) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
|
||||
import { auth } from "@sofa/auth/server";
|
||||
import { AVATAR_DIR } from "@sofa/config";
|
||||
import { getUserPlatformIdList, updateUserPlatforms } from "@sofa/core/platforms";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
@@ -64,3 +65,13 @@ export const removeAvatar = os.account.removeAvatar.use(authed).handler(async ({
|
||||
headers: context.headers,
|
||||
});
|
||||
});
|
||||
|
||||
export const platforms = os.account.platforms.use(authed).handler(async ({ context }) => {
|
||||
return { platformIds: getUserPlatformIdList(context.user.id) };
|
||||
});
|
||||
|
||||
export const updatePlatformsHandler = os.account.updatePlatforms
|
||||
.use(authed)
|
||||
.handler(async ({ input, context }) => {
|
||||
updateUserPlatforms(context.user.id, input.platformIds);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
getContinueWatchingFeed,
|
||||
getLibraryFeed,
|
||||
getRecommendationsFeed,
|
||||
getUpcomingFeed,
|
||||
getUserStats,
|
||||
getWatchCount,
|
||||
getWatchHistory,
|
||||
} from "@sofa/core/discovery";
|
||||
import { getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
@@ -43,32 +41,6 @@ export const continueWatching = os.dashboard.continueWatching.use(authed).handle
|
||||
return { items };
|
||||
});
|
||||
|
||||
export const library = os.dashboard.library.use(authed).handler(({ input, context }) => {
|
||||
const {
|
||||
items: feed,
|
||||
page,
|
||||
totalPages,
|
||||
totalResults,
|
||||
} = getLibraryFeed(context.user.id, input.page, input.limit);
|
||||
|
||||
const titleIds = feed.map((t) => t.titleId);
|
||||
const displayStatuses = getDisplayStatusesByTitleIds(context.user.id, titleIds);
|
||||
|
||||
const items = feed.map((t) => ({
|
||||
id: t.titleId,
|
||||
tmdbId: t.tmdbId,
|
||||
type: t.type,
|
||||
title: t.title,
|
||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||
posterThumbHash: t.posterThumbHash ?? null,
|
||||
releaseDate: t.releaseDate ?? null,
|
||||
firstAirDate: t.firstAirDate ?? null,
|
||||
voteAverage: t.voteAverage,
|
||||
userStatus: displayStatuses[t.titleId] ?? null,
|
||||
}));
|
||||
return { items, page, totalPages, totalResults };
|
||||
});
|
||||
|
||||
export const recommendations = os.dashboard.recommendations.use(authed).handler(({ context }) => {
|
||||
const feed = getRecommendationsFeed(context.user.id);
|
||||
const items = feed
|
||||
@@ -93,6 +65,8 @@ export const upcoming = os.dashboard.upcoming.use(authed).handler(({ input, cont
|
||||
days: input.days,
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
mediaType: input.mediaType,
|
||||
statusFilter: input.statusFilter,
|
||||
});
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { WATCH_REGION } from "@sofa/config";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { discover as discoverTmdb } from "@sofa/tmdb/client";
|
||||
@@ -18,15 +19,27 @@ export const discover = os.discover.use(authed).handler(async ({ input, context
|
||||
});
|
||||
}
|
||||
|
||||
const results = await discoverTmdb(
|
||||
input.type,
|
||||
{
|
||||
sort_by: "popularity.desc",
|
||||
const params: Record<string, string> = {
|
||||
sort_by: input.sortBy ?? "popularity.desc",
|
||||
"vote_count.gte": "50",
|
||||
with_genres: String(input.genreId),
|
||||
},
|
||||
input.page,
|
||||
);
|
||||
};
|
||||
if (input.genreId) params.with_genres = String(input.genreId);
|
||||
if (input.yearMin) {
|
||||
const key = input.type === "movie" ? "primary_release_date.gte" : "first_air_date.gte";
|
||||
params[key] = `${input.yearMin}-01-01`;
|
||||
}
|
||||
if (input.yearMax) {
|
||||
const key = input.type === "movie" ? "primary_release_date.lte" : "first_air_date.lte";
|
||||
params[key] = `${input.yearMax}-12-31`;
|
||||
}
|
||||
if (input.ratingMin != null) params["vote_average.gte"] = String(input.ratingMin);
|
||||
if (input.language) params.with_original_language = input.language;
|
||||
if (input.providerId) {
|
||||
params.with_watch_providers = String(input.providerId);
|
||||
params.watch_region = WATCH_REGION;
|
||||
}
|
||||
|
||||
const results = await discoverTmdb(input.type, params, input.page);
|
||||
|
||||
type DiscoverResult = NonNullable<typeof results.results>[number] & {
|
||||
title?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { listPlatforms } from "@sofa/core/platforms";
|
||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
@@ -161,3 +162,15 @@ export const genres = os.explore.genres.use(authed).handler(async ({ input }) =>
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
export const watchProviders = os.explore.watchProviders.use(authed).handler(async () => {
|
||||
const allPlatforms = listPlatforms();
|
||||
return {
|
||||
providers: allPlatforms.map((p) => ({
|
||||
id: p.id,
|
||||
tmdbProviderId: p.tmdbProviderId,
|
||||
name: p.name,
|
||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getFilteredLibraryFeed, getLibraryGenresList } from "@sofa/core/library";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const list = os.library.list.use(authed).handler(({ input, context }) => {
|
||||
const result = getFilteredLibraryFeed(context.user.id, {
|
||||
search: input.search,
|
||||
statuses: input.statuses,
|
||||
type: input.type,
|
||||
genreId: input.genreId,
|
||||
ratingMin: input.ratingMin,
|
||||
ratingMax: input.ratingMax,
|
||||
yearMin: input.yearMin,
|
||||
yearMax: input.yearMax,
|
||||
contentRating: input.contentRating,
|
||||
onMyServices: input.onMyServices,
|
||||
sortBy: input.sortBy,
|
||||
sortDirection: input.sortDirection,
|
||||
page: input.page,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
id: item.titleId,
|
||||
tmdbId: item.tmdbId,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
||||
posterThumbHash: item.posterThumbHash ?? null,
|
||||
releaseDate: item.releaseDate ?? null,
|
||||
firstAirDate: item.firstAirDate ?? null,
|
||||
voteAverage: item.voteAverage,
|
||||
userStatus: item.userStatus,
|
||||
userRating: item.userRating,
|
||||
})),
|
||||
page: result.page,
|
||||
totalPages: result.totalPages,
|
||||
totalResults: result.totalResults,
|
||||
};
|
||||
});
|
||||
|
||||
export const genres = os.library.genres.use(authed).handler(({ context }) => {
|
||||
return { genres: getLibraryGenresList(context.user.id) };
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { listPlatforms } from "@sofa/core/platforms";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const list = os.platforms.list.use(authed).handler(async () => {
|
||||
const allPlatforms = listPlatforms();
|
||||
return {
|
||||
platforms: allPlatforms.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
tmdbProviderId: p.tmdbProviderId,
|
||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||
displayOrder: p.displayOrder,
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -20,8 +20,8 @@ import { authed } from "../middleware";
|
||||
|
||||
const log = createLogger("titles");
|
||||
|
||||
export const detail = os.titles.detail.use(authed).handler(async ({ input }) => {
|
||||
const result = await getOrFetchTitle(input.id);
|
||||
export const detail = os.titles.detail.use(authed).handler(async ({ input, context }) => {
|
||||
const result = await getOrFetchTitle(input.id, context.user.id);
|
||||
if (!result)
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Title not found",
|
||||
|
||||
@@ -7,7 +7,9 @@ import * as episodes from "./procedures/episodes";
|
||||
import * as explore from "./procedures/explore";
|
||||
import * as imports from "./procedures/imports";
|
||||
import * as integrations from "./procedures/integrations";
|
||||
import * as library from "./procedures/library";
|
||||
import * as people from "./procedures/people";
|
||||
import * as platformProcs from "./procedures/platforms";
|
||||
import { search } from "./procedures/search";
|
||||
import * as seasons from "./procedures/seasons";
|
||||
import * as status from "./procedures/status";
|
||||
@@ -15,6 +17,10 @@ import * as system from "./procedures/system";
|
||||
import * as titles from "./procedures/titles";
|
||||
|
||||
export const implementedRouter = {
|
||||
library: {
|
||||
list: library.list,
|
||||
genres: library.genres,
|
||||
},
|
||||
titles: {
|
||||
detail: titles.detail,
|
||||
updateStatus: titles.updateStatus,
|
||||
@@ -41,7 +47,6 @@ export const implementedRouter = {
|
||||
stats: dashboard.stats,
|
||||
continueWatching: dashboard.continueWatching,
|
||||
upcoming: dashboard.upcoming,
|
||||
library: dashboard.library,
|
||||
recommendations: dashboard.recommendations,
|
||||
watchHistory: dashboard.watchHistory,
|
||||
},
|
||||
@@ -49,6 +54,7 @@ export const implementedRouter = {
|
||||
trending: explore.trending,
|
||||
popular: explore.popular,
|
||||
genres: explore.genres,
|
||||
watchProviders: explore.watchProviders,
|
||||
},
|
||||
search,
|
||||
discover,
|
||||
@@ -87,6 +93,11 @@ export const implementedRouter = {
|
||||
updateName: account.updateName,
|
||||
uploadAvatar: account.uploadAvatar,
|
||||
removeAvatar: account.removeAvatar,
|
||||
platforms: account.platforms,
|
||||
updatePlatforms: account.updatePlatformsHandler,
|
||||
},
|
||||
platforms: {
|
||||
list: platformProcs.list,
|
||||
},
|
||||
imports: {
|
||||
parseFile: imports.parseFile,
|
||||
|
||||
@@ -28,9 +28,11 @@
|
||||
"@sofa/api": "workspace:*",
|
||||
"@sofa/i18n": "workspace:*",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"@tanstack/react-hotkeys": "0.5.1",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-router": "1.168.3",
|
||||
"@tanstack/zod-adapter": "1.166.9",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient, signIn, signUp } from "@/lib/auth/client";
|
||||
import { useAppForm } from "@/lib/form";
|
||||
|
||||
export interface AuthConfig {
|
||||
oidcEnabled: boolean;
|
||||
@@ -39,11 +40,7 @@ export function AuthForm({
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
|
||||
const isRegister = mode === "register";
|
||||
@@ -51,32 +48,34 @@ export function AuthForm({
|
||||
const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false);
|
||||
const oidcProviderName = authConfig?.oidcProviderName || "SSO";
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const form = useAppForm({
|
||||
defaultValues: { name: "", email: "", password: "" },
|
||||
onSubmit: async ({ value }) => {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
const result = await signUp.email({ name, email, password });
|
||||
const result = await signUp.email({
|
||||
name: value.name,
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? t`Registration failed`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const result = await signIn.email({ email, password });
|
||||
const result = await signIn.email({ email: value.email, password: value.password });
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? t`Login failed`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
void navigate({ to: "/dashboard" });
|
||||
void navigate({ to: isRegister ? "/onboarding" : "/dashboard" });
|
||||
} catch {
|
||||
setError(t`Something went wrong`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function handleOidcLogin() {
|
||||
setError("");
|
||||
@@ -160,7 +159,10 @@ export function AuthForm({
|
||||
|
||||
{showPasswordForm && (
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@@ -170,8 +172,13 @@ export function AuthForm({
|
||||
}}
|
||||
>
|
||||
{isRegister && (
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<motion.div variants={fieldVariants} className="space-y-1.5">
|
||||
<Label htmlFor="name" className="text-muted-foreground tracking-wider uppercase">
|
||||
<Label
|
||||
htmlFor="name"
|
||||
className="text-muted-foreground tracking-wider uppercase"
|
||||
>
|
||||
<Trans>Name</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -179,14 +186,18 @@ export function AuthForm({
|
||||
type="text"
|
||||
required
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className={authInputClass}
|
||||
placeholder={t`Your name…`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</form.Field>
|
||||
)}
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<motion.div variants={fieldVariants} className="space-y-1.5">
|
||||
<Label htmlFor="email" className="text-muted-foreground tracking-wider uppercase">
|
||||
<Trans>Email</Trans>
|
||||
@@ -197,15 +208,22 @@ export function AuthForm({
|
||||
required
|
||||
autoComplete="email"
|
||||
spellCheck={false}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className={authInputClass}
|
||||
placeholder="wwhite@graymatter.biz"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<motion.div variants={fieldVariants} className="space-y-1.5">
|
||||
<Label htmlFor="password" className="text-muted-foreground tracking-wider uppercase">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-muted-foreground tracking-wider uppercase"
|
||||
>
|
||||
<Trans>Password</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -214,20 +232,24 @@ export function AuthForm({
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className={authInputClass}
|
||||
placeholder={t`Min 8 characters…`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Subscribe selector={(state) => state.isSubmitting}>
|
||||
{(isSubmitting) => (
|
||||
<motion.div variants={fieldVariants}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={isSubmitting}
|
||||
className="hover:shadow-primary/20 h-11 w-full rounded-lg text-sm hover:shadow-lg"
|
||||
>
|
||||
{loading ? (
|
||||
{isSubmitting ? (
|
||||
<Trans>Loading…</Trans>
|
||||
) : isRegister ? (
|
||||
<Trans>Create account</Trans>
|
||||
@@ -236,6 +258,8 @@ export function AuthForm({
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</motion.form>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconBooks } from "@tabler/icons-react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
import { FeedSection } from "./feed-section";
|
||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||
|
||||
export function LibrarySection() {
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
orpc.dashboard.library.infiniteOptions({
|
||||
input: (pageParam: number) => ({ page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
const { data, isPending } = useQuery(
|
||||
orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }),
|
||||
);
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
});
|
||||
|
||||
if (isPending) return <TitleGridSectionSkeleton />;
|
||||
|
||||
const items = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const items = data?.items ?? [];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FeedSection title={t`In Your Library`} icon={<IconBooks className="text-primary size-5" />}>
|
||||
<FeedSection
|
||||
title={t`In Your Library`}
|
||||
icon={<IconBooks className="text-primary size-5" />}
|
||||
seeAllLink="/library"
|
||||
>
|
||||
<TitleGrid items={items} />
|
||||
<div ref={sentinelRef} />
|
||||
{isFetchingNextPage && <TitleGridSectionSkeleton />}
|
||||
</FeedSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconLoader, IconSearch } from "@tabler/icons-react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { FeedSection } from "@/components/dashboard/feed-section";
|
||||
import { TitleGrid } from "@/components/dashboard/title-grid";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
const DECADE_PRESETS = [
|
||||
{ label: "2020s", min: 2020, max: 2029 },
|
||||
{ label: "2010s", min: 2010, max: 2019 },
|
||||
{ label: "2000s", min: 2000, max: 2009 },
|
||||
{ label: "1990s", min: 1990, max: 1999 },
|
||||
{ label: "1980s", min: 1980, max: 1989 },
|
||||
{ label: "1970s", min: 1970, max: 1979 },
|
||||
{ label: "Pre-1970", min: 1900, max: 1969 },
|
||||
] as const;
|
||||
|
||||
const RATING_PRESETS = [
|
||||
{ label: "7+", value: 7 },
|
||||
{ label: "6+", value: 6 },
|
||||
{ label: "5+", value: 5 },
|
||||
] as const;
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "popularity.desc", labelKey: "Most popular" },
|
||||
{ value: "vote_average.desc", labelKey: "Highest rated" },
|
||||
{ value: "primary_release_date.desc", labelKey: "Newest" },
|
||||
{ value: "primary_release_date.asc", labelKey: "Oldest" },
|
||||
] as const;
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ code: "en", name: "English" },
|
||||
{ code: "es", name: "Spanish" },
|
||||
{ code: "fr", name: "French" },
|
||||
{ code: "de", name: "German" },
|
||||
{ code: "ja", name: "Japanese" },
|
||||
{ code: "ko", name: "Korean" },
|
||||
{ code: "zh", name: "Chinese" },
|
||||
{ code: "hi", name: "Hindi" },
|
||||
{ code: "it", name: "Italian" },
|
||||
{ code: "pt", name: "Portuguese" },
|
||||
] as const;
|
||||
|
||||
export function DiscoverSection() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [type, setType] = useState<"movie" | "tv">("movie");
|
||||
const [genreId, setGenreId] = useState<number | undefined>(undefined);
|
||||
const [yearMin, setYearMin] = useState<number | undefined>(undefined);
|
||||
const [yearMax, setYearMax] = useState<number | undefined>(undefined);
|
||||
const [ratingMin, setRatingMin] = useState<number | undefined>(undefined);
|
||||
type DiscoverSortBy =
|
||||
| "popularity.desc"
|
||||
| "vote_average.desc"
|
||||
| "primary_release_date.desc"
|
||||
| "primary_release_date.asc";
|
||||
const [sortBy, setSortBy] = useState<DiscoverSortBy | undefined>(undefined);
|
||||
const [language, setLanguage] = useState<string | undefined>(undefined);
|
||||
const [providerId, setProviderId] = useState<number | undefined>(undefined);
|
||||
const [selectedPlatformId, setSelectedPlatformId] = useState("");
|
||||
|
||||
const { data: genreData } = useQuery(orpc.explore.genres.queryOptions({ input: { type } }));
|
||||
const { data: providerData } = useQuery(orpc.platforms.list.queryOptions());
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery(
|
||||
orpc.discover.infiniteOptions({
|
||||
input: (pageParam: number) => ({
|
||||
type,
|
||||
genreId,
|
||||
yearMin,
|
||||
yearMax,
|
||||
ratingMin,
|
||||
sortBy,
|
||||
language,
|
||||
providerId,
|
||||
page: pageParam,
|
||||
}),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
});
|
||||
|
||||
const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data?.pages]);
|
||||
|
||||
const genres = genreData?.genres ?? [];
|
||||
const providers = providerData?.platforms ?? [];
|
||||
|
||||
const sortLabels: Record<string, string> = {
|
||||
"popularity.desc": t`Most popular`,
|
||||
"vote_average.desc": t`Highest rated`,
|
||||
"primary_release_date.desc": t`Newest`,
|
||||
"primary_release_date.asc": t`Oldest`,
|
||||
};
|
||||
|
||||
const languageNames: Record<string, string> = {
|
||||
en: t`English`,
|
||||
es: t`Spanish`,
|
||||
fr: t`French`,
|
||||
de: t`German`,
|
||||
ja: t`Japanese`,
|
||||
ko: t`Korean`,
|
||||
zh: t`Chinese`,
|
||||
hi: t`Hindi`,
|
||||
it: t`Italian`,
|
||||
pt: t`Portuguese`,
|
||||
};
|
||||
|
||||
function handleDecadeChange(value: string | null) {
|
||||
if (!value) {
|
||||
setYearMin(undefined);
|
||||
setYearMax(undefined);
|
||||
return;
|
||||
}
|
||||
const preset = DECADE_PRESETS.find((d) => String(d.min) === value);
|
||||
if (preset) {
|
||||
setYearMin(preset.min);
|
||||
setYearMax(preset.max);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRatingChange(value: string | null) {
|
||||
if (!value) {
|
||||
setRatingMin(undefined);
|
||||
return;
|
||||
}
|
||||
setRatingMin(Number(value));
|
||||
}
|
||||
|
||||
function handleSortChange(value: string | null) {
|
||||
setSortBy((value || undefined) as DiscoverSortBy | undefined);
|
||||
}
|
||||
|
||||
function handleLanguageChange(value: string | null) {
|
||||
setLanguage(value || undefined);
|
||||
}
|
||||
|
||||
function handleProviderChange(value: string | null) {
|
||||
setSelectedPlatformId(value ?? "");
|
||||
if (!value) {
|
||||
setProviderId(undefined);
|
||||
return;
|
||||
}
|
||||
// Resolve platform UUID to TMDB provider ID for the discover API
|
||||
const platform = providers.find((p) => p.id === value);
|
||||
setProviderId(platform?.tmdbProviderId ?? undefined);
|
||||
}
|
||||
|
||||
function handleGenreChange(value: string | null) {
|
||||
if (!value) {
|
||||
setGenreId(undefined);
|
||||
return;
|
||||
}
|
||||
setGenreId(Number(value));
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedSection title={t`Discover`} icon={<IconSearch className="text-primary size-5" />}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Type toggle: Movie | TV */}
|
||||
<ToggleGroup
|
||||
value={[type]}
|
||||
onValueChange={(values) => {
|
||||
const next = values.find((v) => v !== type);
|
||||
if (next === "movie" || next === "tv") {
|
||||
setType(next);
|
||||
setGenreId(undefined);
|
||||
setProviderId(undefined);
|
||||
setSelectedPlatformId("");
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="movie">{t`Movie`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tv">{t`TV`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="bg-border/30 mx-0.5 hidden h-5 w-px sm:block" />
|
||||
|
||||
{/* Genre select */}
|
||||
<Select
|
||||
value={genreId != null ? String(genreId) : ""}
|
||||
onValueChange={handleGenreChange}
|
||||
modal={false}
|
||||
aria-label={t`Genre`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={genreId != null ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Genre`;
|
||||
const genre = genres.find((g) => String(g.id) === value);
|
||||
return genre?.name ?? t`Genre`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`All genres`}</SelectItem>
|
||||
{genres.map((genre) => (
|
||||
<SelectItem key={genre.id} value={String(genre.id)}>
|
||||
{genre.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Year select (decade presets) */}
|
||||
<Select
|
||||
value={yearMin != null ? String(yearMin) : ""}
|
||||
onValueChange={handleDecadeChange}
|
||||
modal={false}
|
||||
aria-label={t`Decade`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={yearMin != null ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Year`;
|
||||
const preset = DECADE_PRESETS.find((d) => String(d.min) === value);
|
||||
if (preset?.min === 1900) return t`Pre-1970`;
|
||||
return preset?.label ?? t`Year`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`Any year`}</SelectItem>
|
||||
{DECADE_PRESETS.map((d) => (
|
||||
<SelectItem key={d.min} value={String(d.min)}>
|
||||
{d.min === 1900 ? t`Pre-1970` : d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Rating select (minimum TMDB rating) */}
|
||||
<Select
|
||||
value={ratingMin != null ? String(ratingMin) : ""}
|
||||
onValueChange={handleRatingChange}
|
||||
modal={false}
|
||||
aria-label={t`Rating`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={ratingMin != null ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Rating`;
|
||||
return `${value}+`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`Any rating`}</SelectItem>
|
||||
{RATING_PRESETS.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Sort select */}
|
||||
<Select
|
||||
value={sortBy ?? ""}
|
||||
onValueChange={handleSortChange}
|
||||
modal={false}
|
||||
aria-label={t`Sort`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={sortBy ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Sort`;
|
||||
return sortLabels[value] ?? t`Sort`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`Default`}</SelectItem>
|
||||
{SORT_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{sortLabels[s.value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Language select */}
|
||||
<Select
|
||||
value={language ?? ""}
|
||||
onValueChange={handleLanguageChange}
|
||||
modal={false}
|
||||
aria-label={t`Language`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={language ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Language`;
|
||||
return languageNames[value] ?? t`Language`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`Any language`}</SelectItem>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{languageNames[lang.code]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Provider select */}
|
||||
<Select
|
||||
value={selectedPlatformId}
|
||||
onValueChange={handleProviderChange}
|
||||
modal={false}
|
||||
aria-label={t`Provider`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={selectedPlatformId ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Provider`;
|
||||
const platform = providers.find((p) => p.id === value);
|
||||
return platform?.name ?? t`Provider`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`All providers`}</SelectItem>
|
||||
{providers
|
||||
.filter((p) => p.tmdbProviderId != null)
|
||||
.map((platform) => (
|
||||
<SelectItem key={platform.id} value={platform.id}>
|
||||
{platform.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<IconLoader className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||
{t`No titles found. Try adjusting your filters.`}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<TitleGrid items={items} />
|
||||
<div ref={sentinelRef} />
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<IconLoader className="text-muted-foreground size-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FeedSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { plural } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
export interface LibraryFiltersProps {
|
||||
filters: {
|
||||
statuses?: string[];
|
||||
type?: string;
|
||||
genreId?: number;
|
||||
ratingMin?: number;
|
||||
ratingMax?: number;
|
||||
yearMin?: number;
|
||||
yearMax?: number;
|
||||
contentRating?: string;
|
||||
onMyServices?: boolean;
|
||||
};
|
||||
onFilterChange: (key: string, value: unknown) => void;
|
||||
onClearAll: () => void;
|
||||
activeFilterCount: number;
|
||||
}
|
||||
|
||||
const DECADES = [
|
||||
{ label: "2020s", yearMin: 2020, yearMax: 2029 },
|
||||
{ label: "2010s", yearMin: 2010, yearMax: 2019 },
|
||||
{ label: "2000s", yearMin: 2000, yearMax: 2009 },
|
||||
{ label: "90s", yearMin: 1990, yearMax: 1999 },
|
||||
{ label: "80s", yearMin: 1980, yearMax: 1989 },
|
||||
] as const;
|
||||
|
||||
const CONTENT_RATINGS = [
|
||||
"G",
|
||||
"PG",
|
||||
"PG-13",
|
||||
"R",
|
||||
"NC-17",
|
||||
"TV-Y",
|
||||
"TV-Y7",
|
||||
"TV-G",
|
||||
"TV-PG",
|
||||
"TV-14",
|
||||
"TV-MA",
|
||||
];
|
||||
|
||||
function Divider() {
|
||||
return <div className="bg-border/30 mx-1 hidden h-5 w-px sm:block" />;
|
||||
}
|
||||
|
||||
export function LibraryFilters({
|
||||
filters,
|
||||
onFilterChange,
|
||||
onClearAll,
|
||||
activeFilterCount,
|
||||
}: LibraryFiltersProps) {
|
||||
const { t } = useLingui();
|
||||
const { data: genreData } = useQuery(orpc.library.genres.queryOptions());
|
||||
|
||||
const statuses = useMemo(
|
||||
() => [
|
||||
{ value: "in_watchlist", label: t`Watchlist` },
|
||||
{ value: "watching", label: t`Watching` },
|
||||
{ value: "caught_up", label: t`Caught Up` },
|
||||
{ value: "completed", label: t`Completed` },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const types = useMemo(
|
||||
() => [
|
||||
{ value: undefined, label: t`All` },
|
||||
{ value: "movie", label: t`Movie` },
|
||||
{ value: "tv", label: t`TV` },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
function toggleStatus(status: string) {
|
||||
const current = filters.statuses ?? [];
|
||||
const next = current.includes(status)
|
||||
? current.filter((s) => s !== status)
|
||||
: [...current, status];
|
||||
onFilterChange("statuses", next.length > 0 ? next : undefined);
|
||||
}
|
||||
|
||||
// Status dropdown label
|
||||
const statusLabel = useMemo(() => {
|
||||
const selected = filters.statuses ?? [];
|
||||
if (selected.length === 0) return t`Status`;
|
||||
if (selected.length === 1) {
|
||||
return statuses.find((s) => s.value === selected[0])?.label ?? t`Status`;
|
||||
}
|
||||
return plural(selected.length, { one: "# status", other: "# statuses" });
|
||||
}, [filters.statuses, statuses, t]);
|
||||
|
||||
// Year dropdown value
|
||||
const activeDecade = DECADES.find(
|
||||
(d) => filters.yearMin === d.yearMin && filters.yearMax === d.yearMax,
|
||||
);
|
||||
const isOlderActive = filters.yearMax === 1979 && filters.yearMin === undefined;
|
||||
const yearValue = activeDecade ? String(activeDecade.yearMin) : isOlderActive ? "older" : "";
|
||||
|
||||
function handleYearChange(value: string | null) {
|
||||
if (!value) {
|
||||
onFilterChange("yearMin", undefined);
|
||||
onFilterChange("yearMax", undefined);
|
||||
return;
|
||||
}
|
||||
if (value === "older") {
|
||||
onFilterChange("yearMin", undefined);
|
||||
onFilterChange("yearMax", 1979);
|
||||
return;
|
||||
}
|
||||
const decade = DECADES.find((d) => String(d.yearMin) === value);
|
||||
if (decade) {
|
||||
onFilterChange("yearMin", decade.yearMin);
|
||||
onFilterChange("yearMax", decade.yearMax);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 py-1">
|
||||
{/* Type pills */}
|
||||
{types.map((typ) => (
|
||||
<Button
|
||||
key={typ.value ?? "all"}
|
||||
variant={filters.type === typ.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => onFilterChange("type", typ.value)}
|
||||
>
|
||||
{typ.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Status — multi-select dropdown */}
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-active={(filters.statuses ?? []).length > 0 ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
{statusLabel}
|
||||
<IconChevronDown aria-hidden={true} className="text-muted-foreground size-3" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-44 p-1">
|
||||
{statuses.map((s) => {
|
||||
const isActive = (filters.statuses ?? []).includes(s.value);
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => toggleStatus(s.value)}
|
||||
className="hover:bg-muted/50 flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<div
|
||||
className={`flex size-3.5 items-center justify-center rounded border transition-colors ${
|
||||
isActive ? "border-primary bg-primary text-primary-foreground" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{isActive && <IconCheck className="size-2.5" strokeWidth={3} />}
|
||||
</div>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Genre */}
|
||||
<Select
|
||||
value={filters.genreId !== undefined ? String(filters.genreId) : ""}
|
||||
onValueChange={(v) => onFilterChange("genreId", v === "" ? undefined : Number(v))}
|
||||
modal={false}
|
||||
aria-label={t`Genre`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={filters.genreId != null ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Genre`;
|
||||
const genre = genreData?.genres.find((g) => String(g.id) === value);
|
||||
return genre?.name ?? t`Genre`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`All genres`}</SelectItem>
|
||||
{genreData?.genres.map((genre) => (
|
||||
<SelectItem key={genre.id} value={String(genre.id)}>
|
||||
{genre.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Rating */}
|
||||
<Select
|
||||
value={filters.ratingMin !== undefined ? String(filters.ratingMin) : ""}
|
||||
onValueChange={(v) => onFilterChange("ratingMin", v === "" ? undefined : Number(v))}
|
||||
modal={false}
|
||||
aria-label={t`Rating`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={filters.ratingMin != null ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Rating`;
|
||||
return `${value}\u2605+`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`Any`}</SelectItem>
|
||||
<SelectItem value="1">1★+</SelectItem>
|
||||
<SelectItem value="2">2★+</SelectItem>
|
||||
<SelectItem value="3">3★+</SelectItem>
|
||||
<SelectItem value="4">4★+</SelectItem>
|
||||
<SelectItem value="5">5★</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Year */}
|
||||
<Select value={yearValue} onValueChange={handleYearChange} modal={false} aria-label={t`Year`}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={yearValue ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return t`Year`;
|
||||
if (value === "older") return t`Pre-1980`;
|
||||
const decade = DECADES.find((d) => String(d.yearMin) === value);
|
||||
return decade?.label ?? t`Year`;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`Any year`}</SelectItem>
|
||||
{DECADES.map((d) => (
|
||||
<SelectItem key={d.yearMin} value={String(d.yearMin)}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="older">{t`Pre-1980`}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Content Rating */}
|
||||
<Select
|
||||
value={filters.contentRating ?? ""}
|
||||
onValueChange={(v) => onFilterChange("contentRating", v === "" ? undefined : v)}
|
||||
modal={false}
|
||||
aria-label={t`Content rating`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={filters.contentRating ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>{(value: string | null) => (value ? value : t`Age`)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectItem value="">{t`All`}</SelectItem>
|
||||
{CONTENT_RATINGS.map((rating) => (
|
||||
<SelectItem key={rating} value={rating}>
|
||||
{rating}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* On my services */}
|
||||
<label className="flex cursor-pointer items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={filters.onMyServices ?? false}
|
||||
onCheckedChange={(checked) => onFilterChange("onMyServices", checked || undefined)}
|
||||
aria-label={t`On my services`}
|
||||
/>
|
||||
<span className="text-muted-foreground text-[11px]">{t`On my services`}</span>
|
||||
</label>
|
||||
|
||||
{/* Clear */}
|
||||
{activeFilterCount > 0 && (
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-muted-foreground hover:text-foreground text-xs transition-colors"
|
||||
>
|
||||
{t`Clear`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { plural } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconChevronDown, IconFilter, IconSearch, IconSortDescending } from "@tabler/icons-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { LibraryFilters, type LibraryFiltersProps } from "@/components/library/library-filters";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface LibraryToolbarProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
filters: LibraryFiltersProps["filters"];
|
||||
onFilterChange: (key: string, value: unknown) => void;
|
||||
onClearAll: () => void;
|
||||
sortBy: string;
|
||||
sortDirection: string;
|
||||
onSortChange: (sortBy: string, sortDirection: string) => void;
|
||||
totalResults: number;
|
||||
activeFilterCount: number;
|
||||
}
|
||||
|
||||
export function LibraryToolbar({
|
||||
search,
|
||||
onSearchChange,
|
||||
filters,
|
||||
onFilterChange,
|
||||
onClearAll,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
onSortChange,
|
||||
totalResults,
|
||||
activeFilterCount,
|
||||
}: LibraryToolbarProps) {
|
||||
const { t } = useLingui();
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ label: t`Date Added`, sortBy: "added_at", direction: "desc" },
|
||||
{ label: t`Title A-Z`, sortBy: "title", direction: "asc" },
|
||||
{ label: t`Title Z-A`, sortBy: "title", direction: "desc" },
|
||||
{ label: t`Release Date`, sortBy: "release_date", direction: "desc" },
|
||||
{ label: t`User Rating`, sortBy: "user_rating", direction: "desc" },
|
||||
{ label: t`TMDB Rating`, sortBy: "vote_average", direction: "desc" },
|
||||
{ label: t`Popularity`, sortBy: "popularity", direction: "desc" },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-xs">
|
||||
<IconSearch
|
||||
aria-hidden={true}
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t`Search library...`}
|
||||
className="pl-7"
|
||||
aria-label={t`Search library`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{plural(totalResults, { one: "# result", other: "# results" })}
|
||||
</span>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<IconFilter aria-hidden={true} className="size-3.5" />
|
||||
{t`Filters`}
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge variant="default" className="ml-1 size-4 justify-center px-0">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
<IconChevronDown
|
||||
aria-hidden={true}
|
||||
className={`text-muted-foreground size-3 transition-transform duration-200 ${filtersOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<IconSortDescending aria-hidden={true} className="size-3.5" />
|
||||
{t`Sort`}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
{sortOptions.map((option) => {
|
||||
const isActive = sortBy === option.sortBy && sortDirection === option.direction;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${option.sortBy}-${option.direction}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSortChange(option.sortBy, option.direction)}
|
||||
>
|
||||
<span className={isActive ? "text-primary font-medium" : ""}>
|
||||
{option.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible filter strip */}
|
||||
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
|
||||
<div className="border-border/15 border-b pt-3 pb-3">
|
||||
<LibraryFilters
|
||||
filters={filters}
|
||||
onFilterChange={onFilterChange}
|
||||
onClearAll={onClearAll}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
IconBooks,
|
||||
IconCalendarEvent,
|
||||
IconCompass,
|
||||
IconHome,
|
||||
@@ -119,6 +120,7 @@ export function NavBar({
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/dashboard", label: t`Home` },
|
||||
{ href: "/library", label: t`Library` },
|
||||
{ href: "/explore", label: t`Explore` },
|
||||
{ href: "/upcoming", label: t`Upcoming` },
|
||||
] as const;
|
||||
@@ -281,6 +283,7 @@ export function MobileTabBar() {
|
||||
|
||||
const mobileTabs = [
|
||||
{ href: "/dashboard", label: t`Home`, icon: IconHome },
|
||||
{ href: "/library", label: t`Library`, icon: IconBooks },
|
||||
{ href: "/explore", label: t`Explore`, icon: IconCompass },
|
||||
{ href: "/upcoming", label: t`Upcoming`, icon: IconCalendarEvent },
|
||||
{ href: "/settings", label: t`Settings`, icon: IconSettings },
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import type { Platform } from "@sofa/api/schemas";
|
||||
|
||||
interface PlatformGridProps {
|
||||
platforms: Platform[];
|
||||
selectedIds: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PlatformGrid({ platforms, selectedIds, onToggle }: PlatformGridProps) {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-4 md:grid-cols-5">
|
||||
{platforms.map((platform) => {
|
||||
const isSelected = selectedIds.has(platform.id);
|
||||
return (
|
||||
<button
|
||||
key={platform.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(platform.id)}
|
||||
className={`group relative flex flex-col items-center gap-2 rounded-xl border p-3 motion-safe:transition-colors motion-safe:duration-150 ${
|
||||
isSelected
|
||||
? "border-primary/50 bg-primary/8"
|
||||
: "border-border/20 hover:border-primary/30 hover:bg-primary/5"
|
||||
}`}
|
||||
aria-label={(() => {
|
||||
const name = platform.name;
|
||||
return t`Toggle ${name}`;
|
||||
})()}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{/* Check badge */}
|
||||
<AnimatePresence>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 20 }}
|
||||
className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex size-4 items-center justify-center rounded-full"
|
||||
>
|
||||
<IconCheck className="size-2.5" strokeWidth={3} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Logo */}
|
||||
{platform.logoPath ? (
|
||||
<img
|
||||
src={platform.logoPath}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-10 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg text-xs font-medium">
|
||||
{platform.name.slice(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span className="text-muted-foreground line-clamp-1 w-full text-center text-[11px] leading-tight">
|
||||
{platform.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -41,6 +42,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { authClient, signOut } from "@/lib/auth/client";
|
||||
import { getErrorMessage } from "@/lib/error-messages";
|
||||
import { useAppForm } from "@/lib/form";
|
||||
import { client, orpc } from "@/lib/orpc/client";
|
||||
import type { NormalizedImport } from "@sofa/api/schemas";
|
||||
import { formatDate } from "@sofa/i18n/format";
|
||||
@@ -788,49 +790,39 @@ function ImportOptionCheckbox({
|
||||
function ChangePasswordDialog() {
|
||||
const { t } = useLingui();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [revokeOtherSessions, setRevokeOtherSessions] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
function resetForm() {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setRevokeOtherSessions(false);
|
||||
const changePasswordSchema = useMemo(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, t`Current password is required`),
|
||||
newPassword: z.string().min(8, t`New password must be at least 8 characters`),
|
||||
confirmPassword: z.string().min(1, t`Please confirm your password`),
|
||||
revokeOtherSessions: z.boolean(),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t`Passwords do not match`,
|
||||
path: ["confirmPassword"],
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
revokeOtherSessions: false,
|
||||
},
|
||||
validators: { onSubmit: changePasswordSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
setError("");
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) resetForm();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!currentPassword) {
|
||||
setError(t`Current password is required`);
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError(t`New password must be at least 8 characters`);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t`Passwords do not match`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions,
|
||||
currentPassword: value.currentPassword,
|
||||
newPassword: value.newPassword,
|
||||
revokeOtherSessions: value.revokeOtherSessions,
|
||||
});
|
||||
if (result.error) {
|
||||
setError(t`Failed to change password`);
|
||||
@@ -840,8 +832,15 @@ function ChangePasswordDialog() {
|
||||
handleOpenChange(false);
|
||||
} catch {
|
||||
setError(t`Something went wrong`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
form.reset();
|
||||
setError("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -861,7 +860,13 @@ function ChangePasswordDialog() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="grid gap-3">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="grid gap-3"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<IconAlertTriangle />
|
||||
@@ -869,6 +874,8 @@ function ChangePasswordDialog() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form.Field name="currentPassword">
|
||||
{(field) => (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="current-password">
|
||||
<Trans>Current password</Trans>
|
||||
@@ -877,12 +884,24 @@ function ChangePasswordDialog() {
|
||||
id="current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
aria-invalid={field.state.meta.errors.length > 0 || undefined}
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-destructive text-xs">
|
||||
{field.state.meta.errors
|
||||
.map((e) => (typeof e === "string" ? e : ""))
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="newPassword">
|
||||
{(field) => (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="new-password">
|
||||
<Trans>New password</Trans>
|
||||
@@ -891,12 +910,24 @@ function ChangePasswordDialog() {
|
||||
id="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
aria-invalid={field.state.meta.errors.length > 0 || undefined}
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-destructive text-xs">
|
||||
{field.state.meta.errors
|
||||
.map((e) => (typeof e === "string" ? e : ""))
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="confirmPassword">
|
||||
{(field) => (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="confirm-password">
|
||||
<Trans>Confirm new password</Trans>
|
||||
@@ -905,33 +936,52 @@ function ChangePasswordDialog() {
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
aria-invalid={field.state.meta.errors.length > 0 || undefined}
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-destructive text-xs">
|
||||
{field.state.meta.errors
|
||||
.map((e) => (typeof e === "string" ? e : ""))
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="revokeOtherSessions">
|
||||
{(field) => (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="revoke-sessions"
|
||||
checked={revokeOtherSessions}
|
||||
onCheckedChange={(checked) => setRevokeOtherSessions(checked === true)}
|
||||
disabled={isSubmitting}
|
||||
checked={field.state.value}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
<Label htmlFor="revoke-sessions" className="cursor-pointer">
|
||||
<Trans>Sign out of other sessions</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
|
||||
>
|
||||
{({ canSubmit, isSubmitting }) => (
|
||||
<DialogFooter className="pt-2">
|
||||
<DialogClose render={<Button variant="outline" />}>
|
||||
<Trans>Cancel</Trans>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Button type="submit" disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting && <Spinner className="size-3.5" />}
|
||||
<Trans>Update password</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { IconChevronDown, IconDeviceTv } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { PlatformGrid } from "@/components/platforms/platform-grid";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { client, orpc } from "@/lib/orpc/client";
|
||||
|
||||
export function StreamingServicesSection() {
|
||||
const { t } = useLingui();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [saved, setSaved] = useState(false);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const selectedIdsRef = useRef<Set<string>>(selectedIds);
|
||||
const saveCounterRef = useRef(0);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const platformsQuery = useQuery(orpc.platforms.list.queryOptions());
|
||||
const userPlatformsQuery = useQuery(orpc.account.platforms.queryOptions());
|
||||
|
||||
// Initialize selected IDs from server data
|
||||
useEffect(() => {
|
||||
if (userPlatformsQuery.data && !initialized.current) {
|
||||
const ids = new Set(userPlatformsQuery.data.platformIds);
|
||||
setSelectedIds(ids);
|
||||
selectedIdsRef.current = ids;
|
||||
initialized.current = true;
|
||||
}
|
||||
}, [userPlatformsQuery.data]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
clearTimeout(savedTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
const counter = ++saveCounterRef.current;
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
const ids = selectedIdsRef.current;
|
||||
await client.account.updatePlatforms({ platformIds: [...ids] });
|
||||
// Only show "Saved" if no newer save was triggered
|
||||
if (saveCounterRef.current !== counter) return;
|
||||
setSaved(true);
|
||||
clearTimeout(savedTimerRef.current);
|
||||
savedTimerRef.current = setTimeout(setSaved, 1500, false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
selectedIdsRef.current = next;
|
||||
save();
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const platforms = platformsQuery.data?.platforms ?? [];
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CardContent className={open ? "pb-4" : ""}>
|
||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<IconDeviceTv className="text-primary size-4" />
|
||||
</div>
|
||||
<div className="text-start">
|
||||
<CardTitle>
|
||||
<Trans>Streaming Services</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedCount > 0 ? (
|
||||
t`${selectedCount} selected`
|
||||
) : (
|
||||
<Trans>Choose your subscriptions</Trans>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AnimatePresence>
|
||||
{saved && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: 4 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 4 }}
|
||||
className="text-primary text-xs"
|
||||
>
|
||||
<Trans>Saved</Trans>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<IconChevronDown
|
||||
aria-hidden={true}
|
||||
className={`text-muted-foreground size-4 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</CardContent>
|
||||
|
||||
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
|
||||
<CardContent className="border-border/30 border-t pt-4">
|
||||
<PlatformGrid platforms={platforms} selectedIds={selectedIds} onToggle={handleToggle} />
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
|
||||
{offers.map((offer) =>
|
||||
offer.watchUrl ? (
|
||||
<a
|
||||
key={offer.providerId}
|
||||
key={offer.platformId}
|
||||
href={offer.watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -103,7 +103,7 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
|
||||
<span className="text-popover-foreground truncate text-xs">{offer.providerName}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div key={offer.providerId} className="flex items-center gap-2.5 px-2 py-1.5">
|
||||
<div key={offer.platformId} className="flex items-center gap-2.5 px-2 py-1.5">
|
||||
<OverflowProviderIcon offer={offer} />
|
||||
<span className="text-popover-foreground truncate text-xs">{offer.providerName}</span>
|
||||
</div>
|
||||
@@ -114,6 +114,48 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OffersByType({
|
||||
offers,
|
||||
offerLabels,
|
||||
}: {
|
||||
offers: AvailabilityOffer[];
|
||||
offerLabels: Record<string, string>;
|
||||
}) {
|
||||
const byType: Record<string, AvailabilityOffer[]> = {};
|
||||
for (const offer of offers) {
|
||||
if (!byType[offer.offerType]) byType[offer.offerType] = [];
|
||||
byType[offer.offerType].push(offer);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(byType).map(([type, typeOffers]) => {
|
||||
const visible = typeOffers.slice(0, MAX_VISIBLE);
|
||||
const overflow = typeOffers.slice(MAX_VISIBLE);
|
||||
|
||||
return (
|
||||
<div key={type} className="space-y-1.5">
|
||||
<span className="text-muted-foreground/60 text-[10px] font-medium tracking-wider uppercase">
|
||||
{offerLabels[type] ?? type}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
{visible.map((offer) => (
|
||||
<ProviderBadge
|
||||
key={offer.platformId}
|
||||
name={offer.providerName}
|
||||
logoPath={offer.logoPath}
|
||||
watchUrl={offer.watchUrl}
|
||||
/>
|
||||
))}
|
||||
{overflow.length > 0 && <OverflowBadge offers={overflow} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) {
|
||||
const { t } = useLingui();
|
||||
const offerLabels: Record<string, string> = {
|
||||
@@ -123,44 +165,44 @@ export function TitleAvailability({ availability }: { availability: Availability
|
||||
free: t`Free`,
|
||||
ads: t`With Ads`,
|
||||
};
|
||||
const availByType: Record<string, AvailabilityOffer[]> = {};
|
||||
for (const offer of availability) {
|
||||
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
|
||||
availByType[offer.offerType].push(offer);
|
||||
|
||||
if (availability.length === 0) return null;
|
||||
|
||||
const userOffers = availability.filter((o) => o.isUserSubscribed);
|
||||
const otherOffers = availability.filter((o) => !o.isUserSubscribed);
|
||||
|
||||
// If user has matching subscriptions, show split view
|
||||
if (userOffers.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4 pt-1">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
<Trans>On your services</Trans>
|
||||
</h2>
|
||||
<OffersByType offers={userOffers} offerLabels={offerLabels} />
|
||||
</div>
|
||||
|
||||
{otherOffers.length > 0 && (
|
||||
<div className="border-border/30 space-y-2 border-t pt-3">
|
||||
<h2 className="text-muted-foreground/60 text-[10px] font-medium tracking-wider uppercase">
|
||||
<Trans>Also available on</Trans>
|
||||
</h2>
|
||||
<div className="opacity-60">
|
||||
<OffersByType offers={otherOffers} offerLabels={offerLabels} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(availByType).length === 0) return null;
|
||||
|
||||
// Default: single "Where to Watch" section
|
||||
return (
|
||||
<div className="space-y-2 pt-1">
|
||||
<h2 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
<Trans>Where to Watch</Trans>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(availByType).map(([type, offers]) => {
|
||||
const visible = offers.slice(0, MAX_VISIBLE);
|
||||
const overflow = offers.slice(MAX_VISIBLE);
|
||||
|
||||
return (
|
||||
<div key={type} className="space-y-1.5">
|
||||
<span className="text-muted-foreground/60 text-[10px] font-medium tracking-wider uppercase">
|
||||
{offerLabels[type] ?? type}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
{visible.map((offer) => (
|
||||
<ProviderBadge
|
||||
key={offer.providerId}
|
||||
name={offer.providerName}
|
||||
logoPath={offer.logoPath}
|
||||
watchUrl={offer.watchUrl}
|
||||
/>
|
||||
))}
|
||||
{overflow.length > 0 && <OverflowBadge offers={overflow} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<OffersByType offers={availability} offerLabels={offerLabels} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFormHookContexts } from "@tanstack/react-form";
|
||||
|
||||
export const { fieldContext, formContext, useFieldContext, useFormContext } =
|
||||
createFormHookContexts();
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useId } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { useFieldContext, useFormContext } from "./context";
|
||||
|
||||
function normalizeErrors(errors: unknown[]): Array<{ message?: string }> {
|
||||
return errors.map((e) => (typeof e === "string" ? { message: e } : (e as { message?: string })));
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
description,
|
||||
className,
|
||||
...inputProps
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
} & Omit<React.ComponentProps<"input">, "value" | "onChange" | "onBlur">) {
|
||||
const field = useFieldContext<string>();
|
||||
const id = useId();
|
||||
const hasErrors = field.state.meta.errors.length > 0;
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
<Input
|
||||
id={id}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
aria-invalid={hasErrors || undefined}
|
||||
className={className}
|
||||
{...inputProps}
|
||||
/>
|
||||
<FieldError errors={normalizeErrors(field.state.meta.errors)} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaField({
|
||||
label,
|
||||
description,
|
||||
className,
|
||||
...textareaProps
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
} & Omit<React.ComponentProps<"textarea">, "value" | "onChange" | "onBlur">) {
|
||||
const field = useFieldContext<string>();
|
||||
const id = useId();
|
||||
const hasErrors = field.state.meta.errors.length > 0;
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
<Textarea
|
||||
id={id}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
aria-invalid={hasErrors || undefined}
|
||||
className={className}
|
||||
{...textareaProps}
|
||||
/>
|
||||
<FieldError errors={normalizeErrors(field.state.meta.errors)} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxField({ label, description }: { label: ReactNode; description?: ReactNode }) {
|
||||
const field = useFieldContext<boolean>();
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id={id} checked={field.state.value} onCheckedChange={field.handleChange} />
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchField({
|
||||
label,
|
||||
description,
|
||||
size,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
const field = useFieldContext<boolean>();
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
id={id}
|
||||
checked={field.state.value}
|
||||
onCheckedChange={field.handleChange}
|
||||
size={size}
|
||||
/>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton({
|
||||
children,
|
||||
loadingChildren,
|
||||
...buttonProps
|
||||
}: {
|
||||
children: ReactNode;
|
||||
loadingChildren?: ReactNode;
|
||||
} & Omit<React.ComponentProps<typeof Button>, "type" | "disabled">) {
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<form.Subscribe
|
||||
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
|
||||
>
|
||||
{({ canSubmit, isSubmitting }) => (
|
||||
<Button type="submit" disabled={!canSubmit || isSubmitting} {...buttonProps}>
|
||||
{isSubmitting && <Spinner className="size-3.5" />}
|
||||
{isSubmitting ? (loadingChildren ?? children) : children}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
);
|
||||
}
|
||||
|
||||
export { CheckboxField, SwitchField, SubmitButton, TextField, TextareaField };
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createFormHook } from "@tanstack/react-form";
|
||||
|
||||
import { fieldContext, formContext } from "./context";
|
||||
import { CheckboxField, SubmitButton, SwitchField, TextareaField, TextField } from "./fields";
|
||||
|
||||
const { useAppForm, withForm } = createFormHook({
|
||||
fieldComponents: {
|
||||
TextField,
|
||||
TextareaField,
|
||||
CheckboxField,
|
||||
SwitchField,
|
||||
},
|
||||
formComponents: {
|
||||
SubmitButton,
|
||||
},
|
||||
fieldContext,
|
||||
formContext,
|
||||
});
|
||||
|
||||
export { useAppForm, withForm };
|
||||
@@ -17,6 +17,8 @@ import { Route as AuthRegisterRouteImport } from './routes/_auth/register'
|
||||
import { Route as AuthLoginRouteImport } from './routes/_auth/login'
|
||||
import { Route as AppUpcomingRouteImport } from './routes/_app/upcoming'
|
||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||
import { Route as AppOnboardingRouteImport } from './routes/_app/onboarding'
|
||||
import { Route as AppLibraryRouteImport } from './routes/_app/library'
|
||||
import { Route as AppExploreRouteImport } from './routes/_app/explore'
|
||||
import { Route as AppDashboardRouteImport } from './routes/_app/dashboard'
|
||||
import { Route as AppTitlesIdRouteImport } from './routes/_app/titles.$id'
|
||||
@@ -60,6 +62,16 @@ const AppSettingsRoute = AppSettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppOnboardingRoute = AppOnboardingRouteImport.update({
|
||||
id: '/onboarding',
|
||||
path: '/onboarding',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppLibraryRoute = AppLibraryRouteImport.update({
|
||||
id: '/library',
|
||||
path: '/library',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppExploreRoute = AppExploreRouteImport.update({
|
||||
id: '/explore',
|
||||
path: '/explore',
|
||||
@@ -86,6 +98,8 @@ export interface FileRoutesByFullPath {
|
||||
'/setup': typeof SetupRoute
|
||||
'/dashboard': typeof AppDashboardRoute
|
||||
'/explore': typeof AppExploreRoute
|
||||
'/library': typeof AppLibraryRoute
|
||||
'/onboarding': typeof AppOnboardingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/upcoming': typeof AppUpcomingRoute
|
||||
'/login': typeof AuthLoginRoute
|
||||
@@ -98,6 +112,8 @@ export interface FileRoutesByTo {
|
||||
'/setup': typeof SetupRoute
|
||||
'/dashboard': typeof AppDashboardRoute
|
||||
'/explore': typeof AppExploreRoute
|
||||
'/library': typeof AppLibraryRoute
|
||||
'/onboarding': typeof AppOnboardingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/upcoming': typeof AppUpcomingRoute
|
||||
'/login': typeof AuthLoginRoute
|
||||
@@ -113,6 +129,8 @@ export interface FileRoutesById {
|
||||
'/setup': typeof SetupRoute
|
||||
'/_app/dashboard': typeof AppDashboardRoute
|
||||
'/_app/explore': typeof AppExploreRoute
|
||||
'/_app/library': typeof AppLibraryRoute
|
||||
'/_app/onboarding': typeof AppOnboardingRoute
|
||||
'/_app/settings': typeof AppSettingsRoute
|
||||
'/_app/upcoming': typeof AppUpcomingRoute
|
||||
'/_auth/login': typeof AuthLoginRoute
|
||||
@@ -127,6 +145,8 @@ export interface FileRouteTypes {
|
||||
| '/setup'
|
||||
| '/dashboard'
|
||||
| '/explore'
|
||||
| '/library'
|
||||
| '/onboarding'
|
||||
| '/settings'
|
||||
| '/upcoming'
|
||||
| '/login'
|
||||
@@ -139,6 +159,8 @@ export interface FileRouteTypes {
|
||||
| '/setup'
|
||||
| '/dashboard'
|
||||
| '/explore'
|
||||
| '/library'
|
||||
| '/onboarding'
|
||||
| '/settings'
|
||||
| '/upcoming'
|
||||
| '/login'
|
||||
@@ -153,6 +175,8 @@ export interface FileRouteTypes {
|
||||
| '/setup'
|
||||
| '/_app/dashboard'
|
||||
| '/_app/explore'
|
||||
| '/_app/library'
|
||||
| '/_app/onboarding'
|
||||
| '/_app/settings'
|
||||
| '/_app/upcoming'
|
||||
| '/_auth/login'
|
||||
@@ -226,6 +250,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppSettingsRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/onboarding': {
|
||||
id: '/_app/onboarding'
|
||||
path: '/onboarding'
|
||||
fullPath: '/onboarding'
|
||||
preLoaderRoute: typeof AppOnboardingRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/library': {
|
||||
id: '/_app/library'
|
||||
path: '/library'
|
||||
fullPath: '/library'
|
||||
preLoaderRoute: typeof AppLibraryRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/explore': {
|
||||
id: '/_app/explore'
|
||||
path: '/explore'
|
||||
@@ -260,6 +298,8 @@ declare module '@tanstack/react-router' {
|
||||
interface AppRouteChildren {
|
||||
AppDashboardRoute: typeof AppDashboardRoute
|
||||
AppExploreRoute: typeof AppExploreRoute
|
||||
AppLibraryRoute: typeof AppLibraryRoute
|
||||
AppOnboardingRoute: typeof AppOnboardingRoute
|
||||
AppSettingsRoute: typeof AppSettingsRoute
|
||||
AppUpcomingRoute: typeof AppUpcomingRoute
|
||||
AppPeopleIdRoute: typeof AppPeopleIdRoute
|
||||
@@ -269,6 +309,8 @@ interface AppRouteChildren {
|
||||
const AppRouteChildren: AppRouteChildren = {
|
||||
AppDashboardRoute: AppDashboardRoute,
|
||||
AppExploreRoute: AppExploreRoute,
|
||||
AppLibraryRoute: AppLibraryRoute,
|
||||
AppOnboardingRoute: AppOnboardingRoute,
|
||||
AppSettingsRoute: AppSettingsRoute,
|
||||
AppUpcomingRoute: AppUpcomingRoute,
|
||||
AppPeopleIdRoute: AppPeopleIdRoute,
|
||||
|
||||
@@ -23,14 +23,8 @@ export const Route = createFileRoute("/_app/dashboard")({
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
),
|
||||
context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.dashboard.library.infiniteOptions({
|
||||
input: (pageParam: number) => ({ page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }),
|
||||
),
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { DiscoverSection } from "@/components/explore/discover-section";
|
||||
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
|
||||
import { HeroBanner } from "@/components/explore/hero-banner";
|
||||
import { TitleRow } from "@/components/explore/title-row";
|
||||
@@ -170,6 +171,8 @@ function ExplorePage() {
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
|
||||
<DiscoverSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconBooks, IconFilterOff } from "@tabler/icons-react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TitleGrid, TitleGridSectionSkeleton } from "@/components/dashboard/title-grid";
|
||||
import { LibraryToolbar } from "@/components/library/library-toolbar";
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
const librarySearchSchema = z.object({
|
||||
search: z.string().optional().catch(undefined),
|
||||
statuses: z.array(z.string()).optional().catch(undefined),
|
||||
type: z.enum(["movie", "tv"]).optional().catch(undefined),
|
||||
genreId: z.number().optional().catch(undefined),
|
||||
ratingMin: z.number().optional().catch(undefined),
|
||||
ratingMax: z.number().optional().catch(undefined),
|
||||
yearMin: z.number().optional().catch(undefined),
|
||||
yearMax: z.number().optional().catch(undefined),
|
||||
contentRating: z.string().optional().catch(undefined),
|
||||
onMyServices: z.boolean().optional().catch(undefined),
|
||||
sortBy: z.string().optional().catch(undefined),
|
||||
sortDirection: z.enum(["asc", "desc"]).optional().catch(undefined),
|
||||
});
|
||||
|
||||
type LibrarySearch = z.infer<typeof librarySearchSchema>;
|
||||
|
||||
export const Route = createFileRoute("/_app/library")({
|
||||
validateSearch: zodValidator(librarySearchSchema),
|
||||
staleTime: 30_000,
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.library.list.infiniteOptions({
|
||||
input: (pageParam: number) => ({ page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
),
|
||||
context.queryClient.ensureQueryData(orpc.library.genres.queryOptions()),
|
||||
]);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Library — Sofa" }] }),
|
||||
pendingComponent: LibrarySkeleton,
|
||||
errorComponent: RouteError,
|
||||
component: LibraryPage,
|
||||
});
|
||||
|
||||
function LibrarySkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TitleGridSectionSkeleton />
|
||||
<TitleGridSectionSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPage() {
|
||||
const { t } = useLingui();
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
// Debounced search — update URL 300ms after the user stops typing
|
||||
const [prevSearchParam, setPrevSearchParam] = useState(search.search);
|
||||
const [localSearch, setLocalSearch] = useState(search.search ?? "");
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
// Sync localSearch when URL params change externally (back/forward navigation)
|
||||
if (prevSearchParam !== search.search) {
|
||||
setPrevSearchParam(search.search);
|
||||
setLocalSearch(search.search ?? "");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateSearch = useCallback(
|
||||
(updates: Partial<LibrarySearch>) => {
|
||||
void navigate({
|
||||
search: (prev) => {
|
||||
const next = { ...prev, ...updates };
|
||||
// Remove undefined/empty values
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (
|
||||
value === undefined ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0)
|
||||
) {
|
||||
delete (next as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
},
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
const updates: Partial<LibrarySearch> = {
|
||||
[key]: value === "" || value === false ? undefined : value,
|
||||
};
|
||||
// Clear ratingMax when ratingMin changes (UI only exposes ratingMin now)
|
||||
if (key === "ratingMin") updates.ratingMax = undefined;
|
||||
updateSearch(updates);
|
||||
},
|
||||
[updateSearch],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
setLocalSearch("");
|
||||
void navigate({ search: {}, replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(sortBy: string, sortDirection: string) => {
|
||||
updateSearch({ sortBy, sortDirection: sortDirection as "asc" | "desc" });
|
||||
},
|
||||
[updateSearch],
|
||||
);
|
||||
|
||||
// Build input from search params
|
||||
const queryInput = useMemo(() => {
|
||||
const input: Record<string, unknown> = {};
|
||||
if (search.search) input.search = search.search;
|
||||
if (search.statuses?.length) input.statuses = search.statuses;
|
||||
if (search.type) input.type = search.type;
|
||||
if (search.genreId) input.genreId = search.genreId;
|
||||
if (search.ratingMin) input.ratingMin = search.ratingMin;
|
||||
if (search.ratingMax) input.ratingMax = search.ratingMax;
|
||||
if (search.yearMin) input.yearMin = search.yearMin;
|
||||
if (search.yearMax) input.yearMax = search.yearMax;
|
||||
if (search.contentRating) input.contentRating = search.contentRating;
|
||||
if (search.onMyServices) input.onMyServices = true;
|
||||
if (search.sortBy) input.sortBy = search.sortBy;
|
||||
if (search.sortDirection) input.sortDirection = search.sortDirection;
|
||||
return input;
|
||||
}, [search]);
|
||||
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
orpc.library.list.infiniteOptions({
|
||||
input: (pageParam: number) => ({ ...queryInput, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
});
|
||||
|
||||
const items = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const totalResults = data?.pages[0]?.totalResults ?? 0;
|
||||
|
||||
// Count active filters (excluding sort)
|
||||
const activeFilterCount = [
|
||||
search.statuses?.length ? 1 : 0,
|
||||
search.type ? 1 : 0,
|
||||
search.genreId ? 1 : 0,
|
||||
search.ratingMin || search.ratingMax ? 1 : 0,
|
||||
search.yearMin || search.yearMax ? 1 : 0,
|
||||
search.contentRating ? 1 : 0,
|
||||
search.onMyServices ? 1 : 0,
|
||||
].reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconBooks aria-hidden={true} className="text-primary size-5" />
|
||||
<h1 className="font-display text-xl tracking-tight">{t`Library`}</h1>
|
||||
</div>
|
||||
|
||||
<LibraryToolbar
|
||||
search={localSearch}
|
||||
onSearchChange={(value: string) => {
|
||||
setLocalSearch(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
updateSearch({ search: value || undefined });
|
||||
}, 300);
|
||||
}}
|
||||
filters={{
|
||||
statuses: search.statuses,
|
||||
type: search.type,
|
||||
genreId: search.genreId,
|
||||
ratingMin: search.ratingMin,
|
||||
ratingMax: search.ratingMax,
|
||||
yearMin: search.yearMin,
|
||||
yearMax: search.yearMax,
|
||||
contentRating: search.contentRating,
|
||||
onMyServices: search.onMyServices,
|
||||
}}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClearAll={handleClearAll}
|
||||
sortBy={search.sortBy ?? "added_at"}
|
||||
sortDirection={search.sortDirection ?? "desc"}
|
||||
onSortChange={handleSortChange}
|
||||
totalResults={totalResults}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
|
||||
{isPending ? (
|
||||
<TitleGridSectionSkeleton />
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<IconFilterOff className="text-muted-foreground/40 size-12" />
|
||||
<p className="text-muted-foreground text-lg">{t`No titles match your filters`}</p>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
className="text-primary text-sm hover:underline"
|
||||
>
|
||||
{t`Clear all filters`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TitleGrid items={items} />
|
||||
<div ref={sentinelRef} />
|
||||
{isFetchingNextPage && <TitleGridSectionSkeleton />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { PlatformGrid } from "@/components/platforms/platform-grid";
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { client, orpc } from "@/lib/orpc/client";
|
||||
|
||||
export const Route = createFileRoute("/_app/onboarding")({
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureQueryData(orpc.platforms.list.queryOptions());
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Get Started — Sofa" }] }),
|
||||
errorComponent: RouteError,
|
||||
component: OnboardingPage,
|
||||
});
|
||||
|
||||
function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const platformsQuery = useQuery(orpc.platforms.list.queryOptions());
|
||||
const platforms = platformsQuery.data?.platforms ?? [];
|
||||
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleContinue() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (selectedIds.size > 0) {
|
||||
await client.account.updatePlatforms({ platformIds: [...selectedIds] });
|
||||
}
|
||||
void navigate({ to: "/dashboard" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
void navigate({ to: "/dashboard" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-12">
|
||||
<motion.div
|
||||
className="w-full max-w-lg space-y-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 24 }}
|
||||
>
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="font-display text-2xl font-medium text-balance">
|
||||
<Trans>What do you watch on?</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
<Trans>Select your streaming services to personalize your experience</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PlatformGrid platforms={platforms} selectedIds={selectedIds} onToggle={handleToggle} />
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Button onClick={handleContinue} disabled={saving} className="w-full max-w-xs">
|
||||
{saving ? <Trans>Saving…</Trans> : <Trans>Continue</Trans>}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
||||
>
|
||||
<Trans>Skip for now</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { IntegrationsSection } from "@/components/settings/integrations-section"
|
||||
import { LanguageSection } from "@/components/settings/language-section";
|
||||
import { RegistrationSection } from "@/components/settings/registration-section";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
import { StreamingServicesSection } from "@/components/settings/streaming-services-section";
|
||||
import { SystemHealthCards } from "@/components/settings/system-health-section";
|
||||
import { UpdateCheckSection } from "@/components/settings/update-check-section";
|
||||
import { TmdbLogo } from "@/components/tmdb-logo";
|
||||
@@ -34,6 +35,8 @@ export const Route = createFileRoute("/_app/settings")({
|
||||
const promises: Promise<unknown>[] = [
|
||||
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.system.status.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.platforms.list.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.account.platforms.queryOptions()),
|
||||
];
|
||||
const isAdmin = context.session.user.role === "admin";
|
||||
if (isAdmin) {
|
||||
@@ -140,6 +143,7 @@ function SettingsPage() {
|
||||
</h2>
|
||||
</div>
|
||||
<LanguageSection />
|
||||
<StreamingServicesSection />
|
||||
</div>
|
||||
|
||||
<IntegrationsSection />
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { IconCalendarEvent } from "@tabler/icons-react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { UpcomingRow } from "@/components/dashboard/upcoming-item";
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
|
||||
|
||||
const upcomingSearchSchema = z.object({
|
||||
type: z.enum(["all", "movie", "tv"]).optional().catch(undefined),
|
||||
status: z.enum(["all", "watching", "watchlist"]).optional().catch(undefined),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_app/upcoming")({
|
||||
validateSearch: zodValidator(upcomingSearchSchema),
|
||||
staleTime: 30_000,
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureInfiniteQueryData(
|
||||
@@ -53,12 +62,22 @@ function UpcomingSkeleton() {
|
||||
}
|
||||
|
||||
function UpcomingPage() {
|
||||
const { t } = useLingui();
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const typeFilter = search.type ?? "all";
|
||||
const statusFilter = search.status ?? "all";
|
||||
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
mediaType: typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
|
||||
statusFilter:
|
||||
statusFilter !== "all" ? [statusFilter as "watching" | "watchlist"] : undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
@@ -72,14 +91,66 @@ function UpcomingPage() {
|
||||
isFetchingNextPage,
|
||||
});
|
||||
|
||||
function setTypeFilter(value: string) {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
type: value === "all" ? undefined : (value as "movie" | "tv"),
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
function setStatusFilter(value: string) {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
status: value === "all" ? undefined : (value as "watching" | "watchlist"),
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
const filterToggles = (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ToggleGroup
|
||||
value={[typeFilter]}
|
||||
onValueChange={(values) => {
|
||||
const next = values.find((v) => v !== typeFilter) ?? "all";
|
||||
setTypeFilter(next);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="movie">{t`Movies`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tv">{t`TV Shows`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
value={[statusFilter]}
|
||||
onValueChange={(values) => {
|
||||
const next = values.find((v) => v !== statusFilter) ?? "all";
|
||||
setStatusFilter(next);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="watching">{t`Watching`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="watchlist">{t`Watchlist`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isPending) return <UpcomingSkeleton />;
|
||||
|
||||
const allItems = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (allItems.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<UpcomingHeader />
|
||||
{filterToggles}
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<IconCalendarEvent className="text-muted-foreground/40 size-12" />
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
@@ -95,6 +166,7 @@ function UpcomingPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<UpcomingHeader />
|
||||
{filterToggles}
|
||||
{buckets.map((bucket) => (
|
||||
<section key={bucket.key}>
|
||||
<h2 className="font-display text-muted-foreground mb-2 text-sm font-medium tracking-wider uppercase">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"apps/native": {
|
||||
"name": "@sofa/native",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "catalog:",
|
||||
"@expo/metro-runtime": "55.0.6",
|
||||
@@ -51,7 +51,7 @@
|
||||
"@sofa/i18n": "workspace:*",
|
||||
"@tabler/icons-react-native": "3.40.0",
|
||||
"@tanstack/query-async-storage-persister": "5.95.2",
|
||||
"@tanstack/react-form": "1.28.5",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "5.95.2",
|
||||
"better-auth": "catalog:",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"apps/public-api": {
|
||||
"name": "@sofa/public-api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"@vercel/firewall": "1.1.2",
|
||||
@@ -126,7 +126,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@sofa/server",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/json-schema": "catalog:",
|
||||
@@ -152,7 +152,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@sofa/web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.3.0",
|
||||
"@fontsource-variable/dm-sans": "5.2.8",
|
||||
@@ -168,9 +168,11 @@
|
||||
"@sofa/api": "workspace:*",
|
||||
"@sofa/i18n": "workspace:*",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"@tanstack/react-hotkeys": "0.5.1",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-router": "1.168.3",
|
||||
"@tanstack/zod-adapter": "1.166.9",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -212,7 +214,7 @@
|
||||
},
|
||||
"packages/api": {
|
||||
"name": "@sofa/api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@orpc/contract": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -224,7 +226,7 @@
|
||||
},
|
||||
"packages/auth": {
|
||||
"name": "@sofa/auth",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@better-auth/drizzle-adapter": "1.5.6",
|
||||
"@better-auth/expo": "catalog:",
|
||||
@@ -241,7 +243,7 @@
|
||||
},
|
||||
"packages/config": {
|
||||
"name": "@sofa/config",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"devDependencies": {
|
||||
"@sofa/tsconfig": "workspace:*",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -250,7 +252,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@sofa/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@orpc/server": "catalog:",
|
||||
"@sofa/api": "workspace:*",
|
||||
@@ -274,7 +276,7 @@
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@sofa/db",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@sofa/config": "workspace:*",
|
||||
"@sofa/logger": "workspace:*",
|
||||
@@ -289,7 +291,7 @@
|
||||
},
|
||||
"packages/i18n": {
|
||||
"name": "@sofa/i18n",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@lingui/core": "catalog:",
|
||||
"@lingui/react": "catalog:",
|
||||
@@ -307,7 +309,7 @@
|
||||
},
|
||||
"packages/logger": {
|
||||
"name": "@sofa/logger",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"pino": "10.3.1",
|
||||
"pino-pretty": "13.1.3",
|
||||
@@ -320,7 +322,7 @@
|
||||
},
|
||||
"packages/test": {
|
||||
"name": "@sofa/test",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@sofa/db": "workspace:*",
|
||||
"better-sqlite3": "12.8.0",
|
||||
@@ -335,7 +337,7 @@
|
||||
},
|
||||
"packages/tmdb": {
|
||||
"name": "@sofa/tmdb",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@sofa/logger": "workspace:*",
|
||||
"openapi-fetch": "0.17.0",
|
||||
@@ -348,7 +350,7 @@
|
||||
},
|
||||
"packages/tsconfig": {
|
||||
"name": "@sofa/tsconfig",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
},
|
||||
},
|
||||
"catalog": {
|
||||
@@ -368,6 +370,7 @@
|
||||
"@orpc/server": "1.13.9",
|
||||
"@orpc/tanstack-query": "1.13.9",
|
||||
"@orpc/zod": "1.13.9",
|
||||
"@tanstack/react-form": "1.28.5",
|
||||
"@tanstack/react-query": "5.95.2",
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/node": "25.5.0",
|
||||
@@ -1404,6 +1407,8 @@
|
||||
|
||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
|
||||
|
||||
"@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.166.9", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-HHllQ/CKGi8YBbftv6OmzojtHM6Rk4UszAFICAgUMbwiqtKqjlIZQ/7mv2IPNxBb8YlOQgzyQ4jz2UTEXIi6YA=="],
|
||||
|
||||
"@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
@@ -1616,7 +1621,7 @@
|
||||
|
||||
"babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
@@ -1648,7 +1653,7 @@
|
||||
|
||||
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
@@ -2110,7 +2115,7 @@
|
||||
|
||||
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
@@ -2440,7 +2445,7 @@
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
@@ -3156,10 +3161,6 @@
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
@@ -3180,6 +3181,8 @@
|
||||
|
||||
"@expo/fingerprint/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"@expo/fingerprint/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@expo/metro/metro-runtime": ["metro-runtime@0.83.3", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw=="],
|
||||
|
||||
"@expo/metro/metro-source-map": ["metro-source-map@0.83.3", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.3", "nullthrows": "^1.1.1", "ob1": "0.83.3", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg=="],
|
||||
@@ -3288,6 +3291,12 @@
|
||||
|
||||
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@tanstack/zod-adapter/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@ts-morph/common/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
@@ -3310,6 +3319,8 @@
|
||||
|
||||
"chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
@@ -3330,10 +3341,6 @@
|
||||
|
||||
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"eslint/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"eslint/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||
|
||||
"expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
@@ -3354,6 +3361,8 @@
|
||||
|
||||
"express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
@@ -3364,6 +3373,8 @@
|
||||
|
||||
"finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
@@ -3490,8 +3501,6 @@
|
||||
|
||||
"test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"test-exclude/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -3522,9 +3531,7 @@
|
||||
|
||||
"@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
"@expo/cli/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@expo/cli/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
||||
|
||||
@@ -3534,6 +3541,14 @@
|
||||
|
||||
"@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
||||
|
||||
"@expo/config-plugins/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@expo/config/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@expo/fingerprint/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"@expo/metro-config/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@expo/metro-config/hermes-parser/hermes-estree": ["hermes-estree@0.32.1", "", {}, "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg=="],
|
||||
|
||||
"@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
@@ -3594,8 +3609,6 @@
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
@@ -3684,8 +3697,14 @@
|
||||
|
||||
"@tanstack/router-plugin/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"@tanstack/router-plugin/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
|
||||
@@ -3706,7 +3725,7 @@
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"eslint/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
"expo-updates/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
@@ -3714,6 +3733,8 @@
|
||||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
|
||||
@@ -3730,12 +3751,8 @@
|
||||
|
||||
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"react-native/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"shadcn/ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
@@ -3756,8 +3773,6 @@
|
||||
|
||||
"tedious/bl/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||
|
||||
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
|
||||
@@ -3832,9 +3847,7 @@
|
||||
|
||||
"vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
"@expo/cli/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"@expo/cli/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||
|
||||
@@ -3846,6 +3859,14 @@
|
||||
|
||||
"@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||
|
||||
"@expo/config-plugins/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"@expo/config/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"@expo/fingerprint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@expo/metro-config/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||
|
||||
"@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
@@ -3860,8 +3881,6 @@
|
||||
|
||||
"@istanbuljs/load-nyc-config/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.33.3", "", {}, "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg=="],
|
||||
@@ -3904,11 +3923,13 @@
|
||||
|
||||
"@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
"@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"react-native/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
"expo-updates/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"shadcn/ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
@@ -3918,7 +3939,7 @@
|
||||
|
||||
"shadcn/ora/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
"@expo/cli/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@expo/cli/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||
|
||||
@@ -3928,6 +3949,12 @@
|
||||
|
||||
"@expo/cli/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@expo/config-plugins/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@expo/config/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@expo/metro-config/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@expo/package-manager/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||
|
||||
"@expo/package-manager/ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
|
||||
@@ -3938,11 +3965,7 @@
|
||||
|
||||
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"@react-native/codegen/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"react-native/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
"expo-updates/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"shadcn/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
title: Get user library
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Fetch paginated titles in the user's library with their tracking
|
||||
statuses.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Fetch paginated titles in the user's library with their tracking statuses.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/dashboard/library","method":"get"}]} />
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: List available watch providers
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Fetch the list of streaming providers available in the configured
|
||||
region. Used to populate provider filter dropdowns.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Fetch the list of streaming providers available in the configured region. Used to populate provider filter dropdowns.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/explore/watch-providers","method":"get"}]} />
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: List genres in user's library
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Get the distinct genres present in the user's library, ordered
|
||||
alphabetically. Used to populate the genre filter dropdown.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Get the distinct genres present in the user's library, ordered alphabetically. Used to populate the genre filter dropdown.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/library/genres","method":"get"}]} />
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: List library with filters
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Fetch paginated, filtered, and sorted titles from the user's library.
|
||||
Supports filtering by status, type, genre, rating, year, content
|
||||
rating, and streaming availability.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Fetch paginated, filtered, and sorted titles from the user's library. Supports filtering by status, type, genre, rating, year, content rating, and streaming availability.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/library","method":"get"}]} />
|
||||
+680
-190
@@ -1569,6 +1569,419 @@
|
||||
},
|
||||
"openapi": "3.1.1",
|
||||
"paths": {
|
||||
"/library": {
|
||||
"get": {
|
||||
"operationId": "library.list",
|
||||
"summary": "List library with filters",
|
||||
"description": "Fetch paginated, filtered, and sorted titles from the user's library. Supports filtering by status, type, genre, rating, year, content rating, and streaming availability.",
|
||||
"tags": [
|
||||
"Library"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 200,
|
||||
"description": "Search within library by title name"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "statuses",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"in_watchlist",
|
||||
"watching",
|
||||
"caught_up",
|
||||
"completed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by display statuses (multi-select)"
|
||||
},
|
||||
"style": "deepObject",
|
||||
"explode": true,
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"movie",
|
||||
"tv"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Filter by media type"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "genreId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Filter by TMDB genre ID"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "ratingMin",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 5,
|
||||
"description": "Minimum user star rating"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "ratingMax",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 5,
|
||||
"description": "Maximum user star rating"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "yearMin",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1900,
|
||||
"maximum": 2100,
|
||||
"description": "Minimum release year"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "yearMax",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1900,
|
||||
"maximum": 2100,
|
||||
"description": "Maximum release year"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "contentRating",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Content rating filter (e.g. PG-13, TV-MA)"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "availableToStream",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"description": "Only show titles with streaming availability"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"title",
|
||||
"added_at",
|
||||
"release_date",
|
||||
"popularity",
|
||||
"user_rating",
|
||||
"vote_average"
|
||||
],
|
||||
"type": "string",
|
||||
"default": "added_at",
|
||||
"description": "Sort field"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "sortDirection",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string",
|
||||
"default": "desc",
|
||||
"description": "Sort direction"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 500,
|
||||
"default": 1,
|
||||
"description": "Page number (1-indexed)"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 20,
|
||||
"description": "Results per page (1-100, default 20)"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Filtered library items with user statuses and ratings",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Title ID"
|
||||
},
|
||||
"tmdbId": {
|
||||
"type": "number",
|
||||
"description": "TMDB numeric ID"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"movie",
|
||||
"tv"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Media type"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title"
|
||||
},
|
||||
"posterPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Poster image path"
|
||||
},
|
||||
"posterThumbHash": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "ThumbHash blur placeholder for the poster"
|
||||
},
|
||||
"releaseDate": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Release date (ISO 8601)"
|
||||
},
|
||||
"firstAirDate": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "First air date (ISO 8601)"
|
||||
},
|
||||
"voteAverage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Average rating (0-10)"
|
||||
},
|
||||
"userStatus": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"in_watchlist",
|
||||
"watching",
|
||||
"caught_up",
|
||||
"completed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User's display status"
|
||||
},
|
||||
"userRating": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User's star rating (1-5), or null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"tmdbId",
|
||||
"type",
|
||||
"title",
|
||||
"posterPath",
|
||||
"posterThumbHash",
|
||||
"releaseDate",
|
||||
"firstAirDate",
|
||||
"voteAverage",
|
||||
"userStatus",
|
||||
"userRating"
|
||||
],
|
||||
"description": "A library item with user status and rating"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"type": "number",
|
||||
"description": "Current page number"
|
||||
},
|
||||
"totalPages": {
|
||||
"type": "number",
|
||||
"description": "Total number of pages available"
|
||||
},
|
||||
"totalResults": {
|
||||
"type": "number",
|
||||
"description": "Total number of results across all pages"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items",
|
||||
"page",
|
||||
"totalPages",
|
||||
"totalResults"
|
||||
],
|
||||
"description": "Filtered and sorted library titles"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"session": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/library/genres": {
|
||||
"get": {
|
||||
"operationId": "library.genres",
|
||||
"summary": "List genres in user's library",
|
||||
"description": "Get the distinct genres present in the user's library, ordered alphabetically. Used to populate the genre filter dropdown.",
|
||||
"tags": [
|
||||
"Library"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Genres present in the library",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"genres": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"description": "Genres present in the user's library"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"genres"
|
||||
],
|
||||
"description": "Genres that exist in the user's library"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"session": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/titles/{id}": {
|
||||
"get": {
|
||||
"operationId": "titles.detail",
|
||||
@@ -2894,6 +3307,39 @@
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "mediaType",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"movie",
|
||||
"tv"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Filter to only movies or only TV episodes"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "statusFilter",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"watching",
|
||||
"watchlist"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by user tracking status"
|
||||
},
|
||||
"style": "deepObject",
|
||||
"explode": true,
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3120,195 +3566,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/dashboard/library": {
|
||||
"get": {
|
||||
"operationId": "dashboard.library",
|
||||
"summary": "Get user library",
|
||||
"description": "Fetch paginated titles in the user's library with their tracking statuses.",
|
||||
"tags": [
|
||||
"Dashboard"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 500,
|
||||
"default": 1,
|
||||
"description": "Page number (1-indexed)"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 20,
|
||||
"description": "Results per page (1-100, default 20)"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Paginated library items with user statuses",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Title ID"
|
||||
},
|
||||
"tmdbId": {
|
||||
"type": "number",
|
||||
"description": "TMDB numeric ID"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"movie",
|
||||
"tv"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Media type"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title"
|
||||
},
|
||||
"posterPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Poster image path"
|
||||
},
|
||||
"posterThumbHash": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "ThumbHash blur placeholder for the poster"
|
||||
},
|
||||
"releaseDate": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Release date (ISO 8601)"
|
||||
},
|
||||
"firstAirDate": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "First air date (ISO 8601)"
|
||||
},
|
||||
"voteAverage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Average rating (0-10)"
|
||||
},
|
||||
"userStatus": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"in_watchlist",
|
||||
"watching",
|
||||
"caught_up",
|
||||
"completed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User's display status"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"tmdbId",
|
||||
"type",
|
||||
"title",
|
||||
"posterPath",
|
||||
"posterThumbHash",
|
||||
"releaseDate",
|
||||
"firstAirDate",
|
||||
"voteAverage",
|
||||
"userStatus"
|
||||
],
|
||||
"description": "A library item with user status"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"type": "number",
|
||||
"description": "Current page number"
|
||||
},
|
||||
"totalPages": {
|
||||
"type": "number",
|
||||
"description": "Total number of pages available"
|
||||
},
|
||||
"totalResults": {
|
||||
"type": "number",
|
||||
"description": "Total number of results across all pages"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items",
|
||||
"page",
|
||||
"totalPages",
|
||||
"totalResults"
|
||||
],
|
||||
"description": "Paginated titles in the user's library"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"session": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/dashboard/recommendations": {
|
||||
"get": {
|
||||
"operationId": "dashboard.recommendations",
|
||||
@@ -4039,6 +4296,159 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/explore/watch-providers": {
|
||||
"get": {
|
||||
"operationId": "explore.watchProviders",
|
||||
"summary": "List available watch providers",
|
||||
"description": "Fetch the list of streaming providers available in the configured region. Used to populate provider filter dropdowns.",
|
||||
"tags": [
|
||||
"Explore"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"enum": [
|
||||
"movie",
|
||||
"tv"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Media type filter"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Provider list with logos",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "TMDB provider ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Provider display name"
|
||||
},
|
||||
"logoPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Provider logo image path"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"logoPath"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providers"
|
||||
],
|
||||
"description": "Available streaming providers for the user's region"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"412": {
|
||||
"description": "412",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"defined": {
|
||||
"const": true
|
||||
},
|
||||
"code": {
|
||||
"const": "PRECONDITION_FAILED"
|
||||
},
|
||||
"status": {
|
||||
"const": 412
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"default": "TMDB API key is not configured"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"const": "TMDB_NOT_CONFIGURED"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"defined",
|
||||
"code",
|
||||
"status",
|
||||
"message",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"defined": {
|
||||
"const": false
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "number"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
"required": [
|
||||
"defined",
|
||||
"code",
|
||||
"status",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"session": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/search": {
|
||||
"get": {
|
||||
"operationId": "search",
|
||||
@@ -4353,7 +4763,7 @@
|
||||
{
|
||||
"name": "genreId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
@@ -4363,6 +4773,86 @@
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "yearMin",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1900,
|
||||
"maximum": 2100,
|
||||
"description": "Minimum release year"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "yearMax",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1900,
|
||||
"maximum": 2100,
|
||||
"description": "Maximum release year"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "ratingMin",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 10,
|
||||
"description": "Minimum TMDB vote average"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"enum": [
|
||||
"popularity.desc",
|
||||
"vote_average.desc",
|
||||
"primary_release_date.desc",
|
||||
"primary_release_date.asc"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Sort order for results"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "ISO 639-1 original language code"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "providerId",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "TMDB watch provider ID"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@orpc/server": "1.13.9",
|
||||
"@orpc/tanstack-query": "1.13.9",
|
||||
"@orpc/zod": "1.13.9",
|
||||
"@tanstack/react-form": "1.28.5",
|
||||
"@tanstack/react-query": "5.95.2",
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/node": "25.5.0",
|
||||
|
||||
@@ -23,11 +23,14 @@ import {
|
||||
ImportPreviewSchema,
|
||||
IntegrationOutput,
|
||||
IntegrationsListOutput,
|
||||
LibraryOutput,
|
||||
LibraryGenresOutput,
|
||||
LibraryListInput,
|
||||
LibraryListOutput,
|
||||
MediaTypeParam,
|
||||
PageParam,
|
||||
PaginatedInput,
|
||||
ParseFileInput,
|
||||
PlatformsListOutput,
|
||||
ParsePayloadInput,
|
||||
PersonDetailOutput,
|
||||
PopularOutput,
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
TriggerJobInput,
|
||||
TriggerJobOutput,
|
||||
UpdateCheckOutput,
|
||||
UpdateUserPlatformsInput,
|
||||
UpdateNameInput,
|
||||
UpdateRatingInput,
|
||||
UpdateScheduleInput,
|
||||
@@ -62,8 +66,10 @@ import {
|
||||
UploadAvatarInput,
|
||||
UploadAvatarOutput,
|
||||
UserInfoOutput,
|
||||
UserPlatformsOutput,
|
||||
WatchHistoryInput,
|
||||
WatchHistoryOutput,
|
||||
WatchProvidersOutput,
|
||||
} from "./schemas";
|
||||
|
||||
export const contract = {
|
||||
@@ -247,6 +253,31 @@ export const contract = {
|
||||
},
|
||||
}),
|
||||
},
|
||||
library: {
|
||||
list: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/library",
|
||||
tags: ["Library"],
|
||||
summary: "List library with filters",
|
||||
description:
|
||||
"Fetch paginated, filtered, and sorted titles from the user's library. Supports filtering by status, type, genre, rating, year, content rating, and streaming availability.",
|
||||
successDescription: "Filtered library items with user statuses and ratings",
|
||||
})
|
||||
.input(LibraryListInput)
|
||||
.output(LibraryListOutput),
|
||||
genres: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/library/genres",
|
||||
tags: ["Library"],
|
||||
summary: "List genres in user's library",
|
||||
description:
|
||||
"Get the distinct genres present in the user's library, ordered alphabetically. Used to populate the genre filter dropdown.",
|
||||
successDescription: "Genres present in the library",
|
||||
})
|
||||
.output(LibraryGenresOutput),
|
||||
},
|
||||
dashboard: {
|
||||
stats: oc
|
||||
.route({
|
||||
@@ -270,17 +301,6 @@ export const contract = {
|
||||
successDescription: "In-progress shows with next episode and watch progress",
|
||||
})
|
||||
.output(ContinueWatchingOutput),
|
||||
library: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/library",
|
||||
tags: ["Dashboard"],
|
||||
summary: "Get user library",
|
||||
description: "Fetch paginated titles in the user's library with their tracking statuses.",
|
||||
successDescription: "Paginated library items with user statuses",
|
||||
})
|
||||
.input(PaginatedInput)
|
||||
.output(LibraryOutput),
|
||||
recommendations: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
@@ -372,6 +392,24 @@ export const contract = {
|
||||
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
||||
},
|
||||
}),
|
||||
watchProviders: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/explore/watch-providers",
|
||||
tags: ["Explore"],
|
||||
summary: "List available watch providers",
|
||||
description:
|
||||
"Fetch the list of streaming providers available in the configured region. Used to populate provider filter dropdowns.",
|
||||
successDescription: "Provider list with logos",
|
||||
})
|
||||
.input(MediaTypeParam)
|
||||
.output(WatchProvidersOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: {
|
||||
message: "TMDB API key is not configured",
|
||||
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
||||
},
|
||||
}),
|
||||
},
|
||||
search: oc
|
||||
.route({
|
||||
@@ -724,6 +762,38 @@ export const contract = {
|
||||
})
|
||||
.input(z.void())
|
||||
.output(z.void()),
|
||||
platforms: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/account/platforms",
|
||||
tags: ["Account"],
|
||||
summary: "Get user's streaming platforms",
|
||||
description: "Fetch the current user's subscribed streaming platform IDs.",
|
||||
successDescription: "List of platform IDs",
|
||||
})
|
||||
.output(UserPlatformsOutput),
|
||||
updatePlatforms: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/account/platforms",
|
||||
tags: ["Account"],
|
||||
summary: "Update streaming platforms",
|
||||
description: "Set the current user's subscribed streaming platforms.",
|
||||
})
|
||||
.input(UpdateUserPlatformsInput)
|
||||
.output(z.void()),
|
||||
},
|
||||
platforms: {
|
||||
list: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/platforms",
|
||||
tags: ["Platforms"],
|
||||
summary: "List all platforms",
|
||||
description: "Fetch all available streaming platforms, ordered by popularity.",
|
||||
successDescription: "All platforms with metadata",
|
||||
})
|
||||
.output(PlatformsListOutput),
|
||||
},
|
||||
imports: {
|
||||
parseFile: oc
|
||||
|
||||
+111
-6
@@ -87,7 +87,26 @@ export const SearchInput = z
|
||||
export const DiscoverInput = z
|
||||
.object({
|
||||
type: z.enum(["movie", "tv"]).describe("Media type to discover"),
|
||||
genreId: z.number().int().describe("TMDB genre ID to filter by"),
|
||||
genreId: z.number().int().optional().describe("TMDB genre ID to filter by"),
|
||||
yearMin: z.number().int().min(1900).max(2100).optional().describe("Minimum release year"),
|
||||
yearMax: z.number().int().min(1900).max(2100).optional().describe("Maximum release year"),
|
||||
ratingMin: z.number().min(0).max(10).optional().describe("Minimum TMDB vote average"),
|
||||
sortBy: z
|
||||
.enum([
|
||||
"popularity.desc",
|
||||
"vote_average.desc",
|
||||
"primary_release_date.desc",
|
||||
"primary_release_date.asc",
|
||||
])
|
||||
.optional()
|
||||
.describe("Sort order for results"),
|
||||
language: z
|
||||
.string()
|
||||
.length(2)
|
||||
.regex(/^[a-z]{2}$/)
|
||||
.optional()
|
||||
.describe("ISO 639-1 original language code"),
|
||||
providerId: z.number().int().optional().describe("TMDB watch provider ID"),
|
||||
})
|
||||
.merge(PageParam)
|
||||
.meta({ description: "Genre-based discovery filters" });
|
||||
@@ -261,14 +280,39 @@ export const SeasonSchema = z
|
||||
|
||||
export const AvailabilityOfferSchema = z
|
||||
.object({
|
||||
providerId: z.number().describe("JustWatch provider ID"),
|
||||
platformId: z.string().describe("Platform ID"),
|
||||
providerName: z.string().describe("Display name (e.g. Netflix, Hulu)"),
|
||||
logoPath: z.string().nullable().describe("Provider logo image path"),
|
||||
offerType: z.string().describe("Offer type: flatrate, rent, buy, free, ads"),
|
||||
watchUrl: z.string().nullable().describe("Direct link to watch on this provider"),
|
||||
isUserSubscribed: z.boolean().describe("Whether the user subscribes to this platform"),
|
||||
})
|
||||
.meta({ description: "A streaming availability offer from a provider" });
|
||||
|
||||
export const PlatformSchema = z
|
||||
.object({
|
||||
id: z.string().describe("Platform ID"),
|
||||
name: z.string().describe("Display name"),
|
||||
tmdbProviderId: z.number().nullable().describe("TMDB provider ID (null for custom platforms)"),
|
||||
logoPath: z.string().nullable().describe("Logo image path"),
|
||||
displayOrder: z.number().describe("Sort order"),
|
||||
})
|
||||
.meta({ description: "A streaming platform" });
|
||||
|
||||
export type Platform = z.infer<typeof PlatformSchema>;
|
||||
|
||||
export const PlatformsListOutput = z.object({
|
||||
platforms: z.array(PlatformSchema),
|
||||
});
|
||||
|
||||
export const UserPlatformsOutput = z.object({
|
||||
platformIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const UpdateUserPlatformsInput = z.object({
|
||||
platformIds: z.array(z.string()).describe("List of platform IDs the user subscribes to"),
|
||||
});
|
||||
|
||||
export const CastMemberSchema = z
|
||||
.object({
|
||||
id: z.string().describe("Credit ID"),
|
||||
@@ -507,7 +551,36 @@ export const ContinueWatchingOutput = z
|
||||
description: "TV shows the user is currently watching with next episode info",
|
||||
});
|
||||
|
||||
export const LibraryOutput = z
|
||||
// ─── Library (filtered) ───────────────────────────────────────
|
||||
|
||||
export const LibraryListInput = z
|
||||
.object({
|
||||
search: z.string().max(200).optional().describe("Search within library by title name"),
|
||||
statuses: z
|
||||
.array(displayStatusEnum)
|
||||
.optional()
|
||||
.describe("Filter by display statuses (multi-select)"),
|
||||
type: z.enum(["movie", "tv"]).optional().describe("Filter by media type"),
|
||||
genreId: z.number().int().optional().describe("Filter by TMDB genre ID"),
|
||||
ratingMin: z.number().int().min(1).max(5).optional().describe("Minimum user star rating"),
|
||||
ratingMax: z.number().int().min(1).max(5).optional().describe("Maximum user star rating"),
|
||||
yearMin: z.number().int().min(1900).max(2100).optional().describe("Minimum release year"),
|
||||
yearMax: z.number().int().min(1900).max(2100).optional().describe("Maximum release year"),
|
||||
contentRating: z.string().optional().describe("Content rating filter (e.g. PG-13, TV-MA)"),
|
||||
onMyServices: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Only show titles available on the user's streaming services"),
|
||||
sortBy: z
|
||||
.enum(["title", "added_at", "release_date", "popularity", "user_rating", "vote_average"])
|
||||
.default("added_at")
|
||||
.describe("Sort field"),
|
||||
sortDirection: z.enum(["asc", "desc"]).default("desc").describe("Sort direction"),
|
||||
})
|
||||
.merge(PaginatedInput)
|
||||
.meta({ description: "Filters, sorting, and pagination for the library" });
|
||||
|
||||
export const LibraryListOutput = z
|
||||
.object({
|
||||
items: z.array(
|
||||
z
|
||||
@@ -525,12 +598,36 @@ export const LibraryOutput = z
|
||||
firstAirDate: z.string().nullable().describe("First air date (ISO 8601)"),
|
||||
voteAverage: z.number().nullable().describe("Average rating (0-10)"),
|
||||
userStatus: displayStatusEnum.nullable().describe("User's display status"),
|
||||
userRating: z.number().nullable().describe("User's star rating (1-5), or null"),
|
||||
})
|
||||
.meta({ description: "A library item with user status" }),
|
||||
.meta({ description: "A library item with user status and rating" }),
|
||||
),
|
||||
})
|
||||
.merge(PaginationMeta)
|
||||
.meta({ description: "Paginated titles in the user's library" });
|
||||
.meta({ description: "Filtered and sorted library titles" });
|
||||
|
||||
export const LibraryGenresOutput = z
|
||||
.object({
|
||||
genres: z
|
||||
.array(z.object({ id: z.number(), name: z.string() }))
|
||||
.describe("Genres present in the user's library"),
|
||||
})
|
||||
.meta({ description: "Genres that exist in the user's library" });
|
||||
|
||||
// ─── Watch providers ──────────────────────────────────────────
|
||||
|
||||
export const WatchProvidersOutput = z
|
||||
.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string().describe("Platform ID"),
|
||||
tmdbProviderId: z.number().nullable().describe("TMDB provider ID"),
|
||||
name: z.string().describe("Provider display name"),
|
||||
logoPath: z.string().nullable().describe("Provider logo image path"),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({ description: "Available streaming providers for the user's region" });
|
||||
|
||||
export const DashboardRecommendationsOutput = z
|
||||
.object({
|
||||
@@ -553,6 +650,14 @@ export const UpcomingInput = z
|
||||
.describe("How many days into the future to look"),
|
||||
limit: z.number().int().min(1).max(50).default(20).describe("Maximum items per page"),
|
||||
cursor: z.string().optional().describe("Pagination cursor"),
|
||||
mediaType: z
|
||||
.enum(["movie", "tv"])
|
||||
.optional()
|
||||
.describe("Filter to only movies or only TV episodes"),
|
||||
statusFilter: z
|
||||
.array(z.enum(["watching", "watchlist"]))
|
||||
.optional()
|
||||
.describe("Filter by user tracking status"),
|
||||
})
|
||||
.meta({ description: "Filters for the upcoming feed" });
|
||||
|
||||
@@ -580,7 +685,7 @@ export const UpcomingItemSchema = z
|
||||
isNewSeason: z.boolean().describe("Whether this is a new season for a completed show"),
|
||||
streamingProvider: z
|
||||
.object({
|
||||
providerId: z.number(),
|
||||
platformId: z.string(),
|
||||
providerName: z.string(),
|
||||
logoPath: z.string().nullable(),
|
||||
})
|
||||
|
||||
@@ -19,3 +19,7 @@ export const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
||||
export const TMDB_API_BASE_URL = process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3";
|
||||
|
||||
export const TMDB_IMAGE_BASE_URL = process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
|
||||
// ─── Watch providers ──────────────────────────────────────────
|
||||
|
||||
export const WATCH_REGION = process.env.WATCH_REGION || "US";
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
"./display-status": "./src/display-status.ts",
|
||||
"./export": "./src/export.ts",
|
||||
"./image-cache": "./src/image-cache.ts",
|
||||
"./library": "./src/library.ts",
|
||||
"./imports": "./src/imports/index.ts",
|
||||
"./imports/parsers": "./src/imports/parsers.ts",
|
||||
"./integrations": "./src/integrations.ts",
|
||||
"./lists": "./src/lists.ts",
|
||||
"./metadata": "./src/metadata.ts",
|
||||
"./person": "./src/person.ts",
|
||||
"./platforms": "./src/platforms.ts",
|
||||
"./providers": "./src/providers.ts",
|
||||
"./settings": "./src/settings.ts",
|
||||
"./system-health": "./src/system-health.ts",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
getAvailabilityOffers,
|
||||
ensurePlatformForTmdbProvider,
|
||||
getAvailabilityForTitle,
|
||||
replaceAvailabilityTransaction,
|
||||
} from "@sofa/db/queries/availability";
|
||||
import { getTitleById } from "@sofa/db/queries/title";
|
||||
import type { availabilityOffers } from "@sofa/db/schema";
|
||||
import type { titleAvailability } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getWatchProviders } from "@sofa/tmdb/client";
|
||||
|
||||
@@ -18,21 +19,22 @@ export async function refreshAvailability(titleId: string) {
|
||||
const now = new Date();
|
||||
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
|
||||
|
||||
// Collect all offer rows, then batch insert in a single transaction
|
||||
const allOfferRows: (typeof availabilityOffers.$inferInsert)[] = [];
|
||||
const allOfferRows: (typeof titleAvailability.$inferInsert)[] = [];
|
||||
if (us) {
|
||||
for (const offerType of offerTypes) {
|
||||
const providers = us[offerType];
|
||||
if (!providers) continue;
|
||||
for (const p of providers) {
|
||||
const platformId = ensurePlatformForTmdbProvider(
|
||||
p.provider_id,
|
||||
p.provider_name ?? "",
|
||||
p.logo_path ?? null,
|
||||
);
|
||||
allOfferRows.push({
|
||||
titleId,
|
||||
region: "US",
|
||||
providerId: p.provider_id,
|
||||
providerName: p.provider_name ?? "",
|
||||
logoPath: p.logo_path ?? "",
|
||||
platformId,
|
||||
offerType,
|
||||
link: us.link ?? null,
|
||||
region: "US",
|
||||
lastFetchedAt: now,
|
||||
});
|
||||
}
|
||||
@@ -51,5 +53,5 @@ export async function refreshAvailability(titleId: string) {
|
||||
}
|
||||
|
||||
export function getAvailability(titleId: string) {
|
||||
return getAvailabilityOffers(titleId);
|
||||
return getAvailabilityForTitle(titleId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getEpisodeWatchHistoryBuckets,
|
||||
getHighlyRatedTitleIds,
|
||||
getInProgressTitleIds,
|
||||
getLibraryFeed,
|
||||
getMovieWatchCountSince,
|
||||
getMovieWatchHistoryBuckets,
|
||||
getNewAvailableFeed,
|
||||
@@ -305,8 +304,6 @@ export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[]
|
||||
|
||||
export { getNewAvailableFeed } from "@sofa/db/queries/discovery";
|
||||
|
||||
export { getLibraryFeed } from "@sofa/db/queries/discovery";
|
||||
|
||||
export function getRecommendationsFeed(userId: string) {
|
||||
// Get recommendations from user's highly-rated or completed titles
|
||||
const userCompletedOrRated = getEngagedTitleIds(userId);
|
||||
@@ -373,7 +370,7 @@ export interface UpcomingItem {
|
||||
userStatus: DisplayStatus;
|
||||
isNewSeason: boolean;
|
||||
streamingProvider: {
|
||||
providerId: number;
|
||||
platformId: string;
|
||||
providerName: string;
|
||||
logoPath: string | null;
|
||||
} | null;
|
||||
@@ -386,9 +383,18 @@ export interface UpcomingFeedResult {
|
||||
|
||||
export function getUpcomingFeed(
|
||||
userId: string,
|
||||
options: { days?: number; limit?: number; cursor?: string } = {},
|
||||
options: {
|
||||
days?: number;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
mediaType?: "movie" | "tv";
|
||||
statusFilter?: ("watching" | "watchlist")[];
|
||||
} = {},
|
||||
): UpcomingFeedResult {
|
||||
const { days = 90, limit = 20, cursor } = options;
|
||||
const { days = 90, limit = 20, cursor, mediaType, statusFilter } = options;
|
||||
|
||||
// Map display status filter to stored statuses for DB query
|
||||
const storedStatuses = statusFilter?.map((s) => (s === "watching" ? "in_progress" : s));
|
||||
|
||||
const now = new Date();
|
||||
const today = formatLocalDate(now);
|
||||
@@ -412,8 +418,10 @@ export function getUpcomingFeed(
|
||||
}
|
||||
}
|
||||
const fromDate = cursorDate ?? today;
|
||||
const episodeRows = getUpcomingEpisodes(userId, fromDate, toDate);
|
||||
const movieRows = getUpcomingMovies(userId, fromDate, toDate);
|
||||
const episodeRows =
|
||||
mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses);
|
||||
const movieRows =
|
||||
mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses);
|
||||
|
||||
// Merge into unified items
|
||||
type RawItem = { date: string; titleId: string; titleName: string } & (
|
||||
@@ -509,12 +517,12 @@ export function getUpcomingFeed(
|
||||
const providerRows = getAvailabilityByTitleIds(titleIds);
|
||||
const providerMap = new Map<
|
||||
string,
|
||||
{ providerId: number; providerName: string; logoPath: string | null }
|
||||
{ platformId: string; providerName: string; logoPath: string | null }
|
||||
>();
|
||||
for (const p of providerRows) {
|
||||
if (!providerMap.has(p.titleId)) {
|
||||
providerMap.set(p.titleId, {
|
||||
providerId: p.providerId,
|
||||
platformId: p.platformId,
|
||||
providerName: p.providerName,
|
||||
logoPath: p.logoPath,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
getFilteredLibrary,
|
||||
getLibraryGenres,
|
||||
type LibraryFilters,
|
||||
} from "@sofa/db/queries/library";
|
||||
|
||||
import { getDisplayStatusesByTitleIds } from "./tracking";
|
||||
|
||||
export type { LibraryFilters };
|
||||
|
||||
export function getFilteredLibraryFeed(userId: string, filters: LibraryFilters) {
|
||||
const result = getFilteredLibrary(userId, filters);
|
||||
|
||||
const titleIds = result.items.map((i) => i.titleId);
|
||||
const displayStatuses = getDisplayStatusesByTitleIds(userId, titleIds);
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
titleId: item.titleId,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
tmdbId: item.tmdbId,
|
||||
posterPath: item.posterPath,
|
||||
posterThumbHash: item.posterThumbHash,
|
||||
releaseDate: item.releaseDate,
|
||||
firstAirDate: item.firstAirDate,
|
||||
voteAverage: item.voteAverage,
|
||||
userStatus: displayStatuses[item.titleId] ?? null,
|
||||
userRating: item.userRating ?? null,
|
||||
})),
|
||||
page: result.page,
|
||||
totalPages: result.totalPages,
|
||||
totalResults: result.totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLibraryGenresList(userId: string) {
|
||||
return getLibraryGenres(userId);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
upsertSeasonReturning,
|
||||
} from "@sofa/db/queries/metadata";
|
||||
import { getTitleById } from "@sofa/db/queries/title";
|
||||
import { getUserPlatformIds } from "@sofa/db/queries/user-platforms";
|
||||
import type { titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
|
||||
@@ -683,17 +684,25 @@ async function ensureEnriched(
|
||||
return false;
|
||||
}
|
||||
|
||||
function readAvailability(titleId: string, titleName: string): AvailabilityOffer[] {
|
||||
function readAvailability(
|
||||
titleId: string,
|
||||
titleName: string,
|
||||
userPlatformIds?: Set<string>,
|
||||
): AvailabilityOffer[] {
|
||||
return getAvailabilityOffersForTitle(titleId).map((a) => ({
|
||||
providerId: a.providerId,
|
||||
platformId: a.platformId,
|
||||
providerName: a.providerName,
|
||||
logoPath: tmdbImageUrl(a.logoPath, "logos"),
|
||||
offerType: a.offerType,
|
||||
watchUrl: generateProviderUrl(a.providerId, titleName),
|
||||
watchUrl: generateProviderUrl(a.urlTemplate, titleName),
|
||||
isUserSubscribed: userPlatformIds ? userPlatformIds.has(a.platformId) : false,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getOrFetchTitle(id: string): Promise<{
|
||||
export async function getOrFetchTitle(
|
||||
id: string,
|
||||
userId?: string,
|
||||
): Promise<{
|
||||
title: ResolvedTitle;
|
||||
seasons: Season[];
|
||||
availability: AvailabilityOffer[];
|
||||
@@ -740,7 +749,8 @@ export async function getOrFetchTitle(id: string): Promise<{
|
||||
}
|
||||
|
||||
// Read enrichment data, then backfill anything missing
|
||||
let availability = readAvailability(title.id, title.title);
|
||||
const userPlatformIdSet = userId ? new Set(getUserPlatformIds(userId)) : undefined;
|
||||
let availability = readAvailability(title.id, title.title, userPlatformIdSet);
|
||||
let cast = getCastForTitle(id);
|
||||
|
||||
if (title.lastFetchedAt) {
|
||||
@@ -751,7 +761,8 @@ export async function getOrFetchTitle(id: string): Promise<{
|
||||
if (enriched) {
|
||||
// Re-read only what was missing
|
||||
if (cast.length === 0) cast = getCastForTitle(id);
|
||||
if (availability.length === 0) availability = readAvailability(title.id, title.title);
|
||||
if (availability.length === 0)
|
||||
availability = readAvailability(title.id, title.title, userPlatformIdSet);
|
||||
title = getTitleById(id) ?? title;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
getAllPlatforms,
|
||||
getUserPlatformIds,
|
||||
getUserPlatforms,
|
||||
hasUserPlatforms,
|
||||
platformIdsExist,
|
||||
setUserPlatforms,
|
||||
} from "@sofa/db/queries/user-platforms";
|
||||
|
||||
export function listPlatforms() {
|
||||
return getAllPlatforms();
|
||||
}
|
||||
|
||||
export function getUserPlatformList(userId: string) {
|
||||
return getUserPlatforms(userId);
|
||||
}
|
||||
|
||||
export function getUserPlatformIdList(userId: string) {
|
||||
return getUserPlatformIds(userId);
|
||||
}
|
||||
|
||||
export function updateUserPlatforms(userId: string, platformIds: string[]): void {
|
||||
if (platformIds.length > 0 && !platformIdsExist(platformIds)) {
|
||||
throw new Error("One or more platform IDs do not exist");
|
||||
}
|
||||
setUserPlatforms(userId, platformIds);
|
||||
}
|
||||
|
||||
export function hasUserSetPlatforms(userId: string): boolean {
|
||||
return hasUserPlatforms(userId);
|
||||
}
|
||||
@@ -1,117 +1,8 @@
|
||||
/**
|
||||
* Provider registry mapping TMDB provider IDs to search URL templates.
|
||||
*
|
||||
* To add a new provider:
|
||||
* 1. Find the TMDB provider_id (visible in availability data or TMDB API)
|
||||
* 2. Add an entry below with the service's search URL using {title} placeholder
|
||||
* Generate a watch URL for a provider using its URL template.
|
||||
* Templates use {title} as a placeholder for the URL-encoded title name.
|
||||
*/
|
||||
|
||||
interface ProviderConfig {
|
||||
name: string;
|
||||
searchUrl: string;
|
||||
}
|
||||
|
||||
// TMDB provider_id → search URL config
|
||||
const providers: Record<number, ProviderConfig> = {
|
||||
// Netflix
|
||||
8: { name: "Netflix", searchUrl: "https://www.netflix.com/search?q={title}" },
|
||||
1796: {
|
||||
name: "Netflix basic with Ads",
|
||||
searchUrl: "https://www.netflix.com/search?q={title}",
|
||||
},
|
||||
|
||||
// Amazon
|
||||
9: {
|
||||
name: "Amazon Prime Video",
|
||||
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
},
|
||||
10: {
|
||||
name: "Amazon Video",
|
||||
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
},
|
||||
119: {
|
||||
name: "Amazon Prime Video",
|
||||
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
},
|
||||
|
||||
// Disney+
|
||||
337: {
|
||||
name: "Disney+",
|
||||
searchUrl: "https://www.disneyplus.com/search/{title}",
|
||||
},
|
||||
|
||||
// Apple
|
||||
2: {
|
||||
name: "Apple iTunes",
|
||||
searchUrl: "https://tv.apple.com/search?term={title}",
|
||||
},
|
||||
350: {
|
||||
name: "Apple TV+",
|
||||
searchUrl: "https://tv.apple.com/search?term={title}",
|
||||
},
|
||||
|
||||
// Hulu
|
||||
15: { name: "Hulu", searchUrl: "https://www.hulu.com/search?q={title}" },
|
||||
|
||||
// Max (HBO)
|
||||
384: { name: "HBO Max", searchUrl: "https://play.max.com/search?q={title}" },
|
||||
1899: { name: "Max", searchUrl: "https://play.max.com/search?q={title}" },
|
||||
|
||||
// Paramount+
|
||||
531: {
|
||||
name: "Paramount+",
|
||||
searchUrl: "https://www.paramountplus.com/search/?q={title}",
|
||||
},
|
||||
|
||||
// Peacock
|
||||
386: {
|
||||
name: "Peacock",
|
||||
searchUrl: "https://www.peacocktv.com/search?q={title}",
|
||||
},
|
||||
|
||||
// Google Play
|
||||
3: {
|
||||
name: "Google Play Movies",
|
||||
searchUrl: "https://play.google.com/store/search?q={title}&c=movies",
|
||||
},
|
||||
|
||||
// YouTube
|
||||
192: {
|
||||
name: "YouTube",
|
||||
searchUrl: "https://www.youtube.com/results?search_query={title}",
|
||||
},
|
||||
|
||||
// Crunchyroll
|
||||
283: {
|
||||
name: "Crunchyroll",
|
||||
searchUrl: "https://www.crunchyroll.com/search?q={title}",
|
||||
},
|
||||
|
||||
// Free / ad-supported
|
||||
73: { name: "Tubi", searchUrl: "https://tubitv.com/search/{title}" },
|
||||
300: {
|
||||
name: "Pluto TV",
|
||||
searchUrl: "https://pluto.tv/search/details?q={title}",
|
||||
},
|
||||
|
||||
// Other
|
||||
257: { name: "fuboTV", searchUrl: "https://www.fubo.tv/search/{title}" },
|
||||
43: {
|
||||
name: "Starz",
|
||||
searchUrl: "https://www.starz.com/search?query={title}",
|
||||
},
|
||||
37: {
|
||||
name: "Showtime",
|
||||
searchUrl: "https://www.sho.com/search?q={title}",
|
||||
},
|
||||
};
|
||||
|
||||
const providerRegistry: ReadonlyMap<number, ProviderConfig> = new Map(
|
||||
Object.entries(providers).map(([id, config]) => [Number(id), config]),
|
||||
);
|
||||
|
||||
export function generateProviderUrl(providerId: number, titleName: string): string | null {
|
||||
const config = providerRegistry.get(providerId);
|
||||
if (!config) return null;
|
||||
return config.searchUrl.replace("{title}", encodeURIComponent(titleName));
|
||||
export function generateProviderUrl(urlTemplate: string | null, titleName: string): string | null {
|
||||
if (!urlTemplate) return null;
|
||||
return urlTemplate.replace("{title}", encodeURIComponent(titleName));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { availabilityOffers } from "@sofa/db/schema";
|
||||
import { clearAllTables, eq, insertAvailabilityOffer, insertTitle, testDb } from "@sofa/test/db";
|
||||
import { titleAvailability } from "@sofa/db/schema";
|
||||
import {
|
||||
clearAllTables,
|
||||
eq,
|
||||
insertPlatform,
|
||||
insertTitle,
|
||||
insertTitleAvailability,
|
||||
testDb,
|
||||
} from "@sofa/test/db";
|
||||
|
||||
const { getWatchProviders } = vi.hoisted(() => ({
|
||||
getWatchProviders: vi.fn(async () => ({ results: {} as Record<string, unknown> })),
|
||||
@@ -21,14 +28,15 @@ beforeEach(() => {
|
||||
describe("refreshAvailability", () => {
|
||||
test("clears stale US offers when TMDB returns no US availability", async () => {
|
||||
insertTitle({ id: "movie-1", tmdbId: 101, type: "movie", title: "Movie" });
|
||||
insertAvailabilityOffer("movie-1");
|
||||
const platformId = insertPlatform({ id: "p-1", tmdbProviderId: 8 });
|
||||
insertTitleAvailability("movie-1", platformId);
|
||||
|
||||
await refreshAvailability("movie-1");
|
||||
|
||||
const offers = testDb
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, "movie-1"))
|
||||
.from(titleAvailability)
|
||||
.where(eq(titleAvailability.titleId, "movie-1"))
|
||||
.all();
|
||||
|
||||
expect(offers).toHaveLength(0);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
insertAvailabilityOffer,
|
||||
insertPlatform,
|
||||
insertTitleAvailability,
|
||||
insertEpisodeWatch,
|
||||
insertMovieWatch,
|
||||
insertRating,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
|
||||
import {
|
||||
getContinueWatchingFeed,
|
||||
getLibraryFeed,
|
||||
getNewAvailableFeed,
|
||||
getRecommendationsFeed,
|
||||
getRecommendationsForTitle,
|
||||
@@ -231,7 +231,8 @@ describe("getNewAvailableFeed", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertAvailabilityOffer("m1");
|
||||
const pId = insertPlatform({ id: "p-m1", tmdbProviderId: 8 });
|
||||
insertTitleAvailability("m1", pId);
|
||||
|
||||
const feed = getNewAvailableFeed("user-1");
|
||||
expect(feed).toHaveLength(1);
|
||||
@@ -250,7 +251,8 @@ describe("getNewAvailableFeed", () => {
|
||||
test("excludes titles not in user library", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertAvailabilityOffer("m1");
|
||||
const pId = insertPlatform({ id: "p-m1", tmdbProviderId: 8 });
|
||||
insertTitleAvailability("m1", pId);
|
||||
|
||||
const feed = getNewAvailableFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
@@ -373,86 +375,3 @@ describe("getRecommendationsForTitle", () => {
|
||||
expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLibraryFeed ────────────────────────────────────────────────────
|
||||
|
||||
describe("getLibraryFeed", () => {
|
||||
test("returns first page with correct pagination metadata", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
insertAvailabilityOffer(`m${i}`);
|
||||
}
|
||||
|
||||
const result = getLibraryFeed("user-1", 1, 20);
|
||||
expect(result.items).toHaveLength(20);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.totalResults).toBe(25);
|
||||
expect(result.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
test("returns second page with remaining items", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
insertAvailabilityOffer(`m${i}`);
|
||||
}
|
||||
|
||||
const result = getLibraryFeed("user-1", 2, 20);
|
||||
expect(result.items).toHaveLength(5);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.totalResults).toBe(25);
|
||||
expect(result.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
test("custom limit respected", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
insertAvailabilityOffer(`m${i}`);
|
||||
}
|
||||
|
||||
const result = getLibraryFeed("user-1", 1, 5);
|
||||
expect(result.items).toHaveLength(5);
|
||||
expect(result.totalPages).toBe(2);
|
||||
expect(result.totalResults).toBe(10);
|
||||
});
|
||||
|
||||
test("empty page beyond total returns empty items", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
insertAvailabilityOffer(`m${i}`);
|
||||
}
|
||||
|
||||
const result = getLibraryFeed("user-1", 2, 20);
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.totalResults).toBe(5);
|
||||
});
|
||||
|
||||
test("pages are disjoint and cover all items", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
insertAvailabilityOffer(`m${i}`);
|
||||
}
|
||||
|
||||
const page1 = getLibraryFeed("user-1", 1, 2);
|
||||
const page2 = getLibraryFeed("user-1", 2, 2);
|
||||
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page2.items).toHaveLength(2);
|
||||
|
||||
const allIds = [
|
||||
...page1.items.map((i) => i.tmdbId),
|
||||
...page2.items.map((i) => i.tmdbId),
|
||||
].sort();
|
||||
expect(allIds).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { clearAllTables, insertStatus, insertTitle, insertUser } from "@sofa/test/db";
|
||||
|
||||
import { getFilteredLibraryFeed, getLibraryGenresList } from "../src/library";
|
||||
|
||||
const defaultFilters = { sortBy: "added_at", sortDirection: "desc" as const, page: 1, limit: 20 };
|
||||
|
||||
beforeAll(() => clearAllTables());
|
||||
beforeEach(() => clearAllTables());
|
||||
|
||||
// ── getFilteredLibraryFeed ─────────────────────────────────────────────
|
||||
|
||||
describe("getFilteredLibraryFeed", () => {
|
||||
test("returns first page with correct pagination metadata", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
}
|
||||
|
||||
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters });
|
||||
expect(result.items).toHaveLength(20);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.totalResults).toBe(25);
|
||||
expect(result.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
test("returns second page with remaining items", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
}
|
||||
|
||||
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters, page: 2 });
|
||||
expect(result.items).toHaveLength(5);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.totalResults).toBe(25);
|
||||
expect(result.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
test("custom limit respected", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
}
|
||||
|
||||
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters, limit: 5 });
|
||||
expect(result.items).toHaveLength(5);
|
||||
expect(result.totalPages).toBe(2);
|
||||
expect(result.totalResults).toBe(10);
|
||||
});
|
||||
|
||||
test("empty page beyond total returns empty items", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
}
|
||||
|
||||
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters, page: 2 });
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.page).toBe(2);
|
||||
});
|
||||
|
||||
test("pages are disjoint and cover all items", () => {
|
||||
insertUser();
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
insertTitle({ id: `m${i}`, tmdbId: i });
|
||||
insertStatus("user-1", `m${i}`, "watchlist");
|
||||
}
|
||||
|
||||
const page1 = getFilteredLibraryFeed("user-1", { ...defaultFilters, limit: 2 });
|
||||
const page2 = getFilteredLibraryFeed("user-1", { ...defaultFilters, page: 2, limit: 2 });
|
||||
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page2.items).toHaveLength(2);
|
||||
|
||||
const allTitleIds = new Set([
|
||||
...page1.items.map((i) => i.titleId),
|
||||
...page2.items.map((i) => i.titleId),
|
||||
]);
|
||||
expect(allTitleIds.size).toBe(4);
|
||||
});
|
||||
|
||||
test("filters by type", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1, type: "movie" });
|
||||
insertTitle({ id: "t1", tmdbId: 2, type: "tv" });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertStatus("user-1", "t1", "watchlist");
|
||||
|
||||
const movies = getFilteredLibraryFeed("user-1", { ...defaultFilters, type: "movie" });
|
||||
expect(movies.items).toHaveLength(1);
|
||||
expect(movies.items[0]!.type).toBe("movie");
|
||||
|
||||
const tv = getFilteredLibraryFeed("user-1", { ...defaultFilters, type: "tv" });
|
||||
expect(tv.items).toHaveLength(1);
|
||||
expect(tv.items[0]!.type).toBe("tv");
|
||||
});
|
||||
|
||||
test("filters by search text", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1, title: "The Matrix" });
|
||||
insertTitle({ id: "m2", tmdbId: 2, title: "Inception" });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertStatus("user-1", "m2", "watchlist");
|
||||
|
||||
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters, search: "matrix" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.title).toBe("The Matrix");
|
||||
});
|
||||
|
||||
test("filters by status", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "m2", tmdbId: 2 });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertStatus("user-1", "m2", "completed");
|
||||
|
||||
const watchlist = getFilteredLibraryFeed("user-1", {
|
||||
...defaultFilters,
|
||||
statuses: ["in_watchlist"],
|
||||
});
|
||||
expect(watchlist.items).toHaveLength(1);
|
||||
|
||||
const completed = getFilteredLibraryFeed("user-1", {
|
||||
...defaultFilters,
|
||||
statuses: ["completed"],
|
||||
});
|
||||
expect(completed.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLibraryGenresList ──────────────────────────────────────────────
|
||||
|
||||
describe("getLibraryGenresList", () => {
|
||||
test("returns empty array when user has no titles", () => {
|
||||
insertUser();
|
||||
const genres = getLibraryGenresList("user-1");
|
||||
expect(genres).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
insertAvailabilityOffer,
|
||||
insertPlatform,
|
||||
insertTitleAvailability,
|
||||
insertEpisodeWatch,
|
||||
insertStatus,
|
||||
insertTitle,
|
||||
@@ -282,13 +283,14 @@ describe("streaming provider", () => {
|
||||
const tomorrow = daysFromNow(1);
|
||||
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
|
||||
insertStatus("user-1", "tv-1", "in_progress");
|
||||
insertAvailabilityOffer("tv-1", { providerName: "Netflix", providerId: 8 });
|
||||
const pId = insertPlatform({ id: "p-netflix", name: "Netflix", tmdbProviderId: 8 });
|
||||
insertTitleAvailability("tv-1", pId, { offerType: "flatrate" });
|
||||
|
||||
const result = getUpcomingFeed("user-1", { days: 7 });
|
||||
expect(result.items[0].streamingProvider).toEqual({
|
||||
providerId: 8,
|
||||
platformId: "p-netflix",
|
||||
providerName: "Netflix",
|
||||
logoPath: null,
|
||||
logoPath: "/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,7 +298,8 @@ describe("streaming provider", () => {
|
||||
const tomorrow = daysFromNow(1);
|
||||
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
|
||||
insertStatus("user-1", "tv-1", "in_progress");
|
||||
insertAvailabilityOffer("tv-1", { offerType: "rent" });
|
||||
const pId = insertPlatform({ id: "p-rent", tmdbProviderId: 99 });
|
||||
insertTitleAvailability("tv-1", pId, { offerType: "rent" });
|
||||
|
||||
const result = getUpcomingFeed("user-1", { days: 7 });
|
||||
expect(result.items[0].streamingProvider).toBeNull();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE `platforms` (
|
||||
`id` text PRIMARY KEY,
|
||||
`name` text NOT NULL,
|
||||
`tmdbProviderId` integer,
|
||||
`logoPath` text,
|
||||
`urlTemplate` text,
|
||||
`displayOrder` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `titleAvailability` (
|
||||
`titleId` text NOT NULL,
|
||||
`platformId` text NOT NULL,
|
||||
`offerType` text NOT NULL,
|
||||
`region` text DEFAULT 'US' NOT NULL,
|
||||
`lastFetchedAt` integer,
|
||||
CONSTRAINT `fk_titleAvailability_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_titleAvailability_platformId_platforms_id_fk` FOREIGN KEY (`platformId`) REFERENCES `platforms`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `userPlatforms` (
|
||||
`userId` text NOT NULL,
|
||||
`platformId` text NOT NULL,
|
||||
CONSTRAINT `fk_userPlatforms_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_userPlatforms_platformId_platforms_id_fk` FOREIGN KEY (`platformId`) REFERENCES `platforms`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `availabilityOffers_unique`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `platforms_tmdbProviderId_unique` ON `platforms` (`tmdbProviderId`);--> statement-breakpoint
|
||||
CREATE INDEX `platforms_displayOrder` ON `platforms` (`displayOrder`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `titleAvailability_unique` ON `titleAvailability` (`titleId`,`platformId`,`offerType`,`region`);--> statement-breakpoint
|
||||
CREATE INDEX `titleAvailability_titleId` ON `titleAvailability` (`titleId`);--> statement-breakpoint
|
||||
CREATE INDEX `titleAvailability_platformId` ON `titleAvailability` (`platformId`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `userPlatforms_userId_platformId` ON `userPlatforms` (`userId`,`platformId`);--> statement-breakpoint
|
||||
CREATE INDEX `userPlatforms_userId` ON `userPlatforms` (`userId`);--> statement-breakpoint
|
||||
DROP TABLE `availabilityOffers`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
"./client": "./src/client.ts",
|
||||
"./migrate": "./src/migrate.ts",
|
||||
"./schema": "./src/schema.ts",
|
||||
"./queries/*": "./src/queries/*.ts"
|
||||
"./queries/*": "./src/queries/*.ts",
|
||||
"./seed-platforms": "./src/seed-platforms.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint",
|
||||
|
||||
@@ -1,24 +1,66 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { availabilityOffers } from "../schema";
|
||||
import { platforms, titleAvailability } from "../schema";
|
||||
|
||||
export function replaceAvailabilityTransaction(
|
||||
titleId: string,
|
||||
region: string,
|
||||
offers: (typeof availabilityOffers.$inferInsert)[],
|
||||
offers: (typeof titleAvailability.$inferInsert)[],
|
||||
): void {
|
||||
db.transaction((tx) => {
|
||||
tx.delete(availabilityOffers)
|
||||
.where(and(eq(availabilityOffers.titleId, titleId), eq(availabilityOffers.region, region)))
|
||||
tx.delete(titleAvailability)
|
||||
.where(and(eq(titleAvailability.titleId, titleId), eq(titleAvailability.region, region)))
|
||||
.run();
|
||||
|
||||
if (offers.length > 0) {
|
||||
tx.insert(availabilityOffers).values(offers).onConflictDoNothing().run();
|
||||
tx.insert(titleAvailability).values(offers).onConflictDoNothing().run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvailabilityOffers(titleId: string) {
|
||||
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
|
||||
export function getAvailabilityForTitle(titleId: string) {
|
||||
return db
|
||||
.select({
|
||||
platformId: platforms.id,
|
||||
providerName: platforms.name,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
offerType: titleAvailability.offerType,
|
||||
})
|
||||
.from(titleAvailability)
|
||||
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
|
||||
.where(eq(titleAvailability.titleId, titleId))
|
||||
.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a platform row exists for a TMDB provider. Upserts by tmdbProviderId.
|
||||
* Returns the platform ID.
|
||||
*/
|
||||
export function ensurePlatformForTmdbProvider(
|
||||
tmdbProviderId: number,
|
||||
name: string,
|
||||
logoPath: string | null,
|
||||
): string {
|
||||
const row = db
|
||||
.insert(platforms)
|
||||
.values({
|
||||
tmdbProviderId,
|
||||
name,
|
||||
logoPath,
|
||||
displayOrder: 999,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: platforms.tmdbProviderId,
|
||||
set: {
|
||||
name: sql`excluded.name`,
|
||||
logoPath: sql`excluded.logoPath`,
|
||||
},
|
||||
})
|
||||
.returning({ id: platforms.id })
|
||||
.get();
|
||||
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { and, eq, gte, inArray, isNotNull, lt, or } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
cronRuns,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleCast,
|
||||
titleRecommendations,
|
||||
titles,
|
||||
@@ -64,10 +64,10 @@ export function getTitlesWithStaleOffers(titleIds: string[]) {
|
||||
if (titleIds.length === 0) return new Set<string>();
|
||||
return new Set(
|
||||
db
|
||||
.select({ titleId: availabilityOffers.titleId })
|
||||
.from(availabilityOffers)
|
||||
.where(inArray(availabilityOffers.titleId, titleIds))
|
||||
.groupBy(availabilityOffers.titleId)
|
||||
.select({ titleId: titleAvailability.titleId })
|
||||
.from(titleAvailability)
|
||||
.where(inArray(titleAvailability.titleId, titleIds))
|
||||
.groupBy(titleAvailability.titleId)
|
||||
.all()
|
||||
.map((r) => r.titleId),
|
||||
);
|
||||
@@ -77,15 +77,15 @@ export function getTitlesWithStaleOffersFetchedBefore(titleIds: string[], staleD
|
||||
if (titleIds.length === 0) return new Set<string>();
|
||||
return new Set(
|
||||
db
|
||||
.select({ titleId: availabilityOffers.titleId })
|
||||
.from(availabilityOffers)
|
||||
.select({ titleId: titleAvailability.titleId })
|
||||
.from(titleAvailability)
|
||||
.where(
|
||||
and(
|
||||
inArray(availabilityOffers.titleId, titleIds),
|
||||
lt(availabilityOffers.lastFetchedAt, staleDate),
|
||||
inArray(titleAvailability.titleId, titleIds),
|
||||
lt(titleAvailability.lastFetchedAt, staleDate),
|
||||
),
|
||||
)
|
||||
.groupBy(availabilityOffers.titleId)
|
||||
.groupBy(titleAvailability.titleId)
|
||||
.all()
|
||||
.map((r) => r.titleId),
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
episodes,
|
||||
platforms,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleRecommendations,
|
||||
titles,
|
||||
userEpisodeWatches,
|
||||
@@ -189,64 +190,13 @@ export function getNewAvailableFeed(userId: string, _days = 14) {
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`,
|
||||
sql`EXISTS (SELECT 1 FROM ${titleAvailability} WHERE ${titleAvailability.titleId} = ${titles.id})`,
|
||||
)
|
||||
.orderBy(desc(titles.popularity))
|
||||
.limit(20)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getLibraryFeed(userId: string, page = 1, limit = 20) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const availabilityFilter = sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`;
|
||||
const joinCondition = and(
|
||||
eq(userTitleStatus.titleId, titles.id),
|
||||
eq(userTitleStatus.userId, userId),
|
||||
);
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
type: titles.type,
|
||||
tmdbId: titles.tmdbId,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
popularity: titles.popularity,
|
||||
userStatus: userTitleStatus.status,
|
||||
totalCount: sql<number>`count(*) over()`.as("totalCount"),
|
||||
})
|
||||
.from(titles)
|
||||
.innerJoin(userTitleStatus, joinCondition)
|
||||
.where(availabilityFilter)
|
||||
.orderBy(desc(titles.popularity))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
let totalResults = rows[0]?.totalCount ?? 0;
|
||||
if (rows.length === 0 && offset > 0) {
|
||||
const [{ count }] = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(titles)
|
||||
.innerJoin(userTitleStatus, joinCondition)
|
||||
.where(availabilityFilter)
|
||||
.all();
|
||||
totalResults = count ?? 0;
|
||||
}
|
||||
const items = rows.map(({ totalCount: _, ...item }) => item);
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
totalPages: Math.max(1, Math.ceil(totalResults / limit)),
|
||||
totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEngagedTitleIds(userId: string) {
|
||||
return db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
@@ -315,7 +265,22 @@ export function getTitleByIdOrNull(titleId: string) {
|
||||
|
||||
// ─── Upcoming feed queries ──────────────────────────────────────────
|
||||
|
||||
export function getUpcomingEpisodes(userId: string, fromDate: string, toDate: string) {
|
||||
export function getUpcomingEpisodes(
|
||||
userId: string,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
statusFilter?: string[],
|
||||
) {
|
||||
const conditions = [gte(episodes.airDate, fromDate), lte(episodes.airDate, toDate)];
|
||||
if (statusFilter && statusFilter.length > 0) {
|
||||
conditions.push(
|
||||
sql`${userTitleStatus.status} IN (${sql.join(
|
||||
statusFilter.map((s) => sql`${s}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select({
|
||||
episodeId: episodes.id,
|
||||
@@ -338,12 +303,31 @@ export function getUpcomingEpisodes(userId: string, fromDate: string, toDate: st
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(and(gte(episodes.airDate, fromDate), lte(episodes.airDate, toDate)))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(episodes.airDate), asc(titles.title))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getUpcomingMovies(userId: string, fromDate: string, toDate: string) {
|
||||
export function getUpcomingMovies(
|
||||
userId: string,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
statusFilter?: string[],
|
||||
) {
|
||||
const conditions = [
|
||||
eq(titles.type, "movie"),
|
||||
gte(titles.releaseDate, fromDate),
|
||||
lte(titles.releaseDate, toDate),
|
||||
];
|
||||
if (statusFilter && statusFilter.length > 0) {
|
||||
conditions.push(
|
||||
sql`${userTitleStatus.status} IN (${sql.join(
|
||||
statusFilter.map((s) => sql`${s}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
@@ -360,13 +344,7 @@ export function getUpcomingMovies(userId: string, fromDate: string, toDate: stri
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(titles.type, "movie"),
|
||||
gte(titles.releaseDate, fromDate),
|
||||
lte(titles.releaseDate, toDate),
|
||||
),
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(titles.releaseDate), asc(titles.title))
|
||||
.all();
|
||||
}
|
||||
@@ -375,16 +353,17 @@ export function getAvailabilityByTitleIds(titleIds: string[]) {
|
||||
if (titleIds.length === 0) return [];
|
||||
return db
|
||||
.select({
|
||||
titleId: availabilityOffers.titleId,
|
||||
providerId: availabilityOffers.providerId,
|
||||
providerName: availabilityOffers.providerName,
|
||||
logoPath: availabilityOffers.logoPath,
|
||||
titleId: titleAvailability.titleId,
|
||||
platformId: platforms.id,
|
||||
providerName: platforms.name,
|
||||
logoPath: platforms.logoPath,
|
||||
})
|
||||
.from(availabilityOffers)
|
||||
.from(titleAvailability)
|
||||
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(availabilityOffers.titleId, titleIds),
|
||||
eq(availabilityOffers.offerType, "flatrate"),
|
||||
inArray(titleAvailability.titleId, titleIds),
|
||||
eq(titleAvailability.offerType, "flatrate"),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { availabilityOffers, episodes, persons, seasons, titleCast, titles } from "../schema";
|
||||
import {
|
||||
episodes,
|
||||
persons,
|
||||
platforms,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleCast,
|
||||
titles,
|
||||
} from "../schema";
|
||||
|
||||
// ─── Title image paths ───────────────────────────────────────────────
|
||||
|
||||
@@ -51,9 +59,10 @@ export function getEpisodeStillsForTitle(titleId: string) {
|
||||
|
||||
export function getAvailabilityLogosForTitle(titleId: string) {
|
||||
return db
|
||||
.select({ logoPath: availabilityOffers.logoPath })
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, titleId))
|
||||
.select({ logoPath: platforms.logoPath })
|
||||
.from(titleAvailability)
|
||||
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
|
||||
.where(eq(titleAvailability.titleId, titleId))
|
||||
.all();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { and, asc, countDistinct, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
episodes,
|
||||
genres,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleGenres,
|
||||
titles,
|
||||
userEpisodeWatches,
|
||||
userPlatforms,
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
} from "../schema";
|
||||
|
||||
// ─── Display status SQL ─────────────────────────────────────────────
|
||||
// Derives the user-facing display status inline for filtering/output.
|
||||
// Mirrors the logic in @sofa/core/display-status.ts.
|
||||
// Drizzle has no CASE/WHEN builder, but subqueries built with db.select()
|
||||
// can be embedded in sql`` templates for type-safe column references.
|
||||
|
||||
function airedEpisodeCount(titleId: typeof titles.id, today: string) {
|
||||
return db
|
||||
.select({ count: countDistinct(episodes.id) })
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(
|
||||
and(eq(seasons.titleId, titleId), isNotNull(episodes.airDate), lte(episodes.airDate, today)),
|
||||
);
|
||||
}
|
||||
|
||||
function watchedEpisodeCount(
|
||||
titleId: typeof titles.id,
|
||||
userId: typeof userTitleStatus.userId,
|
||||
today: string,
|
||||
) {
|
||||
return db
|
||||
.select({ count: countDistinct(userEpisodeWatches.episodeId) })
|
||||
.from(userEpisodeWatches)
|
||||
.innerJoin(episodes, eq(userEpisodeWatches.episodeId, episodes.id))
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(
|
||||
and(
|
||||
eq(seasons.titleId, titleId),
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
isNotNull(episodes.airDate),
|
||||
lte(episodes.airDate, today),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function displayStatusExpr() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const aired = airedEpisodeCount(titles.id, today);
|
||||
const watched = watchedEpisodeCount(titles.id, userTitleStatus.userId, today);
|
||||
|
||||
// Order must match @sofa/core/display-status.ts:
|
||||
// 1. watchlist → in_watchlist (any type)
|
||||
// 2. movie → completed if stored=completed, else in_watchlist
|
||||
// 3. TV completed → completed
|
||||
// 4. TV in_progress → check episode progress
|
||||
return sql<string>`(
|
||||
CASE
|
||||
WHEN ${userTitleStatus.status} = 'watchlist' THEN 'in_watchlist'
|
||||
WHEN ${titles.type} = 'movie' THEN
|
||||
CASE WHEN ${userTitleStatus.status} = 'completed' THEN 'completed' ELSE 'in_watchlist' END
|
||||
WHEN ${userTitleStatus.status} = 'completed' THEN 'completed'
|
||||
WHEN (${aired}) > 0 AND (${aired}) = (${watched})
|
||||
THEN CASE
|
||||
WHEN ${titles.status} IN ('Returning Series', 'In Production') THEN 'caught_up'
|
||||
ELSE 'completed'
|
||||
END
|
||||
ELSE 'watching'
|
||||
END
|
||||
)`;
|
||||
}
|
||||
|
||||
// ─── Filtered library feed ──────────────────────────────────────────
|
||||
|
||||
export interface LibraryFilters {
|
||||
search?: string;
|
||||
statuses?: string[];
|
||||
type?: "movie" | "tv";
|
||||
genreId?: number;
|
||||
ratingMin?: number;
|
||||
ratingMax?: number;
|
||||
yearMin?: number;
|
||||
yearMax?: number;
|
||||
contentRating?: string;
|
||||
onMyServices?: boolean;
|
||||
sortBy: string;
|
||||
sortDirection: "asc" | "desc";
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
||||
const offset = (filters.page - 1) * filters.limit;
|
||||
// Only "in_watchlist" maps 1:1 to a stored status. The others (watching, caught_up, completed)
|
||||
// all involve derived logic from episode progress / TMDB show status, so we need the full
|
||||
// display-status SQL computation to filter them correctly.
|
||||
const needsDisplayStatus =
|
||||
filters.statuses &&
|
||||
filters.statuses.some((s) => s === "watching" || s === "caught_up" || s === "completed");
|
||||
|
||||
// Build WHERE conditions
|
||||
const conditions = [eq(userTitleStatus.userId, userId)];
|
||||
|
||||
if (filters.search) {
|
||||
conditions.push(sql`${titles.title} LIKE ${"%" + filters.search + "%"}`);
|
||||
}
|
||||
|
||||
if (filters.type) {
|
||||
conditions.push(eq(titles.type, filters.type));
|
||||
}
|
||||
|
||||
if (filters.genreId) {
|
||||
conditions.push(
|
||||
sql`EXISTS (SELECT 1 FROM ${titleGenres} WHERE ${titleGenres.titleId} = ${titles.id} AND ${titleGenres.genreId} = ${filters.genreId})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.ratingMin != null || filters.ratingMax != null) {
|
||||
conditions.push(sql`${userRatings.ratingStars} IS NOT NULL`);
|
||||
if (filters.ratingMin != null) {
|
||||
conditions.push(gte(userRatings.ratingStars, filters.ratingMin));
|
||||
}
|
||||
if (filters.ratingMax != null) {
|
||||
conditions.push(lte(userRatings.ratingStars, filters.ratingMax));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.yearMin != null || filters.yearMax != null) {
|
||||
const yearExpr = sql`CAST(strftime('%Y', COALESCE(${titles.releaseDate}, ${titles.firstAirDate})) AS INTEGER)`;
|
||||
if (filters.yearMin != null) {
|
||||
conditions.push(sql`${yearExpr} >= ${filters.yearMin}`);
|
||||
}
|
||||
if (filters.yearMax != null) {
|
||||
conditions.push(sql`${yearExpr} <= ${filters.yearMax}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.contentRating) {
|
||||
conditions.push(eq(titles.contentRating, filters.contentRating));
|
||||
}
|
||||
|
||||
if (filters.onMyServices) {
|
||||
// Check if user has platforms set; if so, filter to titles available on their services
|
||||
const userHasPlatforms =
|
||||
db
|
||||
.select({ platformId: userPlatforms.platformId })
|
||||
.from(userPlatforms)
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.limit(1)
|
||||
.get() != null;
|
||||
|
||||
if (userHasPlatforms) {
|
||||
conditions.push(
|
||||
sql`EXISTS (
|
||||
SELECT 1 FROM ${titleAvailability} ta
|
||||
JOIN ${userPlatforms} up ON ta.platformId = up.platformId
|
||||
WHERE ta.titleId = ${titles.id} AND up.userId = ${userId}
|
||||
)`,
|
||||
);
|
||||
} else {
|
||||
// Fallback: any availability
|
||||
conditions.push(
|
||||
sql`EXISTS (SELECT 1 FROM ${titleAvailability} WHERE ${titleAvailability.titleId} = ${titles.id})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Status filtering: optimize simple cases
|
||||
if (filters.statuses && filters.statuses.length > 0 && !needsDisplayStatus) {
|
||||
// Map display statuses to stored statuses for simple cases
|
||||
const storedStatuses: string[] = [];
|
||||
for (const s of filters.statuses) {
|
||||
if (s === "in_watchlist") storedStatuses.push("watchlist");
|
||||
if (s === "completed") storedStatuses.push("completed");
|
||||
}
|
||||
if (storedStatuses.length === 1) {
|
||||
conditions.push(eq(userTitleStatus.status, storedStatuses[0] as "watchlist" | "completed"));
|
||||
} else if (storedStatuses.length > 1) {
|
||||
conditions.push(
|
||||
sql`${userTitleStatus.status} IN (${sql.join(
|
||||
storedStatuses.map((s) => sql`${s}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort expression
|
||||
const dirFn = filters.sortDirection === "asc" ? asc : desc;
|
||||
const sortExpressions = [];
|
||||
|
||||
switch (filters.sortBy) {
|
||||
case "title":
|
||||
sortExpressions.push(dirFn(titles.title));
|
||||
break;
|
||||
case "added_at":
|
||||
sortExpressions.push(dirFn(userTitleStatus.addedAt));
|
||||
break;
|
||||
case "release_date":
|
||||
sortExpressions.push(dirFn(sql`COALESCE(${titles.releaseDate}, ${titles.firstAirDate})`));
|
||||
break;
|
||||
case "popularity":
|
||||
sortExpressions.push(dirFn(titles.popularity));
|
||||
break;
|
||||
case "user_rating":
|
||||
// NULLS LAST: put unrated items at the end
|
||||
sortExpressions.push(
|
||||
asc(sql`CASE WHEN ${userRatings.ratingStars} IS NULL THEN 1 ELSE 0 END`),
|
||||
);
|
||||
sortExpressions.push(dirFn(userRatings.ratingStars));
|
||||
break;
|
||||
case "vote_average":
|
||||
sortExpressions.push(dirFn(titles.voteAverage));
|
||||
break;
|
||||
default:
|
||||
sortExpressions.push(desc(userTitleStatus.addedAt));
|
||||
}
|
||||
|
||||
// Build query with display status when needed for filtering
|
||||
if (needsDisplayStatus) {
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
type: titles.type,
|
||||
tmdbId: titles.tmdbId,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
popularity: titles.popularity,
|
||||
userStatus: userTitleStatus.status,
|
||||
userRating: userRatings.ratingStars,
|
||||
displayStatus: displayStatusExpr().as("display_status"),
|
||||
})
|
||||
.from(titles)
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.leftJoin(
|
||||
userRatings,
|
||||
and(eq(userRatings.titleId, titles.id), eq(userRatings.userId, userId)),
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(...sortExpressions)
|
||||
.all();
|
||||
|
||||
// Post-filter by display status
|
||||
const filtered = rows.filter((r) => filters.statuses!.includes(r.displayStatus));
|
||||
const totalResults = filtered.length;
|
||||
const paged = filtered.slice(offset, offset + filters.limit);
|
||||
|
||||
return {
|
||||
items: paged.map((row) => {
|
||||
const { displayStatus, ...item } = row;
|
||||
return Object.assign(item, { displayStatus });
|
||||
}),
|
||||
page: filters.page,
|
||||
totalPages: Math.max(1, Math.ceil(totalResults / filters.limit)),
|
||||
totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
// Standard query (no display status computation needed)
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
type: titles.type,
|
||||
tmdbId: titles.tmdbId,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
popularity: titles.popularity,
|
||||
userStatus: userTitleStatus.status,
|
||||
userRating: userRatings.ratingStars,
|
||||
totalCount: sql<number>`count(*) over()`.as("totalCount"),
|
||||
})
|
||||
.from(titles)
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.leftJoin(userRatings, and(eq(userRatings.titleId, titles.id), eq(userRatings.userId, userId)))
|
||||
.where(and(...conditions))
|
||||
.orderBy(...sortExpressions)
|
||||
.limit(filters.limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
const totalResults = rows[0]?.totalCount ?? 0;
|
||||
const items = rows.map(({ totalCount: _, ...item }) => item);
|
||||
|
||||
return {
|
||||
items,
|
||||
page: filters.page,
|
||||
totalPages: Math.max(1, Math.ceil(totalResults / filters.limit)),
|
||||
totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Library genres ─────────────────────────────────────────────────
|
||||
|
||||
export function getLibraryGenres(userId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: genres.id,
|
||||
name: genres.name,
|
||||
})
|
||||
.from(genres)
|
||||
.innerJoin(titleGenres, eq(titleGenres.genreId, genres.id))
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titleGenres.titleId), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.groupBy(genres.id, genres.name)
|
||||
.orderBy(asc(genres.name))
|
||||
.all();
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
episodes,
|
||||
genres,
|
||||
platforms,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleGenres,
|
||||
titleRecommendations,
|
||||
titles,
|
||||
@@ -260,7 +261,19 @@ export function getEpisodesNeedingStillHash(seasonIds: string[]) {
|
||||
// ─── Availability ────────────────────────────────────────────────────
|
||||
|
||||
export function getAvailabilityOffersForTitle(titleId: string) {
|
||||
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
|
||||
return db
|
||||
.select({
|
||||
platformId: platforms.id,
|
||||
providerName: platforms.name,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
offerType: titleAvailability.offerType,
|
||||
})
|
||||
.from(titleAvailability)
|
||||
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
|
||||
.where(eq(titleAvailability.titleId, titleId))
|
||||
.all();
|
||||
}
|
||||
|
||||
// ─── Recommendations ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { platforms, userPlatforms } from "../schema";
|
||||
|
||||
export function getUserPlatformIds(userId: string): string[] {
|
||||
return db
|
||||
.select({ platformId: userPlatforms.platformId })
|
||||
.from(userPlatforms)
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.all()
|
||||
.map((r) => r.platformId);
|
||||
}
|
||||
|
||||
export function getUserPlatforms(userId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: platforms.id,
|
||||
name: platforms.name,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
displayOrder: platforms.displayOrder,
|
||||
})
|
||||
.from(userPlatforms)
|
||||
.innerJoin(platforms, eq(userPlatforms.platformId, platforms.id))
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.orderBy(platforms.displayOrder)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function setUserPlatforms(userId: string, platformIds: string[]): void {
|
||||
db.transaction((tx) => {
|
||||
tx.delete(userPlatforms).where(eq(userPlatforms.userId, userId)).run();
|
||||
if (platformIds.length > 0) {
|
||||
tx.insert(userPlatforms)
|
||||
.values(platformIds.map((platformId) => ({ userId, platformId })))
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getAllPlatforms() {
|
||||
return db.select().from(platforms).orderBy(platforms.displayOrder).all();
|
||||
}
|
||||
|
||||
export function hasUserPlatforms(userId: string): boolean {
|
||||
return (
|
||||
db
|
||||
.select({ platformId: userPlatforms.platformId })
|
||||
.from(userPlatforms)
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.limit(1)
|
||||
.get() != null
|
||||
);
|
||||
}
|
||||
|
||||
export function platformIdsExist(platformIds: string[]): boolean {
|
||||
if (platformIds.length === 0) return true;
|
||||
const unique = [...new Set(platformIds)];
|
||||
const found = db
|
||||
.select({ id: platforms.id })
|
||||
.from(platforms)
|
||||
.where(inArray(platforms.id, unique))
|
||||
.all();
|
||||
return found.length === unique.length;
|
||||
}
|
||||
+45
-10
@@ -249,29 +249,64 @@ export const userRatings = sqliteTable(
|
||||
(table) => [uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId)],
|
||||
);
|
||||
|
||||
export const availabilityOffers = sqliteTable(
|
||||
"availabilityOffers",
|
||||
// ─── Platforms & Availability ────────────────────────────────────────
|
||||
|
||||
export const platforms = sqliteTable(
|
||||
"platforms",
|
||||
{
|
||||
id: uuidPk(),
|
||||
name: text("name").notNull(),
|
||||
tmdbProviderId: int("tmdbProviderId"),
|
||||
logoPath: text("logoPath"),
|
||||
urlTemplate: text("urlTemplate"),
|
||||
displayOrder: int("displayOrder").notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("platforms_tmdbProviderId_unique").on(table.tmdbProviderId),
|
||||
index("platforms_displayOrder").on(table.displayOrder),
|
||||
],
|
||||
);
|
||||
|
||||
export const titleAvailability = sqliteTable(
|
||||
"titleAvailability",
|
||||
{
|
||||
titleId: text("titleId")
|
||||
.notNull()
|
||||
.references(() => titles.id, { onDelete: "cascade" }),
|
||||
region: text("region").notNull().default("US"),
|
||||
providerId: int("providerId").notNull(),
|
||||
providerName: text("providerName").notNull(),
|
||||
logoPath: text("logoPath"),
|
||||
platformId: text("platformId")
|
||||
.notNull()
|
||||
.references(() => platforms.id, { onDelete: "cascade" }),
|
||||
offerType: text("offerType", {
|
||||
enum: ["flatrate", "rent", "buy", "free", "ads"],
|
||||
}).notNull(),
|
||||
link: text("link"),
|
||||
region: text("region").notNull().default("US"),
|
||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("availabilityOffers_unique").on(
|
||||
uniqueIndex("titleAvailability_unique").on(
|
||||
table.titleId,
|
||||
table.region,
|
||||
table.providerId,
|
||||
table.platformId,
|
||||
table.offerType,
|
||||
table.region,
|
||||
),
|
||||
index("titleAvailability_titleId").on(table.titleId),
|
||||
index("titleAvailability_platformId").on(table.platformId),
|
||||
],
|
||||
);
|
||||
|
||||
export const userPlatforms = sqliteTable(
|
||||
"userPlatforms",
|
||||
{
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
platformId: text("platformId")
|
||||
.notNull()
|
||||
.references(() => platforms.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("userPlatforms_userId_platformId").on(table.userId, table.platformId),
|
||||
index("userPlatforms_userId").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "./client";
|
||||
import { platforms } from "./schema";
|
||||
|
||||
interface SeedPlatform {
|
||||
tmdbProviderId: number;
|
||||
name: string;
|
||||
urlTemplate: string;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
const SEED_DATA: SeedPlatform[] = [
|
||||
// Netflix
|
||||
{
|
||||
tmdbProviderId: 8,
|
||||
name: "Netflix",
|
||||
urlTemplate: "https://www.netflix.com/search?q={title}",
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 1796,
|
||||
name: "Netflix basic with Ads",
|
||||
urlTemplate: "https://www.netflix.com/search?q={title}",
|
||||
displayOrder: 2,
|
||||
},
|
||||
|
||||
// Amazon
|
||||
{
|
||||
tmdbProviderId: 9,
|
||||
name: "Amazon Prime Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 3,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 10,
|
||||
name: "Amazon Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 4,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 119,
|
||||
name: "Amazon Prime Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 5,
|
||||
},
|
||||
|
||||
// Disney+
|
||||
{
|
||||
tmdbProviderId: 337,
|
||||
name: "Disney+",
|
||||
urlTemplate: "https://www.disneyplus.com/search/{title}",
|
||||
displayOrder: 6,
|
||||
},
|
||||
|
||||
// Apple
|
||||
{
|
||||
tmdbProviderId: 2,
|
||||
name: "Apple iTunes",
|
||||
urlTemplate: "https://tv.apple.com/search?term={title}",
|
||||
displayOrder: 7,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 350,
|
||||
name: "Apple TV+",
|
||||
urlTemplate: "https://tv.apple.com/search?term={title}",
|
||||
displayOrder: 8,
|
||||
},
|
||||
|
||||
// Hulu
|
||||
{
|
||||
tmdbProviderId: 15,
|
||||
name: "Hulu",
|
||||
urlTemplate: "https://www.hulu.com/search?q={title}",
|
||||
displayOrder: 9,
|
||||
},
|
||||
|
||||
// Max (HBO)
|
||||
{
|
||||
tmdbProviderId: 384,
|
||||
name: "HBO Max",
|
||||
urlTemplate: "https://play.max.com/search?q={title}",
|
||||
displayOrder: 10,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 1899,
|
||||
name: "Max",
|
||||
urlTemplate: "https://play.max.com/search?q={title}",
|
||||
displayOrder: 11,
|
||||
},
|
||||
|
||||
// Paramount+
|
||||
{
|
||||
tmdbProviderId: 531,
|
||||
name: "Paramount+",
|
||||
urlTemplate: "https://www.paramountplus.com/search/?q={title}",
|
||||
displayOrder: 12,
|
||||
},
|
||||
|
||||
// Peacock
|
||||
{
|
||||
tmdbProviderId: 386,
|
||||
name: "Peacock",
|
||||
urlTemplate: "https://www.peacocktv.com/search?q={title}",
|
||||
displayOrder: 13,
|
||||
},
|
||||
|
||||
// Google Play
|
||||
{
|
||||
tmdbProviderId: 3,
|
||||
name: "Google Play Movies",
|
||||
urlTemplate: "https://play.google.com/store/search?q={title}&c=movies",
|
||||
displayOrder: 14,
|
||||
},
|
||||
|
||||
// YouTube
|
||||
{
|
||||
tmdbProviderId: 192,
|
||||
name: "YouTube",
|
||||
urlTemplate: "https://www.youtube.com/results?search_query={title}",
|
||||
displayOrder: 15,
|
||||
},
|
||||
|
||||
// Crunchyroll
|
||||
{
|
||||
tmdbProviderId: 283,
|
||||
name: "Crunchyroll",
|
||||
urlTemplate: "https://www.crunchyroll.com/search?q={title}",
|
||||
displayOrder: 16,
|
||||
},
|
||||
|
||||
// Free / ad-supported
|
||||
{
|
||||
tmdbProviderId: 73,
|
||||
name: "Tubi",
|
||||
urlTemplate: "https://tubitv.com/search/{title}",
|
||||
displayOrder: 17,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 300,
|
||||
name: "Pluto TV",
|
||||
urlTemplate: "https://pluto.tv/search/details?q={title}",
|
||||
displayOrder: 18,
|
||||
},
|
||||
|
||||
// Other
|
||||
{
|
||||
tmdbProviderId: 257,
|
||||
name: "fuboTV",
|
||||
urlTemplate: "https://www.fubo.tv/search/{title}",
|
||||
displayOrder: 19,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 43,
|
||||
name: "Starz",
|
||||
urlTemplate: "https://www.starz.com/search?query={title}",
|
||||
displayOrder: 20,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 37,
|
||||
name: "Showtime",
|
||||
urlTemplate: "https://www.sho.com/search?q={title}",
|
||||
displayOrder: 21,
|
||||
},
|
||||
];
|
||||
|
||||
export function seedPlatforms(): void {
|
||||
const insert = db
|
||||
.insert(platforms)
|
||||
.values({
|
||||
id: sql.placeholder("id"),
|
||||
tmdbProviderId: sql.placeholder("tmdbProviderId"),
|
||||
name: sql.placeholder("name"),
|
||||
urlTemplate: sql.placeholder("urlTemplate"),
|
||||
displayOrder: sql.placeholder("displayOrder"),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: platforms.tmdbProviderId,
|
||||
set: {
|
||||
name: sql`excluded.name`,
|
||||
urlTemplate: sql`excluded.urlTemplate`,
|
||||
displayOrder: sql`excluded.displayOrder`,
|
||||
},
|
||||
})
|
||||
.prepare();
|
||||
|
||||
db.transaction(() => {
|
||||
for (const p of SEED_DATA) {
|
||||
insert.execute({
|
||||
id: Bun.randomUUIDv7(),
|
||||
tmdbProviderId: p.tmdbProviderId,
|
||||
name: p.name,
|
||||
urlTemplate: p.urlTemplate,
|
||||
displayOrder: p.displayOrder,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -177,6 +177,10 @@ msgstr "{ratingCount} ratings"
|
||||
msgid "{star, plural, one {# star} other {# stars}}"
|
||||
msgstr "{star, plural, one {# star} other {# stars}}"
|
||||
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "{totalResults, plural, one {# result} other {# results}}"
|
||||
msgstr "{totalResults, plural, one {# result} other {# results}}"
|
||||
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "{unresolvedCount} items have no external IDs and will be resolved by title search, which may be less accurate."
|
||||
msgstr "{unresolvedCount} items have no external IDs and will be resolved by title search, which may be less accurate."
|
||||
@@ -302,11 +306,36 @@ msgstr ""
|
||||
msgid "age {age}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/native/src/components/explore/filterable-title-row.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "All genres"
|
||||
msgstr "All genres"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "All providers"
|
||||
msgstr "All providers"
|
||||
|
||||
#: apps/web/src/components/settings/registration-section.tsx
|
||||
msgid "Allow new users to create accounts"
|
||||
msgstr ""
|
||||
@@ -329,6 +358,22 @@ msgstr ""
|
||||
msgid "Anonymous usage reporting"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Any"
|
||||
msgstr "Any"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Any language"
|
||||
msgstr "Any language"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Any rating"
|
||||
msgstr "Any rating"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Any year"
|
||||
msgstr "Any year"
|
||||
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "App Settings"
|
||||
msgstr ""
|
||||
@@ -337,6 +382,10 @@ msgstr ""
|
||||
msgid "Application"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
msgid "Apply"
|
||||
msgstr "Apply"
|
||||
|
||||
#: apps/native/src/components/settings/integration-card.tsx
|
||||
msgid "Are you sure you want to disconnect {label}? The current URL will stop working."
|
||||
msgstr ""
|
||||
@@ -374,6 +423,13 @@ msgstr ""
|
||||
msgid "Availability"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Available to stream"
|
||||
msgstr "Available to stream"
|
||||
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Back to Login"
|
||||
msgstr ""
|
||||
@@ -458,8 +514,10 @@ msgstr ""
|
||||
msgid "Catch up"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
msgid "Caught Up"
|
||||
@@ -509,6 +567,10 @@ msgstr ""
|
||||
msgid "Checking…"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Chinese"
|
||||
msgstr "Chinese"
|
||||
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#~ msgid "Choose how to import your {0} data."
|
||||
#~ msgstr ""
|
||||
@@ -521,6 +583,7 @@ msgstr "Choose how to import your {sourceLabel} data."
|
||||
#~ msgid "Choose your preferred display language"
|
||||
#~ msgstr "Choose your preferred display language"
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
@@ -529,9 +592,18 @@ msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/command-palette.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Clear all"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/library.tsx
|
||||
msgid "Clear all filters"
|
||||
msgstr "Clear all filters"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
msgid "Clear filters"
|
||||
msgstr "Clear filters"
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Clear Recently Viewed?"
|
||||
@@ -561,9 +633,11 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/dashboard/stats-display.tsx
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
msgid "Completed"
|
||||
@@ -631,6 +705,16 @@ msgstr ""
|
||||
msgid "Connecting…"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Content rating"
|
||||
msgstr "Content rating"
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Content Rating"
|
||||
msgstr "Content Rating"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Continue"
|
||||
msgstr ""
|
||||
@@ -731,10 +815,23 @@ msgstr ""
|
||||
msgid "Database restored. Reloading..."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Date Added"
|
||||
msgstr "Date Added"
|
||||
|
||||
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||
msgid "Day:"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Decade"
|
||||
msgstr "Decade"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Default"
|
||||
msgstr "Default"
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Delete"
|
||||
@@ -784,6 +881,10 @@ msgstr ""
|
||||
msgid "Disconnect {label}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Discover"
|
||||
msgstr "Discover"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Display name"
|
||||
msgstr "Display name"
|
||||
@@ -869,6 +970,10 @@ msgstr ""
|
||||
msgid "Enable the <0>Playback</0> event category"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: apps/native/src/app/(auth)/login.tsx
|
||||
#: apps/native/src/app/(auth)/register.tsx
|
||||
msgid "Enter a valid email address"
|
||||
@@ -1184,6 +1289,12 @@ msgstr ""
|
||||
msgid "Filmography"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Filters"
|
||||
msgstr "Filters"
|
||||
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Finished importing from {source}."
|
||||
msgstr ""
|
||||
@@ -1200,6 +1311,10 @@ msgstr ""
|
||||
msgid "Free up disk space by clearing cached metadata and images"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||
msgid "Frequency"
|
||||
msgstr ""
|
||||
@@ -1208,6 +1323,24 @@ msgstr ""
|
||||
msgid "Friday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "From"
|
||||
msgstr "From"
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Genre"
|
||||
msgstr "Genre"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
#: apps/web/src/components/landing-page.tsx
|
||||
msgid "Get Started"
|
||||
msgstr ""
|
||||
@@ -1247,6 +1380,14 @@ msgstr ""
|
||||
msgid "Here's what's happening with your library"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Highest rated"
|
||||
msgstr "Highest rated"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Hindi"
|
||||
msgstr "Hindi"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
@@ -1377,6 +1518,14 @@ msgstr ""
|
||||
msgid "Invalid token"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Italian"
|
||||
msgstr "Italian"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Japanese"
|
||||
msgstr "Japanese"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr ""
|
||||
@@ -1394,8 +1543,15 @@ msgstr "Keeping"
|
||||
msgid "Keyboard Shortcuts"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Korean"
|
||||
msgstr "Korean"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/settings/language-section.tsx
|
||||
msgid "Language"
|
||||
msgstr ""
|
||||
@@ -1444,7 +1600,12 @@ msgstr ""
|
||||
msgid "Last run succeeded"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/library.tsx
|
||||
msgid "Library"
|
||||
msgstr "Library"
|
||||
|
||||
@@ -1559,14 +1720,32 @@ msgstr ""
|
||||
#~ msgid "Marked as watching"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Max"
|
||||
msgstr "Max"
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Maximum rating"
|
||||
msgstr "Maximum rating"
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Member since {memberSince}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Min"
|
||||
msgstr "Min"
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Min 8 characters…"
|
||||
msgstr "Min 8 characters…"
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Minimum rating"
|
||||
msgstr "Minimum rating"
|
||||
|
||||
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||
msgid "Monday"
|
||||
msgstr ""
|
||||
@@ -1579,18 +1758,28 @@ msgstr ""
|
||||
msgid "More Settings"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Most popular"
|
||||
msgstr "Most popular"
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-row-content.tsx
|
||||
#: apps/native/src/components/search/search-result-row.tsx
|
||||
#: apps/native/src/components/search/search-result-row.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/hero-banner.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/titles/title-hero.tsx
|
||||
msgid "Movie"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr ""
|
||||
|
||||
@@ -1676,6 +1865,7 @@ msgstr ""
|
||||
msgid "New Season"
|
||||
msgstr "New Season"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
msgid "Newest"
|
||||
@@ -1708,10 +1898,18 @@ msgstr ""
|
||||
msgid "No internet connection"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
msgid "No matching titles"
|
||||
msgstr "No matching titles"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(search)/index.tsx
|
||||
msgid "No results for \"{debouncedQuery}\""
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
msgid "No results for \"{debouncedSearch}\""
|
||||
msgstr "No results for \"{debouncedSearch}\""
|
||||
|
||||
#: apps/web/src/components/command-palette.tsx
|
||||
msgid "No results found."
|
||||
msgstr ""
|
||||
@@ -1721,6 +1919,14 @@ msgstr ""
|
||||
msgid "No titles found for this genre."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "No titles found. Try adjusting your filters."
|
||||
msgstr "No titles found. Try adjusting your filters."
|
||||
|
||||
#: apps/web/src/routes/_app/library.tsx
|
||||
msgid "No titles match your filters"
|
||||
msgstr "No titles match your filters"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "No upcoming episodes or releases in the next 90 days."
|
||||
@@ -1736,6 +1942,14 @@ msgstr ""
|
||||
msgid "Not Found"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Older"
|
||||
msgstr "Older"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Oldest"
|
||||
msgstr "Oldest"
|
||||
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
@@ -1904,6 +2118,20 @@ msgstr ""
|
||||
msgid "Popular TV Shows"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Popularity"
|
||||
msgstr "Popularity"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Portuguese"
|
||||
msgstr "Portuguese"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Pre-1970"
|
||||
msgstr "Pre-1970"
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Pre-restore backup"
|
||||
msgstr ""
|
||||
@@ -1929,6 +2157,12 @@ msgstr ""
|
||||
msgid "Profile picture updated"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Provider"
|
||||
msgstr "Provider"
|
||||
|
||||
#: apps/web/src/components/settings/danger-section.tsx
|
||||
#: apps/web/src/components/settings/danger-section.tsx
|
||||
msgid "Purge all"
|
||||
@@ -2008,6 +2242,10 @@ msgstr "Rated {ratingStars, plural, one {# star} other {# stars}}"
|
||||
msgid "Rated {stars, plural, one {# star} other {# stars}}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/titles/star-rating.tsx
|
||||
@@ -2117,6 +2355,11 @@ msgstr "Registration failed"
|
||||
msgid "Registration opened"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Release Date"
|
||||
msgstr "Release Date"
|
||||
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
@@ -2305,6 +2548,14 @@ msgstr ""
|
||||
msgid "Search for movies, TV shows, or run commands"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Search library"
|
||||
msgstr "Search library"
|
||||
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Search library..."
|
||||
msgstr "Search library..."
|
||||
|
||||
#: apps/web/src/components/command-palette.tsx
|
||||
msgid "Search movies & TV shows…"
|
||||
msgstr ""
|
||||
@@ -2313,6 +2564,10 @@ msgstr ""
|
||||
msgid "Search movies, shows, people..."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
msgid "Search your library..."
|
||||
msgstr "Search your library..."
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
msgid "Search…"
|
||||
@@ -2524,15 +2779,28 @@ msgstr ""
|
||||
msgid "Sonarr List URL"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Sort"
|
||||
msgstr "Sort"
|
||||
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
msgid "Sort filmography"
|
||||
msgstr "Sort filmography"
|
||||
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
msgid "Spanish"
|
||||
msgstr "Spanish"
|
||||
|
||||
#: apps/web/src/components/dashboard/stats-section.tsx
|
||||
msgid "Start exploring"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
msgid "Start tracking movies and shows"
|
||||
msgstr ""
|
||||
|
||||
@@ -2549,6 +2817,11 @@ msgstr ""
|
||||
msgid "Starting import..."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Status"
|
||||
msgstr "Status"
|
||||
|
||||
#: apps/native/src/hooks/use-title-actions.ts
|
||||
msgid "Status updated"
|
||||
msgstr ""
|
||||
@@ -2561,6 +2834,11 @@ msgstr ""
|
||||
msgid "Stream"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Streaming"
|
||||
msgstr "Streaming"
|
||||
|
||||
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||
msgid "Sunday"
|
||||
msgstr ""
|
||||
@@ -2677,11 +2955,21 @@ msgstr ""
|
||||
msgid "Time:"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Title A-Z"
|
||||
msgstr "Title A-Z"
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/web/src/routes/_app/titles.$id.tsx
|
||||
msgid "Title not found"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Title Z-A"
|
||||
msgstr "Title Z-A"
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
#: apps/web/src/components/settings/integration-configs.tsx
|
||||
msgid "Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)"
|
||||
@@ -2692,6 +2980,15 @@ msgstr ""
|
||||
msgid "Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "TMDB Rating"
|
||||
msgstr "TMDB Rating"
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "To"
|
||||
msgstr "To"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
msgid "today"
|
||||
msgstr "today"
|
||||
@@ -2736,6 +3033,10 @@ msgstr ""
|
||||
msgid "Trigger job"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
msgid "Try adjusting your filters"
|
||||
msgstr "Try adjusting your filters"
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/web/src/components/route-error.tsx
|
||||
#: apps/web/src/routes/__root.tsx
|
||||
@@ -2747,9 +3048,12 @@ msgid "Tuesday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-row-content.tsx
|
||||
#: apps/native/src/components/search/search-result-row.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/hero-banner.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/titles/title-hero.tsx
|
||||
msgid "TV"
|
||||
@@ -2763,6 +3067,17 @@ msgstr ""
|
||||
msgid "TV show"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "TV Shows"
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Type"
|
||||
msgstr "Type"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "unknown"
|
||||
msgstr ""
|
||||
@@ -2898,6 +3213,11 @@ msgstr "Use at least 8 characters"
|
||||
msgid "User menu"
|
||||
msgstr "User menu"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "User Rating"
|
||||
msgstr "User Rating"
|
||||
|
||||
#: apps/web/src/components/titles/title-hero.tsx
|
||||
msgid "View on TMDB"
|
||||
msgstr "View on TMDB"
|
||||
@@ -2958,17 +3278,27 @@ msgstr ""
|
||||
msgid "Watched S{sNum} E{eNum}"
|
||||
msgstr "Watched S{sNum} E{eNum}"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/title-card.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Watching"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/native/src/components/titles/status-action-button.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/components/titles/status-button.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Watchlist"
|
||||
msgstr ""
|
||||
|
||||
@@ -3014,6 +3344,21 @@ msgstr ""
|
||||
msgid "Writer"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/explore/discover-section.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Year"
|
||||
msgstr "Year"
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Year from"
|
||||
msgstr "Year from"
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Year to"
|
||||
msgstr "Year to"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr ""
|
||||
@@ -3031,6 +3376,7 @@ msgid "Your code:"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
#: apps/web/src/components/dashboard/stats-section.tsx
|
||||
msgid "Your library is empty"
|
||||
msgstr ""
|
||||
|
||||
+39
-7
@@ -16,7 +16,9 @@ const {
|
||||
userEpisodeWatches,
|
||||
userTitleStatus,
|
||||
userRatings,
|
||||
availabilityOffers,
|
||||
platforms,
|
||||
titleAvailability,
|
||||
userPlatforms,
|
||||
titleRecommendations,
|
||||
integrations,
|
||||
} = schema;
|
||||
@@ -171,25 +173,55 @@ export function insertRating(userId: string, titleId: string, ratingStars: numbe
|
||||
testDb.insert(userRatings).values({ userId, titleId, ratingStars, ratedAt: new Date() }).run();
|
||||
}
|
||||
|
||||
export function insertAvailabilityOffer(
|
||||
export function insertPlatform(
|
||||
overrides: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tmdbProviderId?: number;
|
||||
logoPath?: string;
|
||||
urlTemplate?: string;
|
||||
displayOrder?: number;
|
||||
} = {},
|
||||
) {
|
||||
const id = overrides.id ?? "platform-1";
|
||||
testDb
|
||||
.insert(platforms)
|
||||
.values({
|
||||
id,
|
||||
name: overrides.name ?? "Netflix",
|
||||
tmdbProviderId: overrides.tmdbProviderId ?? 8,
|
||||
logoPath: overrides.logoPath ?? "/logo.png",
|
||||
urlTemplate: overrides.urlTemplate ?? "https://www.netflix.com/search?q={title}",
|
||||
displayOrder: overrides.displayOrder ?? 1,
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function insertTitleAvailability(
|
||||
titleId: string,
|
||||
platformId: string,
|
||||
overrides: {
|
||||
providerId?: number;
|
||||
providerName?: string;
|
||||
offerType?: "flatrate" | "rent" | "buy" | "free" | "ads";
|
||||
region?: string;
|
||||
} = {},
|
||||
) {
|
||||
testDb
|
||||
.insert(availabilityOffers)
|
||||
.insert(titleAvailability)
|
||||
.values({
|
||||
titleId,
|
||||
providerId: overrides.providerId ?? 8,
|
||||
providerName: overrides.providerName ?? "Netflix",
|
||||
platformId,
|
||||
offerType: overrides.offerType ?? "flatrate",
|
||||
region: overrides.region ?? "US",
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertUserPlatform(userId: string, platformId: string) {
|
||||
testDb.insert(userPlatforms).values({ userId, platformId }).run();
|
||||
}
|
||||
|
||||
export function insertIntegration(userId: string, provider: string, token = "test-token") {
|
||||
const type = provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
|
||||
return testDb
|
||||
|
||||
@@ -261,6 +261,29 @@ export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
|
||||
return data as TmdbWatchProviderResponse;
|
||||
}
|
||||
|
||||
export interface TmdbWatchProviderListItem {
|
||||
provider_id: number;
|
||||
provider_name: string;
|
||||
logo_path: string | null;
|
||||
display_priorities: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getWatchProviderList(type: "movie" | "tv", watchRegion?: string) {
|
||||
const query = watchRegion ? ({ watch_region: watchRegion } as Record<string, unknown>) : {};
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET("/3/watch/providers/movie", {
|
||||
params: { query },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: watch/providers/movie");
|
||||
return (data as { results?: TmdbWatchProviderListItem[] }).results ?? [];
|
||||
}
|
||||
const { data, error } = await client.GET("/3/watch/providers/tv", {
|
||||
params: { query },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: watch/providers/tv");
|
||||
return (data as { results?: TmdbWatchProviderListItem[] }).results ?? [];
|
||||
}
|
||||
|
||||
// ─── Recommendations & Similar ──────────────────────────────────────
|
||||
|
||||
export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {
|
||||
|
||||
Reference in New Issue
Block a user