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:
2026-03-24 09:07:14 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d1b0d952bd
commit 4fc6b2f8d1
76 changed files with 8918 additions and 942 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ Cross-package imports:
- **JSX text** → `<Trans>` from `@lingui/react/macro` - **JSX text** → `<Trans>` from `@lingui/react/macro`
- **Strings in hooks/components** → `const { t } = useLingui()` from `@lingui/react/macro`, then `` t`string` `` - **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`)`` - **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. - **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. - **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). - **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).
+1 -1
View File
@@ -37,7 +37,7 @@
"@sofa/i18n": "workspace:*", "@sofa/i18n": "workspace:*",
"@tabler/icons-react-native": "3.40.0", "@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.95.2", "@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": "catalog:",
"@tanstack/react-query-persist-client": "5.95.2", "@tanstack/react-query-persist-client": "5.95.2",
"better-auth": "catalog:", "better-auth": "catalog:",
+7 -2
View File
@@ -63,7 +63,7 @@ export default function DashboardScreen() {
}), }),
); );
const continueWatching = useQuery(orpc.dashboard.continueWatching.queryOptions()); 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 recommendations = useQuery(orpc.dashboard.recommendations.queryOptions());
const isRefreshing = const isRefreshing =
@@ -75,6 +75,7 @@ export default function DashboardScreen() {
const onRefresh = useCallback(() => { const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() }); queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
}, []); }, []);
const hasLibrary = (library.data?.items?.length ?? 0) > 0; const hasLibrary = (library.data?.items?.length ?? 0) > 0;
@@ -188,7 +189,11 @@ export default function DashboardScreen() {
{/* Library */} {/* Library */}
<Animated.View entering={FadeInDown.duration(300).delay(350)}> <Animated.View entering={FadeInDown.duration(300).delay(350)}>
<View className="px-4"> <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> </View>
{library.isPending ? ( {library.isPending ? (
<HorizontalPosterRow items={[]} isLoading /> <HorizontalPosterRow items={[]} isLoading />
+72 -2
View File
@@ -2,8 +2,8 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { IconCalendarEvent } from "@tabler/icons-react-native"; import { IconCalendarEvent } from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { RefreshControl, SectionList, View } from "react-native"; import { Pressable, RefreshControl, SectionList, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated"; import Animated, { FadeInDown } from "react-native-reanimated";
import { useCSSVariable, useResolveClassNames } from "uniwind"; import { useCSSVariable, useResolveClassNames } from "uniwind";
@@ -12,10 +12,39 @@ import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import * as Haptics from "@/utils/haptics";
import { groupByDateBucket } from "@sofa/i18n/date-buckets"; import { groupByDateBucket } from "@sofa/i18n/date-buckets";
const contentContainerStyle = { paddingBottom: 24 }; 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() { export default function UpcomingScreen() {
const { t } = useLingui(); const { t } = useLingui();
const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl"); 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 backgroundColor = useCSSVariable("--color-background") as string;
const mutedColor = useCSSVariable("--color-muted-foreground") 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 } = const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isRefetching } =
useInfiniteQuery( useInfiniteQuery(
orpc.dashboard.upcoming.infiniteOptions({ orpc.dashboard.upcoming.infiniteOptions({
@@ -30,6 +62,8 @@ export default function UpcomingScreen() {
days: 90, days: 90,
limit: 20, limit: 20,
cursor: pageParam, cursor: pageParam,
mediaType: mediaType !== "all" ? mediaType : undefined,
statusFilter: statusFilter !== "all" ? [statusFilter] : undefined,
}), }),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
@@ -73,6 +107,42 @@ export default function UpcomingScreen() {
{t`Upcoming`} {t`Upcoming`}
</Stack.Screen.Title> </Stack.Screen.Title>
<Stack.Screen.BackButton displayMode="minimal" /> <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 ? ( {!isPending && allItems.length === 0 ? (
<Animated.View <Animated.View
entering={FadeInDown.duration(300)} 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>
);
}
+1 -1
View File
@@ -477,7 +477,7 @@ export default function TitleDetailScreen() {
contentContainerStyle={titleAvailabilityContentStyle} contentContainerStyle={titleAvailabilityContentStyle}
> >
{availability.map((offer) => ( {availability.map((offer) => (
<View key={`${offer.providerId}-${offer.offerType}`} className="items-center"> <View key={`${offer.platformId}-${offer.offerType}`} className="items-center">
{offer.logoPath && ( {offer.logoPath && (
<Image <Image
source={{ uri: offer.logoPath }} 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.Label>{t`Home`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" /> <NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger> </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 name="(explore)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>{t`Explore`}</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>{t`Explore`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" /> <NativeTabs.Trigger.Icon sf="safari" md="explore" />
+4
View File
@@ -9,6 +9,7 @@ import { registerJobScheduleProvider } from "@sofa/core/system-health";
import { closeDatabase, isDatabaseAccessBlocked } from "@sofa/db/client"; import { closeDatabase, isDatabaseAccessBlocked } from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate"; import { runMigrations } from "@sofa/db/migrate";
import { recoverStaleImportJobs } from "@sofa/db/queries/imports"; import { recoverStaleImportJobs } from "@sofa/db/queries/imports";
import { seedPlatforms } from "@sofa/db/seed-platforms";
import { createLogger } from "@sofa/logger"; import { createLogger } from "@sofa/logger";
import { getJobSchedules, startJobs, stopJobs } from "./cron"; import { getJobSchedules, startJobs, stopJobs } from "./cron";
@@ -36,6 +37,9 @@ await ensureBackupDir();
// Run database migrations // Run database migrations
runMigrations(); runMigrations();
// Seed streaming platforms (idempotent upsert)
seedPlatforms();
// Recover import jobs left in running/pending state from a previous crash // Recover import jobs left in running/pending state from a previous crash
const recoveredJobs = recoverStaleImportJobs(); const recoveredJobs = recoverStaleImportJobs();
if (recoveredJobs > 0) { if (recoveredJobs > 0) {
@@ -3,6 +3,7 @@ import path from "node:path";
import { auth } from "@sofa/auth/server"; import { auth } from "@sofa/auth/server";
import { AVATAR_DIR } from "@sofa/config"; import { AVATAR_DIR } from "@sofa/config";
import { getUserPlatformIdList, updateUserPlatforms } from "@sofa/core/platforms";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -64,3 +65,13 @@ export const removeAvatar = os.account.removeAvatar.use(authed).handler(async ({
headers: context.headers, 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);
});
+2 -28
View File
@@ -1,13 +1,11 @@
import { import {
getContinueWatchingFeed, getContinueWatchingFeed,
getLibraryFeed,
getRecommendationsFeed, getRecommendationsFeed,
getUpcomingFeed, getUpcomingFeed,
getUserStats, getUserStats,
getWatchCount, getWatchCount,
getWatchHistory, getWatchHistory,
} from "@sofa/core/discovery"; } from "@sofa/core/discovery";
import { getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
import { tmdbImageUrl } from "@sofa/tmdb/image"; import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context"; import { os } from "../context";
@@ -43,32 +41,6 @@ export const continueWatching = os.dashboard.continueWatching.use(authed).handle
return { items }; 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 }) => { export const recommendations = os.dashboard.recommendations.use(authed).handler(({ context }) => {
const feed = getRecommendationsFeed(context.user.id); const feed = getRecommendationsFeed(context.user.id);
const items = feed const items = feed
@@ -93,6 +65,8 @@ export const upcoming = os.dashboard.upcoming.use(authed).handler(({ input, cont
days: input.days, days: input.days,
limit: input.limit, limit: input.limit,
cursor: input.cursor, cursor: input.cursor,
mediaType: input.mediaType,
statusFilter: input.statusFilter,
}); });
return { return {
items: result.items.map((item) => ({ items: result.items.map((item) => ({
+22 -9
View File
@@ -1,6 +1,7 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors"; import { AppErrorCode } from "@sofa/api/errors";
import { WATCH_REGION } from "@sofa/config";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking"; import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
import { discover as discoverTmdb } from "@sofa/tmdb/client"; 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( const params: Record<string, string> = {
input.type, sort_by: input.sortBy ?? "popularity.desc",
{ "vote_count.gte": "50",
sort_by: "popularity.desc", };
"vote_count.gte": "50", if (input.genreId) params.with_genres = String(input.genreId);
with_genres: String(input.genreId), if (input.yearMin) {
}, const key = input.type === "movie" ? "primary_release_date.gte" : "first_air_date.gte";
input.page, 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] & { type DiscoverResult = NonNullable<typeof results.results>[number] & {
title?: string; title?: string;
@@ -2,6 +2,7 @@ import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors"; import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { listPlatforms } from "@sofa/core/platforms";
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking"; import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client"; import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config"; import { isTmdbConfigured } from "@sofa/tmdb/config";
@@ -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,
})),
};
});
+2 -2
View File
@@ -20,8 +20,8 @@ import { authed } from "../middleware";
const log = createLogger("titles"); const log = createLogger("titles");
export const detail = os.titles.detail.use(authed).handler(async ({ input }) => { export const detail = os.titles.detail.use(authed).handler(async ({ input, context }) => {
const result = await getOrFetchTitle(input.id); const result = await getOrFetchTitle(input.id, context.user.id);
if (!result) if (!result)
throw new ORPCError("NOT_FOUND", { throw new ORPCError("NOT_FOUND", {
message: "Title not found", message: "Title not found",
+12 -1
View File
@@ -7,7 +7,9 @@ import * as episodes from "./procedures/episodes";
import * as explore from "./procedures/explore"; import * as explore from "./procedures/explore";
import * as imports from "./procedures/imports"; import * as imports from "./procedures/imports";
import * as integrations from "./procedures/integrations"; import * as integrations from "./procedures/integrations";
import * as library from "./procedures/library";
import * as people from "./procedures/people"; import * as people from "./procedures/people";
import * as platformProcs from "./procedures/platforms";
import { search } from "./procedures/search"; import { search } from "./procedures/search";
import * as seasons from "./procedures/seasons"; import * as seasons from "./procedures/seasons";
import * as status from "./procedures/status"; import * as status from "./procedures/status";
@@ -15,6 +17,10 @@ import * as system from "./procedures/system";
import * as titles from "./procedures/titles"; import * as titles from "./procedures/titles";
export const implementedRouter = { export const implementedRouter = {
library: {
list: library.list,
genres: library.genres,
},
titles: { titles: {
detail: titles.detail, detail: titles.detail,
updateStatus: titles.updateStatus, updateStatus: titles.updateStatus,
@@ -41,7 +47,6 @@ export const implementedRouter = {
stats: dashboard.stats, stats: dashboard.stats,
continueWatching: dashboard.continueWatching, continueWatching: dashboard.continueWatching,
upcoming: dashboard.upcoming, upcoming: dashboard.upcoming,
library: dashboard.library,
recommendations: dashboard.recommendations, recommendations: dashboard.recommendations,
watchHistory: dashboard.watchHistory, watchHistory: dashboard.watchHistory,
}, },
@@ -49,6 +54,7 @@ export const implementedRouter = {
trending: explore.trending, trending: explore.trending,
popular: explore.popular, popular: explore.popular,
genres: explore.genres, genres: explore.genres,
watchProviders: explore.watchProviders,
}, },
search, search,
discover, discover,
@@ -87,6 +93,11 @@ export const implementedRouter = {
updateName: account.updateName, updateName: account.updateName,
uploadAvatar: account.uploadAvatar, uploadAvatar: account.uploadAvatar,
removeAvatar: account.removeAvatar, removeAvatar: account.removeAvatar,
platforms: account.platforms,
updatePlatforms: account.updatePlatformsHandler,
},
platforms: {
list: platformProcs.list,
}, },
imports: { imports: {
parseFile: imports.parseFile, parseFile: imports.parseFile,
+2
View File
@@ -28,9 +28,11 @@
"@sofa/api": "workspace:*", "@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*", "@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0", "@tabler/icons-react": "3.40.0",
"@tanstack/react-form": "catalog:",
"@tanstack/react-hotkeys": "0.5.1", "@tanstack/react-hotkeys": "0.5.1",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.3", "@tanstack/react-router": "1.168.3",
"@tanstack/zod-adapter": "1.166.9",
"better-auth": "catalog:", "better-auth": "catalog:",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
+115 -91
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { authClient, signIn, signUp } from "@/lib/auth/client"; import { authClient, signIn, signUp } from "@/lib/auth/client";
import { useAppForm } from "@/lib/form";
export interface AuthConfig { export interface AuthConfig {
oidcEnabled: boolean; oidcEnabled: boolean;
@@ -39,11 +40,7 @@ export function AuthForm({
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false);
const isRegister = mode === "register"; const isRegister = mode === "register";
@@ -51,32 +48,34 @@ export function AuthForm({
const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false); const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false);
const oidcProviderName = authConfig?.oidcProviderName || "SSO"; const oidcProviderName = authConfig?.oidcProviderName || "SSO";
async function handleSubmit(e: React.FormEvent) { const form = useAppForm({
e.preventDefault(); defaultValues: { name: "", email: "", password: "" },
setError(""); onSubmit: async ({ value }) => {
setLoading(true); setError("");
try {
try { if (isRegister) {
if (isRegister) { const result = await signUp.email({
const result = await signUp.email({ name, email, password }); name: value.name,
if (result.error) { email: value.email,
setError(result.error.message ?? t`Registration failed`); password: value.password,
return; });
} if (result.error) {
} else { setError(result.error.message ?? t`Registration failed`);
const result = await signIn.email({ email, password }); return;
if (result.error) { }
setError(result.error.message ?? t`Login failed`); } else {
return; 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: isRegister ? "/onboarding" : "/dashboard" });
} catch {
setError(t`Something went wrong`);
} }
void navigate({ to: "/dashboard" }); },
} catch { });
setError(t`Something went wrong`);
} finally {
setLoading(false);
}
}
async function handleOidcLogin() { async function handleOidcLogin() {
setError(""); setError("");
@@ -160,7 +159,10 @@ export function AuthForm({
{showPasswordForm && ( {showPasswordForm && (
<motion.form <motion.form
onSubmit={handleSubmit} onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-4" className="space-y-4"
initial="hidden" initial="hidden"
animate="visible" animate="visible"
@@ -170,72 +172,94 @@ export function AuthForm({
}} }}
> >
{isRegister && ( {isRegister && (
<motion.div variants={fieldVariants} className="space-y-1.5"> <form.Field name="name">
<Label htmlFor="name" className="text-muted-foreground tracking-wider uppercase"> {(field) => (
<Trans>Name</Trans> <motion.div variants={fieldVariants} className="space-y-1.5">
</Label> <Label
<Input htmlFor="name"
id="name" className="text-muted-foreground tracking-wider uppercase"
type="text" >
required <Trans>Name</Trans>
autoComplete="name" </Label>
value={name} <Input
onChange={(e) => setName(e.target.value)} id="name"
className={authInputClass} type="text"
placeholder={t`Your name…`} required
/> autoComplete="name"
</motion.div> value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
className={authInputClass}
placeholder={t`Your name…`}
/>
</motion.div>
)}
</form.Field>
)} )}
<motion.div variants={fieldVariants} className="space-y-1.5"> <form.Field name="email">
<Label htmlFor="email" className="text-muted-foreground tracking-wider uppercase"> {(field) => (
<Trans>Email</Trans> <motion.div variants={fieldVariants} className="space-y-1.5">
</Label> <Label htmlFor="email" className="text-muted-foreground tracking-wider uppercase">
<Input <Trans>Email</Trans>
id="email" </Label>
type="email" <Input
required id="email"
autoComplete="email" type="email"
spellCheck={false} required
value={email} autoComplete="email"
onChange={(e) => setEmail(e.target.value)} spellCheck={false}
className={authInputClass} value={field.state.value}
placeholder="wwhite@graymatter.biz" onChange={(e) => field.handleChange(e.target.value)}
/> className={authInputClass}
</motion.div> placeholder="wwhite@graymatter.biz"
/>
</motion.div>
)}
</form.Field>
<motion.div variants={fieldVariants} className="space-y-1.5"> <form.Field name="password">
<Label htmlFor="password" className="text-muted-foreground tracking-wider uppercase"> {(field) => (
<Trans>Password</Trans> <motion.div variants={fieldVariants} className="space-y-1.5">
</Label> <Label
<Input htmlFor="password"
id="password" className="text-muted-foreground tracking-wider uppercase"
type="password" >
required <Trans>Password</Trans>
minLength={8} </Label>
autoComplete={mode === "login" ? "current-password" : "new-password"} <Input
value={password} id="password"
onChange={(e) => setPassword(e.target.value)} type="password"
className={authInputClass} required
placeholder={t`Min 8 characters…`} minLength={8}
/> autoComplete={mode === "login" ? "current-password" : "new-password"}
</motion.div> value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
className={authInputClass}
placeholder={t`Min 8 characters…`}
/>
</motion.div>
)}
</form.Field>
<motion.div variants={fieldVariants}> <form.Subscribe selector={(state) => state.isSubmitting}>
<Button {(isSubmitting) => (
type="submit" <motion.div variants={fieldVariants}>
disabled={loading} <Button
className="hover:shadow-primary/20 h-11 w-full rounded-lg text-sm hover:shadow-lg" type="submit"
> disabled={isSubmitting}
{loading ? ( className="hover:shadow-primary/20 h-11 w-full rounded-lg text-sm hover:shadow-lg"
<Trans>Loading</Trans> >
) : isRegister ? ( {isSubmitting ? (
<Trans>Create account</Trans> <Trans>Loading</Trans>
) : ( ) : isRegister ? (
<Trans>Sign in</Trans> <Trans>Create account</Trans>
)} ) : (
</Button> <Trans>Sign in</Trans>
</motion.div> )}
</Button>
</motion.div>
)}
</form.Subscribe>
</motion.form> </motion.form>
)} )}
@@ -1,42 +1,31 @@
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { IconBooks } from "@tabler/icons-react"; 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 { orpc } from "@/lib/orpc/client";
import { FeedSection } from "./feed-section"; import { FeedSection } from "./feed-section";
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid"; import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
export function LibrarySection() { export function LibrarySection() {
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery( const { data, isPending } = useQuery(
orpc.dashboard.library.infiniteOptions({ orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }),
input: (pageParam: number) => ({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
); );
const { t } = useLingui(); const { t } = useLingui();
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
});
if (isPending) return <TitleGridSectionSkeleton />; if (isPending) return <TitleGridSectionSkeleton />;
const items = data?.pages.flatMap((p) => p.items) ?? []; const items = data?.items ?? [];
if (items.length === 0) return null; if (items.length === 0) return null;
return ( 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} /> <TitleGrid items={items} />
<div ref={sentinelRef} />
{isFetchingNextPage && <TitleGridSectionSkeleton />}
</FeedSection> </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>
);
}
+3
View File
@@ -1,5 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { import {
IconBooks,
IconCalendarEvent, IconCalendarEvent,
IconCompass, IconCompass,
IconHome, IconHome,
@@ -119,6 +120,7 @@ export function NavBar({
const navLinks = [ const navLinks = [
{ href: "/dashboard", label: t`Home` }, { href: "/dashboard", label: t`Home` },
{ href: "/library", label: t`Library` },
{ href: "/explore", label: t`Explore` }, { href: "/explore", label: t`Explore` },
{ href: "/upcoming", label: t`Upcoming` }, { href: "/upcoming", label: t`Upcoming` },
] as const; ] as const;
@@ -281,6 +283,7 @@ export function MobileTabBar() {
const mobileTabs = [ const mobileTabs = [
{ href: "/dashboard", label: t`Home`, icon: IconHome }, { href: "/dashboard", label: t`Home`, icon: IconHome },
{ href: "/library", label: t`Library`, icon: IconBooks },
{ href: "/explore", label: t`Explore`, icon: IconCompass }, { href: "/explore", label: t`Explore`, icon: IconCompass },
{ href: "/upcoming", label: t`Upcoming`, icon: IconCalendarEvent }, { href: "/upcoming", label: t`Upcoming`, icon: IconCalendarEvent },
{ href: "/settings", label: t`Settings`, icon: IconSettings }, { 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 { useMutation } from "@tanstack/react-query";
import { useNavigate, useRouter } from "@tanstack/react-router"; import { useNavigate, useRouter } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { authClient, signOut } from "@/lib/auth/client"; import { authClient, signOut } from "@/lib/auth/client";
import { getErrorMessage } from "@/lib/error-messages"; import { getErrorMessage } from "@/lib/error-messages";
import { useAppForm } from "@/lib/form";
import { client, orpc } from "@/lib/orpc/client"; import { client, orpc } from "@/lib/orpc/client";
import type { NormalizedImport } from "@sofa/api/schemas"; import type { NormalizedImport } from "@sofa/api/schemas";
import { formatDate } from "@sofa/i18n/format"; import { formatDate } from "@sofa/i18n/format";
@@ -788,60 +790,57 @@ function ImportOptionCheckbox({
function ChangePasswordDialog() { function ChangePasswordDialog() {
const { t } = useLingui(); const { t } = useLingui();
const [open, setOpen] = useState(false); 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 [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
function resetForm() { const changePasswordSchema = useMemo(
setCurrentPassword(""); () =>
setNewPassword(""); z
setConfirmPassword(""); .object({
setRevokeOtherSessions(false); currentPassword: z.string().min(1, t`Current password is required`),
setError(""); 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("");
try {
const result = await authClient.changePassword({
currentPassword: value.currentPassword,
newPassword: value.newPassword,
revokeOtherSessions: value.revokeOtherSessions,
});
if (result.error) {
setError(t`Failed to change password`);
return;
}
toast.success(t`Password updated`);
handleOpenChange(false);
} catch {
setError(t`Something went wrong`);
}
},
});
function handleOpenChange(nextOpen: boolean) { function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen); setOpen(nextOpen);
if (!nextOpen) resetForm(); if (!nextOpen) {
} form.reset();
setError("");
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,
});
if (result.error) {
setError(t`Failed to change password`);
return;
}
toast.success(t`Password updated`);
handleOpenChange(false);
} catch {
setError(t`Something went wrong`);
} finally {
setIsSubmitting(false);
} }
} }
@@ -861,7 +860,13 @@ function ChangePasswordDialog() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-3"> <form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="grid gap-3"
>
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<IconAlertTriangle /> <IconAlertTriangle />
@@ -869,69 +874,114 @@ function ChangePasswordDialog() {
</Alert> </Alert>
)} )}
<div className="grid gap-1.5"> <form.Field name="currentPassword">
<Label htmlFor="current-password"> {(field) => (
<Trans>Current password</Trans> <div className="grid gap-1.5">
</Label> <Label htmlFor="current-password">
<Input <Trans>Current password</Trans>
id="current-password" </Label>
type="password" <Input
autoComplete="current-password" id="current-password"
value={currentPassword} type="password"
onChange={(e) => setCurrentPassword(e.target.value)} autoComplete="current-password"
disabled={isSubmitting} value={field.state.value}
/> onChange={(e) => field.handleChange(e.target.value)}
</div> 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>
<div className="grid gap-1.5"> <form.Field name="newPassword">
<Label htmlFor="new-password"> {(field) => (
<Trans>New password</Trans> <div className="grid gap-1.5">
</Label> <Label htmlFor="new-password">
<Input <Trans>New password</Trans>
id="new-password" </Label>
type="password" <Input
autoComplete="new-password" id="new-password"
value={newPassword} type="password"
onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password"
disabled={isSubmitting} value={field.state.value}
/> onChange={(e) => field.handleChange(e.target.value)}
</div> 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>
<div className="grid gap-1.5"> <form.Field name="confirmPassword">
<Label htmlFor="confirm-password"> {(field) => (
<Trans>Confirm new password</Trans> <div className="grid gap-1.5">
</Label> <Label htmlFor="confirm-password">
<Input <Trans>Confirm new password</Trans>
id="confirm-password" </Label>
type="password" <Input
autoComplete="new-password" id="confirm-password"
value={confirmPassword} type="password"
onChange={(e) => setConfirmPassword(e.target.value)} autoComplete="new-password"
disabled={isSubmitting} value={field.state.value}
/> onChange={(e) => field.handleChange(e.target.value)}
</div> 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>
<div className="flex items-center gap-2 pt-1"> <form.Field name="revokeOtherSessions">
<Checkbox {(field) => (
id="revoke-sessions" <div className="flex items-center gap-2 pt-1">
checked={revokeOtherSessions} <Checkbox
onCheckedChange={(checked) => setRevokeOtherSessions(checked === true)} id="revoke-sessions"
disabled={isSubmitting} checked={field.state.value}
/> onCheckedChange={field.handleChange}
<Label htmlFor="revoke-sessions" className="cursor-pointer"> />
<Trans>Sign out of other sessions</Trans> <Label htmlFor="revoke-sessions" className="cursor-pointer">
</Label> <Trans>Sign out of other sessions</Trans>
</div> </Label>
</div>
)}
</form.Field>
<DialogFooter className="pt-2"> <form.Subscribe
<DialogClose render={<Button variant="outline" />}> selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
<Trans>Cancel</Trans> >
</DialogClose> {({ canSubmit, isSubmitting }) => (
<Button type="submit" disabled={isSubmitting}> <DialogFooter className="pt-2">
{isSubmitting && <Spinner className="size-3.5" />} <DialogClose render={<Button variant="outline" />}>
<Trans>Update password</Trans> <Trans>Cancel</Trans>
</Button> </DialogClose>
</DialogFooter> <Button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting && <Spinner className="size-3.5" />}
<Trans>Update password</Trans>
</Button>
</DialogFooter>
)}
</form.Subscribe>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </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) => {offers.map((offer) =>
offer.watchUrl ? ( offer.watchUrl ? (
<a <a
key={offer.providerId} key={offer.platformId}
href={offer.watchUrl} href={offer.watchUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -103,7 +103,7 @@ function OverflowBadge({ offers }: { offers: AvailabilityOffer[] }) {
<span className="text-popover-foreground truncate text-xs">{offer.providerName}</span> <span className="text-popover-foreground truncate text-xs">{offer.providerName}</span>
</a> </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} /> <OverflowProviderIcon offer={offer} />
<span className="text-popover-foreground truncate text-xs">{offer.providerName}</span> <span className="text-popover-foreground truncate text-xs">{offer.providerName}</span>
</div> </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[] }) { export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) {
const { t } = useLingui(); const { t } = useLingui();
const offerLabels: Record<string, string> = { const offerLabels: Record<string, string> = {
@@ -123,44 +165,44 @@ export function TitleAvailability({ availability }: { availability: Availability
free: t`Free`, free: t`Free`,
ads: t`With Ads`, ads: t`With Ads`,
}; };
const availByType: Record<string, AvailabilityOffer[]> = {};
for (const offer of availability) { if (availability.length === 0) return null;
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
availByType[offer.offerType].push(offer); 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 ( return (
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase"> <h2 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
<Trans>Where to Watch</Trans> <Trans>Where to Watch</Trans>
</h2> </h2>
<div className="flex flex-wrap gap-4"> <OffersByType offers={availability} offerLabels={offerLabels} />
{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>
</div> </div>
); );
} }
+4
View File
@@ -0,0 +1,4 @@
import { createFormHookContexts } from "@tanstack/react-form";
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts();
+143
View File
@@ -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 };
+20
View File
@@ -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 };
+42
View File
@@ -17,6 +17,8 @@ import { Route as AuthRegisterRouteImport } from './routes/_auth/register'
import { Route as AuthLoginRouteImport } from './routes/_auth/login' import { Route as AuthLoginRouteImport } from './routes/_auth/login'
import { Route as AppUpcomingRouteImport } from './routes/_app/upcoming' import { Route as AppUpcomingRouteImport } from './routes/_app/upcoming'
import { Route as AppSettingsRouteImport } from './routes/_app/settings' 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 AppExploreRouteImport } from './routes/_app/explore'
import { Route as AppDashboardRouteImport } from './routes/_app/dashboard' import { Route as AppDashboardRouteImport } from './routes/_app/dashboard'
import { Route as AppTitlesIdRouteImport } from './routes/_app/titles.$id' import { Route as AppTitlesIdRouteImport } from './routes/_app/titles.$id'
@@ -60,6 +62,16 @@ const AppSettingsRoute = AppSettingsRouteImport.update({
path: '/settings', path: '/settings',
getParentRoute: () => AppRoute, getParentRoute: () => AppRoute,
} as any) } 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({ const AppExploreRoute = AppExploreRouteImport.update({
id: '/explore', id: '/explore',
path: '/explore', path: '/explore',
@@ -86,6 +98,8 @@ export interface FileRoutesByFullPath {
'/setup': typeof SetupRoute '/setup': typeof SetupRoute
'/dashboard': typeof AppDashboardRoute '/dashboard': typeof AppDashboardRoute
'/explore': typeof AppExploreRoute '/explore': typeof AppExploreRoute
'/library': typeof AppLibraryRoute
'/onboarding': typeof AppOnboardingRoute
'/settings': typeof AppSettingsRoute '/settings': typeof AppSettingsRoute
'/upcoming': typeof AppUpcomingRoute '/upcoming': typeof AppUpcomingRoute
'/login': typeof AuthLoginRoute '/login': typeof AuthLoginRoute
@@ -98,6 +112,8 @@ export interface FileRoutesByTo {
'/setup': typeof SetupRoute '/setup': typeof SetupRoute
'/dashboard': typeof AppDashboardRoute '/dashboard': typeof AppDashboardRoute
'/explore': typeof AppExploreRoute '/explore': typeof AppExploreRoute
'/library': typeof AppLibraryRoute
'/onboarding': typeof AppOnboardingRoute
'/settings': typeof AppSettingsRoute '/settings': typeof AppSettingsRoute
'/upcoming': typeof AppUpcomingRoute '/upcoming': typeof AppUpcomingRoute
'/login': typeof AuthLoginRoute '/login': typeof AuthLoginRoute
@@ -113,6 +129,8 @@ export interface FileRoutesById {
'/setup': typeof SetupRoute '/setup': typeof SetupRoute
'/_app/dashboard': typeof AppDashboardRoute '/_app/dashboard': typeof AppDashboardRoute
'/_app/explore': typeof AppExploreRoute '/_app/explore': typeof AppExploreRoute
'/_app/library': typeof AppLibraryRoute
'/_app/onboarding': typeof AppOnboardingRoute
'/_app/settings': typeof AppSettingsRoute '/_app/settings': typeof AppSettingsRoute
'/_app/upcoming': typeof AppUpcomingRoute '/_app/upcoming': typeof AppUpcomingRoute
'/_auth/login': typeof AuthLoginRoute '/_auth/login': typeof AuthLoginRoute
@@ -127,6 +145,8 @@ export interface FileRouteTypes {
| '/setup' | '/setup'
| '/dashboard' | '/dashboard'
| '/explore' | '/explore'
| '/library'
| '/onboarding'
| '/settings' | '/settings'
| '/upcoming' | '/upcoming'
| '/login' | '/login'
@@ -139,6 +159,8 @@ export interface FileRouteTypes {
| '/setup' | '/setup'
| '/dashboard' | '/dashboard'
| '/explore' | '/explore'
| '/library'
| '/onboarding'
| '/settings' | '/settings'
| '/upcoming' | '/upcoming'
| '/login' | '/login'
@@ -153,6 +175,8 @@ export interface FileRouteTypes {
| '/setup' | '/setup'
| '/_app/dashboard' | '/_app/dashboard'
| '/_app/explore' | '/_app/explore'
| '/_app/library'
| '/_app/onboarding'
| '/_app/settings' | '/_app/settings'
| '/_app/upcoming' | '/_app/upcoming'
| '/_auth/login' | '/_auth/login'
@@ -226,6 +250,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSettingsRouteImport preLoaderRoute: typeof AppSettingsRouteImport
parentRoute: typeof AppRoute 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': { '/_app/explore': {
id: '/_app/explore' id: '/_app/explore'
path: '/explore' path: '/explore'
@@ -260,6 +298,8 @@ declare module '@tanstack/react-router' {
interface AppRouteChildren { interface AppRouteChildren {
AppDashboardRoute: typeof AppDashboardRoute AppDashboardRoute: typeof AppDashboardRoute
AppExploreRoute: typeof AppExploreRoute AppExploreRoute: typeof AppExploreRoute
AppLibraryRoute: typeof AppLibraryRoute
AppOnboardingRoute: typeof AppOnboardingRoute
AppSettingsRoute: typeof AppSettingsRoute AppSettingsRoute: typeof AppSettingsRoute
AppUpcomingRoute: typeof AppUpcomingRoute AppUpcomingRoute: typeof AppUpcomingRoute
AppPeopleIdRoute: typeof AppPeopleIdRoute AppPeopleIdRoute: typeof AppPeopleIdRoute
@@ -269,6 +309,8 @@ interface AppRouteChildren {
const AppRouteChildren: AppRouteChildren = { const AppRouteChildren: AppRouteChildren = {
AppDashboardRoute: AppDashboardRoute, AppDashboardRoute: AppDashboardRoute,
AppExploreRoute: AppExploreRoute, AppExploreRoute: AppExploreRoute,
AppLibraryRoute: AppLibraryRoute,
AppOnboardingRoute: AppOnboardingRoute,
AppSettingsRoute: AppSettingsRoute, AppSettingsRoute: AppSettingsRoute,
AppUpcomingRoute: AppUpcomingRoute, AppUpcomingRoute: AppUpcomingRoute,
AppPeopleIdRoute: AppPeopleIdRoute, AppPeopleIdRoute: AppPeopleIdRoute,
+2 -8
View File
@@ -23,14 +23,8 @@ export const Route = createFileRoute("/_app/dashboard")({
context.queryClient.ensureQueryData( context.queryClient.ensureQueryData(
orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }), orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
), ),
context.queryClient.ensureInfiniteQueryData( context.queryClient.ensureQueryData(
orpc.dashboard.library.infiniteOptions({ orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }),
input: (pageParam: number) => ({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
), ),
]); ]);
}, },
+3
View File
@@ -4,6 +4,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useMemo } from "react"; import { useMemo } from "react";
import { DiscoverSection } from "@/components/explore/discover-section";
import { FilterableTitleRow } from "@/components/explore/filterable-title-row"; import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
import { HeroBanner } from "@/components/explore/hero-banner"; import { HeroBanner } from "@/components/explore/hero-banner";
import { TitleRow } from "@/components/explore/title-row"; import { TitleRow } from "@/components/explore/title-row";
@@ -170,6 +171,8 @@ function ExplorePage() {
userStatuses={userStatuses} userStatuses={userStatuses}
episodeProgress={episodeProgress} episodeProgress={episodeProgress}
/> />
<DiscoverSection />
</div> </div>
); );
} }
+242
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+4
View File
@@ -19,6 +19,7 @@ import { IntegrationsSection } from "@/components/settings/integrations-section"
import { LanguageSection } from "@/components/settings/language-section"; import { LanguageSection } from "@/components/settings/language-section";
import { RegistrationSection } from "@/components/settings/registration-section"; import { RegistrationSection } from "@/components/settings/registration-section";
import { SettingsShell } from "@/components/settings/settings-shell"; import { SettingsShell } from "@/components/settings/settings-shell";
import { StreamingServicesSection } from "@/components/settings/streaming-services-section";
import { SystemHealthCards } from "@/components/settings/system-health-section"; import { SystemHealthCards } from "@/components/settings/system-health-section";
import { UpdateCheckSection } from "@/components/settings/update-check-section"; import { UpdateCheckSection } from "@/components/settings/update-check-section";
import { TmdbLogo } from "@/components/tmdb-logo"; import { TmdbLogo } from "@/components/tmdb-logo";
@@ -34,6 +35,8 @@ export const Route = createFileRoute("/_app/settings")({
const promises: Promise<unknown>[] = [ const promises: Promise<unknown>[] = [
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()), context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
context.queryClient.ensureQueryData(orpc.system.status.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"; const isAdmin = context.session.user.role === "admin";
if (isAdmin) { if (isAdmin) {
@@ -140,6 +143,7 @@ function SettingsPage() {
</h2> </h2>
</div> </div>
<LanguageSection /> <LanguageSection />
<StreamingServicesSection />
</div> </div>
<IntegrationsSection /> <IntegrationsSection />
+74 -2
View File
@@ -1,16 +1,25 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { IconCalendarEvent } from "@tabler/icons-react"; import { IconCalendarEvent } from "@tabler/icons-react";
import { useInfiniteQuery } from "@tanstack/react-query"; 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 { UpcomingRow } from "@/components/dashboard/upcoming-item";
import { RouteError } from "@/components/route-error"; import { RouteError } from "@/components/route-error";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
import { groupByDateBucket } from "@sofa/i18n/date-buckets"; 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")({ export const Route = createFileRoute("/_app/upcoming")({
validateSearch: zodValidator(upcomingSearchSchema),
staleTime: 30_000, staleTime: 30_000,
loader: async ({ context }) => { loader: async ({ context }) => {
await context.queryClient.ensureInfiniteQueryData( await context.queryClient.ensureInfiniteQueryData(
@@ -53,12 +62,22 @@ function UpcomingSkeleton() {
} }
function UpcomingPage() { 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( const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.dashboard.upcoming.infiniteOptions({ orpc.dashboard.upcoming.infiniteOptions({
input: (pageParam: string | undefined) => ({ input: (pageParam: string | undefined) => ({
days: 90, days: 90,
limit: 20, limit: 20,
cursor: pageParam, cursor: pageParam,
mediaType: typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
statusFilter:
statusFilter !== "all" ? [statusFilter as "watching" | "watchlist"] : undefined,
}), }),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
@@ -72,14 +91,66 @@ function UpcomingPage() {
isFetchingNextPage, 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 />; if (isPending) return <UpcomingSkeleton />;
const allItems = data?.pages.flatMap((p) => p.items) ?? []; const allItems = data?.pages.flatMap((p) => p.items) ?? [];
if (allItems.length === 0) { if (allItems.length === 0) {
return ( return (
<div className="space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
<UpcomingHeader /> <UpcomingHeader />
{filterToggles}
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center py-20 text-center">
<IconCalendarEvent className="text-muted-foreground/40 size-12" /> <IconCalendarEvent className="text-muted-foreground/40 size-12" />
<p className="text-muted-foreground mt-4 text-sm"> <p className="text-muted-foreground mt-4 text-sm">
@@ -95,6 +166,7 @@ function UpcomingPage() {
return ( return (
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
<UpcomingHeader /> <UpcomingHeader />
{filterToggles}
{buckets.map((bucket) => ( {buckets.map((bucket) => (
<section key={bucket.key}> <section key={bucket.key}>
<h2 className="font-display text-muted-foreground mb-2 text-sm font-medium tracking-wider uppercase"> <h2 className="font-display text-muted-foreground mb-2 text-sm font-medium tracking-wider uppercase">
+78 -55
View File
@@ -27,7 +27,7 @@
}, },
"apps/native": { "apps/native": {
"name": "@sofa/native", "name": "@sofa/native",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@better-auth/expo": "catalog:", "@better-auth/expo": "catalog:",
"@expo/metro-runtime": "55.0.6", "@expo/metro-runtime": "55.0.6",
@@ -51,7 +51,7 @@
"@sofa/i18n": "workspace:*", "@sofa/i18n": "workspace:*",
"@tabler/icons-react-native": "3.40.0", "@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.95.2", "@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": "catalog:",
"@tanstack/react-query-persist-client": "5.95.2", "@tanstack/react-query-persist-client": "5.95.2",
"better-auth": "catalog:", "better-auth": "catalog:",
@@ -111,7 +111,7 @@
}, },
"apps/public-api": { "apps/public-api": {
"name": "@sofa/public-api", "name": "@sofa/public-api",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@hono/zod-validator": "0.7.6", "@hono/zod-validator": "0.7.6",
"@vercel/firewall": "1.1.2", "@vercel/firewall": "1.1.2",
@@ -126,7 +126,7 @@
}, },
"apps/server": { "apps/server": {
"name": "@sofa/server", "name": "@sofa/server",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@orpc/contract": "catalog:", "@orpc/contract": "catalog:",
"@orpc/json-schema": "catalog:", "@orpc/json-schema": "catalog:",
@@ -152,7 +152,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@sofa/web", "name": "@sofa/web",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@base-ui/react": "1.3.0", "@base-ui/react": "1.3.0",
"@fontsource-variable/dm-sans": "5.2.8", "@fontsource-variable/dm-sans": "5.2.8",
@@ -168,9 +168,11 @@
"@sofa/api": "workspace:*", "@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*", "@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0", "@tabler/icons-react": "3.40.0",
"@tanstack/react-form": "catalog:",
"@tanstack/react-hotkeys": "0.5.1", "@tanstack/react-hotkeys": "0.5.1",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.3", "@tanstack/react-router": "1.168.3",
"@tanstack/zod-adapter": "1.166.9",
"better-auth": "catalog:", "better-auth": "catalog:",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -212,7 +214,7 @@
}, },
"packages/api": { "packages/api": {
"name": "@sofa/api", "name": "@sofa/api",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@orpc/contract": "catalog:", "@orpc/contract": "catalog:",
"zod": "catalog:", "zod": "catalog:",
@@ -224,7 +226,7 @@
}, },
"packages/auth": { "packages/auth": {
"name": "@sofa/auth", "name": "@sofa/auth",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@better-auth/drizzle-adapter": "1.5.6", "@better-auth/drizzle-adapter": "1.5.6",
"@better-auth/expo": "catalog:", "@better-auth/expo": "catalog:",
@@ -241,7 +243,7 @@
}, },
"packages/config": { "packages/config": {
"name": "@sofa/config", "name": "@sofa/config",
"version": "0.1.0", "version": "0.1.1",
"devDependencies": { "devDependencies": {
"@sofa/tsconfig": "workspace:*", "@sofa/tsconfig": "workspace:*",
"@types/bun": "catalog:", "@types/bun": "catalog:",
@@ -250,7 +252,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@sofa/core", "name": "@sofa/core",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@orpc/server": "catalog:", "@orpc/server": "catalog:",
"@sofa/api": "workspace:*", "@sofa/api": "workspace:*",
@@ -274,7 +276,7 @@
}, },
"packages/db": { "packages/db": {
"name": "@sofa/db", "name": "@sofa/db",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@sofa/config": "workspace:*", "@sofa/config": "workspace:*",
"@sofa/logger": "workspace:*", "@sofa/logger": "workspace:*",
@@ -289,7 +291,7 @@
}, },
"packages/i18n": { "packages/i18n": {
"name": "@sofa/i18n", "name": "@sofa/i18n",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@lingui/core": "catalog:", "@lingui/core": "catalog:",
"@lingui/react": "catalog:", "@lingui/react": "catalog:",
@@ -307,7 +309,7 @@
}, },
"packages/logger": { "packages/logger": {
"name": "@sofa/logger", "name": "@sofa/logger",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"pino": "10.3.1", "pino": "10.3.1",
"pino-pretty": "13.1.3", "pino-pretty": "13.1.3",
@@ -320,7 +322,7 @@
}, },
"packages/test": { "packages/test": {
"name": "@sofa/test", "name": "@sofa/test",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@sofa/db": "workspace:*", "@sofa/db": "workspace:*",
"better-sqlite3": "12.8.0", "better-sqlite3": "12.8.0",
@@ -335,7 +337,7 @@
}, },
"packages/tmdb": { "packages/tmdb": {
"name": "@sofa/tmdb", "name": "@sofa/tmdb",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@sofa/logger": "workspace:*", "@sofa/logger": "workspace:*",
"openapi-fetch": "0.17.0", "openapi-fetch": "0.17.0",
@@ -348,7 +350,7 @@
}, },
"packages/tsconfig": { "packages/tsconfig": {
"name": "@sofa/tsconfig", "name": "@sofa/tsconfig",
"version": "0.1.0", "version": "0.1.1",
}, },
}, },
"catalog": { "catalog": {
@@ -368,6 +370,7 @@
"@orpc/server": "1.13.9", "@orpc/server": "1.13.9",
"@orpc/tanstack-query": "1.13.9", "@orpc/tanstack-query": "1.13.9",
"@orpc/zod": "1.13.9", "@orpc/zod": "1.13.9",
"@tanstack/react-form": "1.28.5",
"@tanstack/react-query": "5.95.2", "@tanstack/react-query": "5.95.2",
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@types/node": "25.5.0", "@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/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=="], "@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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-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=="], "@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=="], "@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/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-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=="], "@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/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=="], "@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=="], "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/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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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/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/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=="], "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=="], "@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=="], "@expo/cli/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@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/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=="], "@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/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/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=="], "@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=="], "@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/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=="], "@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/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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"shadcn/ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "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=="], "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/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=="], "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=="], "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=="], "@expo/cli/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@expo/cli/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "@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/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/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=="], "@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=="], "@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/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=="], "@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=="], "@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=="], "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=="], "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=="], "@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/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/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=="], "@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=="], "@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=="], "expo-updates/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"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=="],
"shadcn/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "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
View File
@@ -1569,6 +1569,419 @@
}, },
"openapi": "3.1.1", "openapi": "3.1.1",
"paths": { "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}": { "/titles/{id}": {
"get": { "get": {
"operationId": "titles.detail", "operationId": "titles.detail",
@@ -2894,6 +3307,39 @@
}, },
"allowEmptyValue": true, "allowEmptyValue": true,
"allowReserved": 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": { "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": { "/dashboard/recommendations": {
"get": { "get": {
"operationId": "dashboard.recommendations", "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": { "/search": {
"get": { "get": {
"operationId": "search", "operationId": "search",
@@ -4353,7 +4763,7 @@
{ {
"name": "genreId", "name": "genreId",
"in": "query", "in": "query",
"required": true, "required": false,
"schema": { "schema": {
"type": "integer", "type": "integer",
"minimum": -9007199254740991, "minimum": -9007199254740991,
@@ -4363,6 +4773,86 @@
"allowEmptyValue": true, "allowEmptyValue": true,
"allowReserved": 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", "name": "page",
"in": "query", "in": "query",
+1
View File
@@ -23,6 +23,7 @@
"@orpc/server": "1.13.9", "@orpc/server": "1.13.9",
"@orpc/tanstack-query": "1.13.9", "@orpc/tanstack-query": "1.13.9",
"@orpc/zod": "1.13.9", "@orpc/zod": "1.13.9",
"@tanstack/react-form": "1.28.5",
"@tanstack/react-query": "5.95.2", "@tanstack/react-query": "5.95.2",
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@types/node": "25.5.0", "@types/node": "25.5.0",
+82 -12
View File
@@ -23,11 +23,14 @@ import {
ImportPreviewSchema, ImportPreviewSchema,
IntegrationOutput, IntegrationOutput,
IntegrationsListOutput, IntegrationsListOutput,
LibraryOutput, LibraryGenresOutput,
LibraryListInput,
LibraryListOutput,
MediaTypeParam, MediaTypeParam,
PageParam, PageParam,
PaginatedInput, PaginatedInput,
ParseFileInput, ParseFileInput,
PlatformsListOutput,
ParsePayloadInput, ParsePayloadInput,
PersonDetailOutput, PersonDetailOutput,
PopularOutput, PopularOutput,
@@ -53,6 +56,7 @@ import {
TriggerJobInput, TriggerJobInput,
TriggerJobOutput, TriggerJobOutput,
UpdateCheckOutput, UpdateCheckOutput,
UpdateUserPlatformsInput,
UpdateNameInput, UpdateNameInput,
UpdateRatingInput, UpdateRatingInput,
UpdateScheduleInput, UpdateScheduleInput,
@@ -62,8 +66,10 @@ import {
UploadAvatarInput, UploadAvatarInput,
UploadAvatarOutput, UploadAvatarOutput,
UserInfoOutput, UserInfoOutput,
UserPlatformsOutput,
WatchHistoryInput, WatchHistoryInput,
WatchHistoryOutput, WatchHistoryOutput,
WatchProvidersOutput,
} from "./schemas"; } from "./schemas";
export const contract = { 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: { dashboard: {
stats: oc stats: oc
.route({ .route({
@@ -270,17 +301,6 @@ export const contract = {
successDescription: "In-progress shows with next episode and watch progress", successDescription: "In-progress shows with next episode and watch progress",
}) })
.output(ContinueWatchingOutput), .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 recommendations: oc
.route({ .route({
method: "GET", method: "GET",
@@ -372,6 +392,24 @@ export const contract = {
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED), 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 search: oc
.route({ .route({
@@ -724,6 +762,38 @@ export const contract = {
}) })
.input(z.void()) .input(z.void())
.output(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: { imports: {
parseFile: oc parseFile: oc
+111 -6
View File
@@ -87,7 +87,26 @@ export const SearchInput = z
export const DiscoverInput = z export const DiscoverInput = z
.object({ .object({
type: z.enum(["movie", "tv"]).describe("Media type to discover"), type: z.enum(["movie", "tv"]).describe("Media type to discover"),
genreId: z.number().int().describe("TMDB genre ID to filter by"), genreId: z.number().int().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) .merge(PageParam)
.meta({ description: "Genre-based discovery filters" }); .meta({ description: "Genre-based discovery filters" });
@@ -261,14 +280,39 @@ export const SeasonSchema = z
export const AvailabilityOfferSchema = z export const AvailabilityOfferSchema = z
.object({ .object({
providerId: z.number().describe("JustWatch provider ID"), platformId: z.string().describe("Platform ID"),
providerName: z.string().describe("Display name (e.g. Netflix, Hulu)"), providerName: z.string().describe("Display name (e.g. Netflix, Hulu)"),
logoPath: z.string().nullable().describe("Provider logo image path"), logoPath: z.string().nullable().describe("Provider logo image path"),
offerType: z.string().describe("Offer type: flatrate, rent, buy, free, ads"), offerType: z.string().describe("Offer type: flatrate, rent, buy, free, ads"),
watchUrl: z.string().nullable().describe("Direct link to watch on this provider"), 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" }); .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 export const CastMemberSchema = z
.object({ .object({
id: z.string().describe("Credit ID"), 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", 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({ .object({
items: z.array( items: z.array(
z z
@@ -525,12 +598,36 @@ export const LibraryOutput = z
firstAirDate: z.string().nullable().describe("First air date (ISO 8601)"), firstAirDate: z.string().nullable().describe("First air date (ISO 8601)"),
voteAverage: z.number().nullable().describe("Average rating (0-10)"), voteAverage: z.number().nullable().describe("Average rating (0-10)"),
userStatus: displayStatusEnum.nullable().describe("User's display status"), 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) .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 export const DashboardRecommendationsOutput = z
.object({ .object({
@@ -553,6 +650,14 @@ export const UpcomingInput = z
.describe("How many days into the future to look"), .describe("How many days into the future to look"),
limit: z.number().int().min(1).max(50).default(20).describe("Maximum items per page"), limit: z.number().int().min(1).max(50).default(20).describe("Maximum items per page"),
cursor: z.string().optional().describe("Pagination cursor"), 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" }); .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"), isNewSeason: z.boolean().describe("Whether this is a new season for a completed show"),
streamingProvider: z streamingProvider: z
.object({ .object({
providerId: z.number(), platformId: z.string(),
providerName: z.string(), providerName: z.string(),
logoPath: z.string().nullable(), logoPath: z.string().nullable(),
}) })
+4
View File
@@ -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_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"; 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";
+2
View File
@@ -14,12 +14,14 @@
"./display-status": "./src/display-status.ts", "./display-status": "./src/display-status.ts",
"./export": "./src/export.ts", "./export": "./src/export.ts",
"./image-cache": "./src/image-cache.ts", "./image-cache": "./src/image-cache.ts",
"./library": "./src/library.ts",
"./imports": "./src/imports/index.ts", "./imports": "./src/imports/index.ts",
"./imports/parsers": "./src/imports/parsers.ts", "./imports/parsers": "./src/imports/parsers.ts",
"./integrations": "./src/integrations.ts", "./integrations": "./src/integrations.ts",
"./lists": "./src/lists.ts", "./lists": "./src/lists.ts",
"./metadata": "./src/metadata.ts", "./metadata": "./src/metadata.ts",
"./person": "./src/person.ts", "./person": "./src/person.ts",
"./platforms": "./src/platforms.ts",
"./providers": "./src/providers.ts", "./providers": "./src/providers.ts",
"./settings": "./src/settings.ts", "./settings": "./src/settings.ts",
"./system-health": "./src/system-health.ts", "./system-health": "./src/system-health.ts",
+12 -10
View File
@@ -1,9 +1,10 @@
import { import {
getAvailabilityOffers, ensurePlatformForTmdbProvider,
getAvailabilityForTitle,
replaceAvailabilityTransaction, replaceAvailabilityTransaction,
} from "@sofa/db/queries/availability"; } from "@sofa/db/queries/availability";
import { getTitleById } from "@sofa/db/queries/title"; 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 { createLogger } from "@sofa/logger";
import { getWatchProviders } from "@sofa/tmdb/client"; import { getWatchProviders } from "@sofa/tmdb/client";
@@ -18,21 +19,22 @@ export async function refreshAvailability(titleId: string) {
const now = new Date(); const now = new Date();
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const; const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
// Collect all offer rows, then batch insert in a single transaction const allOfferRows: (typeof titleAvailability.$inferInsert)[] = [];
const allOfferRows: (typeof availabilityOffers.$inferInsert)[] = [];
if (us) { if (us) {
for (const offerType of offerTypes) { for (const offerType of offerTypes) {
const providers = us[offerType]; const providers = us[offerType];
if (!providers) continue; if (!providers) continue;
for (const p of providers) { for (const p of providers) {
const platformId = ensurePlatformForTmdbProvider(
p.provider_id,
p.provider_name ?? "",
p.logo_path ?? null,
);
allOfferRows.push({ allOfferRows.push({
titleId, titleId,
region: "US", platformId,
providerId: p.provider_id,
providerName: p.provider_name ?? "",
logoPath: p.logo_path ?? "",
offerType, offerType,
link: us.link ?? null, region: "US",
lastFetchedAt: now, lastFetchedAt: now,
}); });
} }
@@ -51,5 +53,5 @@ export async function refreshAvailability(titleId: string) {
} }
export function getAvailability(titleId: string) { export function getAvailability(titleId: string) {
return getAvailabilityOffers(titleId); return getAvailabilityForTitle(titleId);
} }
+18 -10
View File
@@ -8,7 +8,6 @@ import {
getEpisodeWatchHistoryBuckets, getEpisodeWatchHistoryBuckets,
getHighlyRatedTitleIds, getHighlyRatedTitleIds,
getInProgressTitleIds, getInProgressTitleIds,
getLibraryFeed,
getMovieWatchCountSince, getMovieWatchCountSince,
getMovieWatchHistoryBuckets, getMovieWatchHistoryBuckets,
getNewAvailableFeed, getNewAvailableFeed,
@@ -305,8 +304,6 @@ export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[]
export { getNewAvailableFeed } from "@sofa/db/queries/discovery"; export { getNewAvailableFeed } from "@sofa/db/queries/discovery";
export { getLibraryFeed } from "@sofa/db/queries/discovery";
export function getRecommendationsFeed(userId: string) { export function getRecommendationsFeed(userId: string) {
// Get recommendations from user's highly-rated or completed titles // Get recommendations from user's highly-rated or completed titles
const userCompletedOrRated = getEngagedTitleIds(userId); const userCompletedOrRated = getEngagedTitleIds(userId);
@@ -373,7 +370,7 @@ export interface UpcomingItem {
userStatus: DisplayStatus; userStatus: DisplayStatus;
isNewSeason: boolean; isNewSeason: boolean;
streamingProvider: { streamingProvider: {
providerId: number; platformId: string;
providerName: string; providerName: string;
logoPath: string | null; logoPath: string | null;
} | null; } | null;
@@ -386,9 +383,18 @@ export interface UpcomingFeedResult {
export function getUpcomingFeed( export function getUpcomingFeed(
userId: string, userId: string,
options: { days?: number; limit?: number; cursor?: string } = {}, options: {
days?: number;
limit?: number;
cursor?: string;
mediaType?: "movie" | "tv";
statusFilter?: ("watching" | "watchlist")[];
} = {},
): UpcomingFeedResult { ): 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 now = new Date();
const today = formatLocalDate(now); const today = formatLocalDate(now);
@@ -412,8 +418,10 @@ export function getUpcomingFeed(
} }
} }
const fromDate = cursorDate ?? today; const fromDate = cursorDate ?? today;
const episodeRows = getUpcomingEpisodes(userId, fromDate, toDate); const episodeRows =
const movieRows = getUpcomingMovies(userId, fromDate, toDate); mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses);
const movieRows =
mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses);
// Merge into unified items // Merge into unified items
type RawItem = { date: string; titleId: string; titleName: string } & ( type RawItem = { date: string; titleId: string; titleName: string } & (
@@ -509,12 +517,12 @@ export function getUpcomingFeed(
const providerRows = getAvailabilityByTitleIds(titleIds); const providerRows = getAvailabilityByTitleIds(titleIds);
const providerMap = new Map< const providerMap = new Map<
string, string,
{ providerId: number; providerName: string; logoPath: string | null } { platformId: string; providerName: string; logoPath: string | null }
>(); >();
for (const p of providerRows) { for (const p of providerRows) {
if (!providerMap.has(p.titleId)) { if (!providerMap.has(p.titleId)) {
providerMap.set(p.titleId, { providerMap.set(p.titleId, {
providerId: p.providerId, platformId: p.platformId,
providerName: p.providerName, providerName: p.providerName,
logoPath: p.logoPath, logoPath: p.logoPath,
}); });
+39
View File
@@ -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);
}
+17 -6
View File
@@ -30,6 +30,7 @@ import {
upsertSeasonReturning, upsertSeasonReturning,
} from "@sofa/db/queries/metadata"; } from "@sofa/db/queries/metadata";
import { getTitleById } from "@sofa/db/queries/title"; import { getTitleById } from "@sofa/db/queries/title";
import { getUserPlatformIds } from "@sofa/db/queries/user-platforms";
import type { titles } from "@sofa/db/schema"; import type { titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger"; import { createLogger } from "@sofa/logger";
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client"; import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
@@ -683,17 +684,25 @@ async function ensureEnriched(
return false; return false;
} }
function readAvailability(titleId: string, titleName: string): AvailabilityOffer[] { function readAvailability(
titleId: string,
titleName: string,
userPlatformIds?: Set<string>,
): AvailabilityOffer[] {
return getAvailabilityOffersForTitle(titleId).map((a) => ({ return getAvailabilityOffersForTitle(titleId).map((a) => ({
providerId: a.providerId, platformId: a.platformId,
providerName: a.providerName, providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "logos"), logoPath: tmdbImageUrl(a.logoPath, "logos"),
offerType: a.offerType, 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; title: ResolvedTitle;
seasons: Season[]; seasons: Season[];
availability: AvailabilityOffer[]; availability: AvailabilityOffer[];
@@ -740,7 +749,8 @@ export async function getOrFetchTitle(id: string): Promise<{
} }
// Read enrichment data, then backfill anything missing // 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); let cast = getCastForTitle(id);
if (title.lastFetchedAt) { if (title.lastFetchedAt) {
@@ -751,7 +761,8 @@ export async function getOrFetchTitle(id: string): Promise<{
if (enriched) { if (enriched) {
// Re-read only what was missing // Re-read only what was missing
if (cast.length === 0) cast = getCastForTitle(id); 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; title = getTitleById(id) ?? title;
} }
} }
+31
View File
@@ -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);
}
+5 -114
View File
@@ -1,117 +1,8 @@
/** /**
* Provider registry mapping TMDB provider IDs to search URL templates. * Generate a watch URL for a provider using its URL template.
* * Templates use {title} as a placeholder for the URL-encoded title name.
* 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
*/ */
export function generateProviderUrl(urlTemplate: string | null, titleName: string): string | null {
interface ProviderConfig { if (!urlTemplate) return null;
name: string; return urlTemplate.replace("{title}", encodeURIComponent(titleName));
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));
} }
+13 -5
View File
@@ -1,7 +1,14 @@
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { availabilityOffers } from "@sofa/db/schema"; import { titleAvailability } from "@sofa/db/schema";
import { clearAllTables, eq, insertAvailabilityOffer, insertTitle, testDb } from "@sofa/test/db"; import {
clearAllTables,
eq,
insertPlatform,
insertTitle,
insertTitleAvailability,
testDb,
} from "@sofa/test/db";
const { getWatchProviders } = vi.hoisted(() => ({ const { getWatchProviders } = vi.hoisted(() => ({
getWatchProviders: vi.fn(async () => ({ results: {} as Record<string, unknown> })), getWatchProviders: vi.fn(async () => ({ results: {} as Record<string, unknown> })),
@@ -21,14 +28,15 @@ beforeEach(() => {
describe("refreshAvailability", () => { describe("refreshAvailability", () => {
test("clears stale US offers when TMDB returns no US availability", async () => { test("clears stale US offers when TMDB returns no US availability", async () => {
insertTitle({ id: "movie-1", tmdbId: 101, type: "movie", title: "Movie" }); 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"); await refreshAvailability("movie-1");
const offers = testDb const offers = testDb
.select() .select()
.from(availabilityOffers) .from(titleAvailability)
.where(eq(availabilityOffers.titleId, "movie-1")) .where(eq(titleAvailability.titleId, "movie-1"))
.all(); .all();
expect(offers).toHaveLength(0); expect(offers).toHaveLength(0);
+6 -87
View File
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import { import {
clearAllTables, clearAllTables,
insertAvailabilityOffer, insertPlatform,
insertTitleAvailability,
insertEpisodeWatch, insertEpisodeWatch,
insertMovieWatch, insertMovieWatch,
insertRating, insertRating,
@@ -15,7 +16,6 @@ import {
import { import {
getContinueWatchingFeed, getContinueWatchingFeed,
getLibraryFeed,
getNewAvailableFeed, getNewAvailableFeed,
getRecommendationsFeed, getRecommendationsFeed,
getRecommendationsForTitle, getRecommendationsForTitle,
@@ -231,7 +231,8 @@ describe("getNewAvailableFeed", () => {
insertUser(); insertUser();
insertTitle({ id: "m1", tmdbId: 1 }); insertTitle({ id: "m1", tmdbId: 1 });
insertStatus("user-1", "m1", "watchlist"); insertStatus("user-1", "m1", "watchlist");
insertAvailabilityOffer("m1"); const pId = insertPlatform({ id: "p-m1", tmdbProviderId: 8 });
insertTitleAvailability("m1", pId);
const feed = getNewAvailableFeed("user-1"); const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(1); expect(feed).toHaveLength(1);
@@ -250,7 +251,8 @@ describe("getNewAvailableFeed", () => {
test("excludes titles not in user library", () => { test("excludes titles not in user library", () => {
insertUser(); insertUser();
insertTitle({ id: "m1", tmdbId: 1 }); insertTitle({ id: "m1", tmdbId: 1 });
insertAvailabilityOffer("m1"); const pId = insertPlatform({ id: "p-m1", tmdbProviderId: 8 });
insertTitleAvailability("m1", pId);
const feed = getNewAvailableFeed("user-1"); const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(0); expect(feed).toHaveLength(0);
@@ -373,86 +375,3 @@ describe("getRecommendationsForTitle", () => {
expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]); expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]);
}); });
}); });
// ── getLibraryFeed ────────────────────────────────────────────────────
describe("getLibraryFeed", () => {
test("returns first page with correct pagination metadata", () => {
insertUser();
for (let i = 1; i <= 25; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 1, 20);
expect(result.items).toHaveLength(20);
expect(result.page).toBe(1);
expect(result.totalResults).toBe(25);
expect(result.totalPages).toBe(2);
});
test("returns second page with remaining items", () => {
insertUser();
for (let i = 1; i <= 25; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 2, 20);
expect(result.items).toHaveLength(5);
expect(result.page).toBe(2);
expect(result.totalResults).toBe(25);
expect(result.totalPages).toBe(2);
});
test("custom limit respected", () => {
insertUser();
for (let i = 1; i <= 10; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 1, 5);
expect(result.items).toHaveLength(5);
expect(result.totalPages).toBe(2);
expect(result.totalResults).toBe(10);
});
test("empty page beyond total returns empty items", () => {
insertUser();
for (let i = 1; i <= 5; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 2, 20);
expect(result.items).toHaveLength(0);
expect(result.page).toBe(2);
expect(result.totalResults).toBe(5);
});
test("pages are disjoint and cover all items", () => {
insertUser();
for (let i = 1; i <= 4; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const page1 = getLibraryFeed("user-1", 1, 2);
const page2 = getLibraryFeed("user-1", 2, 2);
expect(page1.items).toHaveLength(2);
expect(page2.items).toHaveLength(2);
const allIds = [
...page1.items.map((i) => i.tmdbId),
...page2.items.map((i) => i.tmdbId),
].sort();
expect(allIds).toEqual([1, 2, 3, 4]);
});
});
+145
View File
@@ -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([]);
});
});
+8 -5
View File
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import { import {
clearAllTables, clearAllTables,
insertAvailabilityOffer, insertPlatform,
insertTitleAvailability,
insertEpisodeWatch, insertEpisodeWatch,
insertStatus, insertStatus,
insertTitle, insertTitle,
@@ -282,13 +283,14 @@ describe("streaming provider", () => {
const tomorrow = daysFromNow(1); const tomorrow = daysFromNow(1);
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
insertStatus("user-1", "tv-1", "in_progress"); 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 }); const result = getUpcomingFeed("user-1", { days: 7 });
expect(result.items[0].streamingProvider).toEqual({ expect(result.items[0].streamingProvider).toEqual({
providerId: 8, platformId: "p-netflix",
providerName: "Netflix", providerName: "Netflix",
logoPath: null, logoPath: "/logo.png",
}); });
}); });
@@ -296,7 +298,8 @@ describe("streaming provider", () => {
const tomorrow = daysFromNow(1); const tomorrow = daysFromNow(1);
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
insertStatus("user-1", "tv-1", "in_progress"); 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 }); const result = getUpcomingFeed("user-1", { days: 7 });
expect(result.items[0].streamingProvider).toBeNull(); 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
+2 -1
View File
@@ -7,7 +7,8 @@
"./client": "./src/client.ts", "./client": "./src/client.ts",
"./migrate": "./src/migrate.ts", "./migrate": "./src/migrate.ts",
"./schema": "./src/schema.ts", "./schema": "./src/schema.ts",
"./queries/*": "./src/queries/*.ts" "./queries/*": "./src/queries/*.ts",
"./seed-platforms": "./src/seed-platforms.ts"
}, },
"scripts": { "scripts": {
"lint": "oxlint", "lint": "oxlint",
+50 -8
View File
@@ -1,24 +1,66 @@
import { and, eq } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
import { availabilityOffers } from "../schema"; import { platforms, titleAvailability } from "../schema";
export function replaceAvailabilityTransaction( export function replaceAvailabilityTransaction(
titleId: string, titleId: string,
region: string, region: string,
offers: (typeof availabilityOffers.$inferInsert)[], offers: (typeof titleAvailability.$inferInsert)[],
): void { ): void {
db.transaction((tx) => { db.transaction((tx) => {
tx.delete(availabilityOffers) tx.delete(titleAvailability)
.where(and(eq(availabilityOffers.titleId, titleId), eq(availabilityOffers.region, region))) .where(and(eq(titleAvailability.titleId, titleId), eq(titleAvailability.region, region)))
.run(); .run();
if (offers.length > 0) { if (offers.length > 0) {
tx.insert(availabilityOffers).values(offers).onConflictDoNothing().run(); tx.insert(titleAvailability).values(offers).onConflictDoNothing().run();
} }
}); });
} }
export function getAvailabilityOffers(titleId: string) { export function getAvailabilityForTitle(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();
}
/**
* 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;
} }
+10 -10
View File
@@ -2,9 +2,9 @@ import { and, eq, gte, inArray, isNotNull, lt, or } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
import { import {
availabilityOffers,
cronRuns, cronRuns,
seasons, seasons,
titleAvailability,
titleCast, titleCast,
titleRecommendations, titleRecommendations,
titles, titles,
@@ -64,10 +64,10 @@ export function getTitlesWithStaleOffers(titleIds: string[]) {
if (titleIds.length === 0) return new Set<string>(); if (titleIds.length === 0) return new Set<string>();
return new Set( return new Set(
db db
.select({ titleId: availabilityOffers.titleId }) .select({ titleId: titleAvailability.titleId })
.from(availabilityOffers) .from(titleAvailability)
.where(inArray(availabilityOffers.titleId, titleIds)) .where(inArray(titleAvailability.titleId, titleIds))
.groupBy(availabilityOffers.titleId) .groupBy(titleAvailability.titleId)
.all() .all()
.map((r) => r.titleId), .map((r) => r.titleId),
); );
@@ -77,15 +77,15 @@ export function getTitlesWithStaleOffersFetchedBefore(titleIds: string[], staleD
if (titleIds.length === 0) return new Set<string>(); if (titleIds.length === 0) return new Set<string>();
return new Set( return new Set(
db db
.select({ titleId: availabilityOffers.titleId }) .select({ titleId: titleAvailability.titleId })
.from(availabilityOffers) .from(titleAvailability)
.where( .where(
and( and(
inArray(availabilityOffers.titleId, titleIds), inArray(titleAvailability.titleId, titleIds),
lt(availabilityOffers.lastFetchedAt, staleDate), lt(titleAvailability.lastFetchedAt, staleDate),
), ),
) )
.groupBy(availabilityOffers.titleId) .groupBy(titleAvailability.titleId)
.all() .all()
.map((r) => r.titleId), .map((r) => r.titleId),
); );
+49 -70
View File
@@ -2,9 +2,10 @@ import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
import { import {
availabilityOffers,
episodes, episodes,
platforms,
seasons, seasons,
titleAvailability,
titleRecommendations, titleRecommendations,
titles, titles,
userEpisodeWatches, userEpisodeWatches,
@@ -189,64 +190,13 @@ export function getNewAvailableFeed(userId: string, _days = 14) {
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)), and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
) )
.where( .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)) .orderBy(desc(titles.popularity))
.limit(20) .limit(20)
.all(); .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) { export function getEngagedTitleIds(userId: string) {
return db return db
.select({ titleId: userTitleStatus.titleId }) .select({ titleId: userTitleStatus.titleId })
@@ -315,7 +265,22 @@ export function getTitleByIdOrNull(titleId: string) {
// ─── Upcoming feed queries ────────────────────────────────────────── // ─── 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 return db
.select({ .select({
episodeId: episodes.id, episodeId: episodes.id,
@@ -338,12 +303,31 @@ export function getUpcomingEpisodes(userId: string, fromDate: string, toDate: st
userTitleStatus, userTitleStatus,
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)), 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)) .orderBy(asc(episodes.airDate), asc(titles.title))
.all(); .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 return db
.select({ .select({
titleId: titles.id, titleId: titles.id,
@@ -360,13 +344,7 @@ export function getUpcomingMovies(userId: string, fromDate: string, toDate: stri
userTitleStatus, userTitleStatus,
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)), and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
) )
.where( .where(and(...conditions))
and(
eq(titles.type, "movie"),
gte(titles.releaseDate, fromDate),
lte(titles.releaseDate, toDate),
),
)
.orderBy(asc(titles.releaseDate), asc(titles.title)) .orderBy(asc(titles.releaseDate), asc(titles.title))
.all(); .all();
} }
@@ -375,16 +353,17 @@ export function getAvailabilityByTitleIds(titleIds: string[]) {
if (titleIds.length === 0) return []; if (titleIds.length === 0) return [];
return db return db
.select({ .select({
titleId: availabilityOffers.titleId, titleId: titleAvailability.titleId,
providerId: availabilityOffers.providerId, platformId: platforms.id,
providerName: availabilityOffers.providerName, providerName: platforms.name,
logoPath: availabilityOffers.logoPath, logoPath: platforms.logoPath,
}) })
.from(availabilityOffers) .from(titleAvailability)
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
.where( .where(
and( and(
inArray(availabilityOffers.titleId, titleIds), inArray(titleAvailability.titleId, titleIds),
eq(availabilityOffers.offerType, "flatrate"), eq(titleAvailability.offerType, "flatrate"),
), ),
) )
.all(); .all();
+13 -4
View File
@@ -1,7 +1,15 @@
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { db } from "../client"; 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 ─────────────────────────────────────────────── // ─── Title image paths ───────────────────────────────────────────────
@@ -51,9 +59,10 @@ export function getEpisodeStillsForTitle(titleId: string) {
export function getAvailabilityLogosForTitle(titleId: string) { export function getAvailabilityLogosForTitle(titleId: string) {
return db return db
.select({ logoPath: availabilityOffers.logoPath }) .select({ logoPath: platforms.logoPath })
.from(availabilityOffers) .from(titleAvailability)
.where(eq(availabilityOffers.titleId, titleId)) .innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
.where(eq(titleAvailability.titleId, titleId))
.all(); .all();
} }
+329
View File
@@ -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();
}
+15 -2
View File
@@ -2,10 +2,11 @@ import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
import { import {
availabilityOffers,
episodes, episodes,
genres, genres,
platforms,
seasons, seasons,
titleAvailability,
titleGenres, titleGenres,
titleRecommendations, titleRecommendations,
titles, titles,
@@ -260,7 +261,19 @@ export function getEpisodesNeedingStillHash(seasonIds: string[]) {
// ─── Availability ──────────────────────────────────────────────────── // ─── Availability ────────────────────────────────────────────────────
export function getAvailabilityOffersForTitle(titleId: string) { 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 ───────────────────────────────────────────────── // ─── Recommendations ─────────────────────────────────────────────────
+68
View File
@@ -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
View File
@@ -249,29 +249,64 @@ export const userRatings = sqliteTable(
(table) => [uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId)], (table) => [uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId)],
); );
export const availabilityOffers = sqliteTable( // ─── Platforms & Availability ────────────────────────────────────────
"availabilityOffers",
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") titleId: text("titleId")
.notNull() .notNull()
.references(() => titles.id, { onDelete: "cascade" }), .references(() => titles.id, { onDelete: "cascade" }),
region: text("region").notNull().default("US"), platformId: text("platformId")
providerId: int("providerId").notNull(), .notNull()
providerName: text("providerName").notNull(), .references(() => platforms.id, { onDelete: "cascade" }),
logoPath: text("logoPath"),
offerType: text("offerType", { offerType: text("offerType", {
enum: ["flatrate", "rent", "buy", "free", "ads"], enum: ["flatrate", "rent", "buy", "free", "ads"],
}).notNull(), }).notNull(),
link: text("link"), region: text("region").notNull().default("US"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }), lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
}, },
(table) => [ (table) => [
uniqueIndex("availabilityOffers_unique").on( uniqueIndex("titleAvailability_unique").on(
table.titleId, table.titleId,
table.region, table.platformId,
table.providerId,
table.offerType, 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),
], ],
); );
+198
View File
@@ -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,
});
}
});
}
+346
View File
@@ -177,6 +177,10 @@ msgstr "{ratingCount} ratings"
msgid "{star, plural, one {# star} other {# stars}}" msgid "{star, plural, one {# star} other {# stars}}"
msgstr "{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 #: 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." 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." 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}" msgid "age {age}"
msgstr "" 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/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/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" msgid "All"
msgstr "" 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 #: apps/web/src/components/settings/registration-section.tsx
msgid "Allow new users to create accounts" msgid "Allow new users to create accounts"
msgstr "" msgstr ""
@@ -329,6 +358,22 @@ msgstr ""
msgid "Anonymous usage reporting" msgid "Anonymous usage reporting"
msgstr "" 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 #: apps/web/src/routes/_app/settings.tsx
msgid "App Settings" msgid "App Settings"
msgstr "" msgstr ""
@@ -337,6 +382,10 @@ msgstr ""
msgid "Application" msgid "Application"
msgstr "" msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
msgid "Apply"
msgstr "Apply"
#: apps/native/src/components/settings/integration-card.tsx #: apps/native/src/components/settings/integration-card.tsx
msgid "Are you sure you want to disconnect {label}? The current URL will stop working." msgid "Are you sure you want to disconnect {label}? The current URL will stop working."
msgstr "" msgstr ""
@@ -374,6 +423,13 @@ msgstr ""
msgid "Availability" msgid "Availability"
msgstr "" 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 #: apps/native/src/app/(auth)/register.tsx
msgid "Back to Login" msgid "Back to Login"
msgstr "" msgstr ""
@@ -458,8 +514,10 @@ msgstr ""
msgid "Catch up" msgid "Catch up"
msgstr "" msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/titles/status-action-button.tsx #: apps/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/dashboard/upcoming-item.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/title-card.tsx
#: apps/web/src/components/titles/status-button.tsx #: apps/web/src/components/titles/status-button.tsx
msgid "Caught Up" msgid "Caught Up"
@@ -509,6 +567,10 @@ msgstr ""
msgid "Checking…" msgid "Checking…"
msgstr "" msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Chinese"
msgstr "Chinese"
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
#~ msgid "Choose how to import your {0} data." #~ msgid "Choose how to import your {0} data."
#~ msgstr "" #~ msgstr ""
@@ -521,6 +583,7 @@ msgstr "Choose how to import your {sourceLabel} data."
#~ msgid "Choose your preferred display language" #~ msgid "Choose your preferred display language"
#~ msgstr "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.ios.tsx #: apps/native/src/components/search/recently-viewed-list.ios.tsx
#: apps/native/src/components/search/recently-viewed-list.tsx #: apps/native/src/components/search/recently-viewed-list.tsx
@@ -529,9 +592,18 @@ msgid "Clear"
msgstr "" msgstr ""
#: apps/web/src/components/command-palette.tsx #: apps/web/src/components/command-palette.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Clear all" msgid "Clear all"
msgstr "" 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.ios.tsx
#: apps/native/src/components/search/recently-viewed-list.tsx #: apps/native/src/components/search/recently-viewed-list.tsx
msgid "Clear Recently Viewed?" msgid "Clear Recently Viewed?"
@@ -561,9 +633,11 @@ msgid "Close"
msgstr "" msgstr ""
#: apps/native/src/app/(tabs)/(home)/index.tsx #: 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/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/dashboard/stats-display.tsx #: apps/web/src/components/dashboard/stats-display.tsx
#: apps/web/src/components/dashboard/upcoming-item.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/title-card.tsx
#: apps/web/src/components/titles/status-button.tsx #: apps/web/src/components/titles/status-button.tsx
msgid "Completed" msgid "Completed"
@@ -631,6 +705,16 @@ msgstr ""
msgid "Connecting…" msgid "Connecting…"
msgstr "" 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 #: apps/native/src/app/(tabs)/(settings)/index.tsx
msgid "Continue" msgid "Continue"
msgstr "" msgstr ""
@@ -731,10 +815,23 @@ msgstr ""
msgid "Database restored. Reloading..." msgid "Database restored. Reloading..."
msgstr "" 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 #: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Day:" msgid "Day:"
msgstr "" 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
#: apps/web/src/components/settings/backup-section.tsx #: apps/web/src/components/settings/backup-section.tsx
msgid "Delete" msgid "Delete"
@@ -784,6 +881,10 @@ msgstr ""
msgid "Disconnect {label}" msgid "Disconnect {label}"
msgstr "" msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Discover"
msgstr "Discover"
#: apps/native/src/app/(tabs)/(settings)/index.tsx #: apps/native/src/app/(tabs)/(settings)/index.tsx
msgid "Display name" msgid "Display name"
msgstr "Display name" msgstr "Display name"
@@ -869,6 +970,10 @@ msgstr ""
msgid "Enable the <0>Playback</0> event category" msgid "Enable the <0>Playback</0> event category"
msgstr "" 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)/login.tsx
#: apps/native/src/app/(auth)/register.tsx #: apps/native/src/app/(auth)/register.tsx
msgid "Enter a valid email address" msgid "Enter a valid email address"
@@ -1184,6 +1289,12 @@ msgstr ""
msgid "Filmography" msgid "Filmography"
msgstr "" 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 #: apps/web/src/components/settings/imports-section.tsx
msgid "Finished importing from {source}." msgid "Finished importing from {source}."
msgstr "" msgstr ""
@@ -1200,6 +1311,10 @@ msgstr ""
msgid "Free up disk space by clearing cached metadata and images" msgid "Free up disk space by clearing cached metadata and images"
msgstr "" msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "French"
msgstr "French"
#: apps/web/src/components/settings/backup-schedule-section.tsx #: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Frequency" msgid "Frequency"
msgstr "" msgstr ""
@@ -1208,6 +1323,24 @@ msgstr ""
msgid "Friday" msgid "Friday"
msgstr "" 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 #: apps/web/src/components/landing-page.tsx
msgid "Get Started" msgid "Get Started"
msgstr "" msgstr ""
@@ -1247,6 +1380,14 @@ msgstr ""
msgid "Here's what's happening with your library" msgid "Here's what's happening with your library"
msgstr "" 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/app/(tabs)/(home)/_layout.tsx
#: apps/native/src/components/navigation/native-tab-bar.tsx #: apps/native/src/components/navigation/native-tab-bar.tsx
#: apps/web/src/components/nav-bar.tsx #: apps/web/src/components/nav-bar.tsx
@@ -1377,6 +1518,14 @@ msgstr ""
msgid "Invalid token" msgid "Invalid token"
msgstr "" 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 #: apps/web/src/components/settings/system-health-section.tsx
msgid "Job" msgid "Job"
msgstr "" msgstr ""
@@ -1394,8 +1543,15 @@ msgstr "Keeping"
msgid "Keyboard Shortcuts" msgid "Keyboard Shortcuts"
msgstr "" 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/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 #: apps/web/src/components/settings/language-section.tsx
msgid "Language" msgid "Language"
msgstr "" msgstr ""
@@ -1444,7 +1600,12 @@ msgstr ""
msgid "Last run succeeded" msgid "Last run succeeded"
msgstr "" 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/components/settings/account-section.tsx
#: apps/web/src/routes/_app/library.tsx
msgid "Library" msgid "Library"
msgstr "Library" msgstr "Library"
@@ -1559,14 +1720,32 @@ msgstr ""
#~ msgid "Marked as watching" #~ msgid "Marked as watching"
#~ msgstr "" #~ 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 #: apps/web/src/components/settings/account-section.tsx
msgid "Member since {memberSince}" msgid "Member since {memberSince}"
msgstr "" 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 #: apps/web/src/components/auth-form.tsx
msgid "Min 8 characters…" msgid "Min 8 characters…"
msgstr "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 #: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Monday" msgid "Monday"
msgstr "" msgstr ""
@@ -1579,18 +1758,28 @@ msgstr ""
msgid "More Settings" msgid "More Settings"
msgstr "" 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/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/recently-viewed-row-content.tsx
#: apps/native/src/components/search/search-result-row.tsx #: apps/native/src/components/search/search-result-row.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/explore/hero-banner.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/titles/title-hero.tsx #: apps/web/src/components/titles/title-hero.tsx
msgid "Movie" msgid "Movie"
msgstr "" msgstr ""
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/components/settings/account-section.tsx #: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-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" msgid "Movies"
msgstr "" msgstr ""
@@ -1676,6 +1865,7 @@ msgstr ""
msgid "New Season" msgid "New Season"
msgstr "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
#: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx
msgid "Newest" msgid "Newest"
@@ -1708,10 +1898,18 @@ msgstr ""
msgid "No internet connection" msgid "No internet connection"
msgstr "" 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 #: apps/native/src/app/(tabs)/(search)/index.tsx
msgid "No results for \"{debouncedQuery}\"" msgid "No results for \"{debouncedQuery}\""
msgstr "" 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 #: apps/web/src/components/command-palette.tsx
msgid "No results found." msgid "No results found."
msgstr "" msgstr ""
@@ -1721,6 +1919,14 @@ msgstr ""
msgid "No titles found for this genre." msgid "No titles found for this genre."
msgstr "" 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/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx #: apps/web/src/routes/_app/upcoming.tsx
msgid "No upcoming episodes or releases in the next 90 days." msgid "No upcoming episodes or releases in the next 90 days."
@@ -1736,6 +1942,14 @@ msgstr ""
msgid "Not Found" msgid "Not Found"
msgstr "" 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/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/dashboard/upcoming-item.tsx
#: apps/web/src/components/title-card.tsx #: apps/web/src/components/title-card.tsx
@@ -1904,6 +2118,20 @@ msgstr ""
msgid "Popular TV Shows" msgid "Popular TV Shows"
msgstr "" 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 #: apps/web/src/components/settings/backup-section.tsx
msgid "Pre-restore backup" msgid "Pre-restore backup"
msgstr "" msgstr ""
@@ -1929,6 +2157,12 @@ msgstr ""
msgid "Profile picture updated" msgid "Profile picture updated"
msgstr "" 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
#: apps/web/src/components/settings/danger-section.tsx #: apps/web/src/components/settings/danger-section.tsx
msgid "Purge all" msgid "Purge all"
@@ -2008,6 +2242,10 @@ msgstr "Rated {ratingStars, plural, one {# star} other {# stars}}"
msgid "Rated {stars, plural, one {# star} other {# stars}}" msgid "Rated {stars, plural, one {# star} other {# stars}}"
msgstr "" 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/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/components/titles/star-rating.tsx #: apps/web/src/components/titles/star-rating.tsx
@@ -2117,6 +2355,11 @@ msgstr "Registration failed"
msgid "Registration opened" msgid "Registration opened"
msgstr "" 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/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/titles/status-button.tsx #: apps/web/src/components/titles/status-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" msgid "Search for movies, TV shows, or run commands"
msgstr "" 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 #: apps/web/src/components/command-palette.tsx
msgid "Search movies & TV shows…" msgid "Search movies & TV shows…"
msgstr "" msgstr ""
@@ -2313,6 +2564,10 @@ msgstr ""
msgid "Search movies, shows, people..." msgid "Search movies, shows, people..."
msgstr "" 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
#: apps/web/src/components/nav-bar.tsx #: apps/web/src/components/nav-bar.tsx
msgid "Search…" msgid "Search…"
@@ -2524,15 +2779,28 @@ msgstr ""
msgid "Sonarr List URL" msgid "Sonarr List URL"
msgstr "" 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 #: apps/web/src/components/people/filmography-grid.tsx
msgid "Sort filmography" msgid "Sort filmography"
msgstr "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 #: apps/web/src/components/dashboard/stats-section.tsx
msgid "Start exploring" msgid "Start exploring"
msgstr "" msgstr ""
#: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/app/(tabs)/(home)/index.tsx
#: apps/native/src/app/(tabs)/(library)/index.tsx
msgid "Start tracking movies and shows" msgid "Start tracking movies and shows"
msgstr "" msgstr ""
@@ -2549,6 +2817,11 @@ msgstr ""
msgid "Starting import..." msgid "Starting import..."
msgstr "" 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 #: apps/native/src/hooks/use-title-actions.ts
msgid "Status updated" msgid "Status updated"
msgstr "" msgstr ""
@@ -2561,6 +2834,11 @@ msgstr ""
msgid "Stream" msgid "Stream"
msgstr "" 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 #: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Sunday" msgid "Sunday"
msgstr "" msgstr ""
@@ -2677,11 +2955,21 @@ msgstr ""
msgid "Time:" msgid "Time:"
msgstr "" 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/native/src/app/title/[id].tsx
#: apps/web/src/routes/_app/titles.$id.tsx #: apps/web/src/routes/_app/titles.$id.tsx
msgid "Title not found" msgid "Title not found"
msgstr "" 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/native/src/components/settings/integration-configs.ts
#: apps/web/src/components/settings/integration-configs.tsx #: 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)" 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)" msgid "Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)"
msgstr "" 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 #: apps/native/src/app/(tabs)/(home)/index.tsx
msgid "today" msgid "today"
msgstr "today" msgstr "today"
@@ -2736,6 +3033,10 @@ msgstr ""
msgid "Trigger job" msgid "Trigger job"
msgstr "" 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/native/src/app/title/[id].tsx
#: apps/web/src/components/route-error.tsx #: apps/web/src/components/route-error.tsx
#: apps/web/src/routes/__root.tsx #: apps/web/src/routes/__root.tsx
@@ -2747,9 +3048,12 @@ msgid "Tuesday"
msgstr "" msgstr ""
#: apps/native/src/app/title/[id].tsx #: 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/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/explore/hero-banner.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/title-hero.tsx #: apps/web/src/components/titles/title-hero.tsx
msgid "TV" msgid "TV"
@@ -2763,6 +3067,17 @@ msgstr ""
msgid "TV show" msgid "TV show"
msgstr "" 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 #: apps/web/src/components/settings/system-health-section.tsx
msgid "unknown" msgid "unknown"
msgstr "" msgstr ""
@@ -2898,6 +3213,11 @@ msgstr "Use at least 8 characters"
msgid "User menu" msgid "User menu"
msgstr "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 #: apps/web/src/components/titles/title-hero.tsx
msgid "View on TMDB" msgid "View on TMDB"
msgstr "View on TMDB" msgstr "View on TMDB"
@@ -2958,17 +3278,27 @@ msgstr ""
msgid "Watched S{sNum} E{eNum}" msgid "Watched S{sNum} E{eNum}"
msgstr "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/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/dashboard/upcoming-item.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/title-card.tsx
#: apps/web/src/components/titles/status-button.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" msgid "Watching"
msgstr "" 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/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/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
#: apps/web/src/components/titles/status-button.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" msgid "Watchlist"
msgstr "" msgstr ""
@@ -3014,6 +3344,21 @@ msgstr ""
msgid "Writer" msgid "Writer"
msgstr "" 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 #: apps/native/src/app/(tabs)/(settings)/index.tsx
msgid "You'll be signed out to change the server URL." msgid "You'll be signed out to change the server URL."
msgstr "" msgstr ""
@@ -3031,6 +3376,7 @@ msgid "Your code:"
msgstr "" msgstr ""
#: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/app/(tabs)/(home)/index.tsx
#: apps/native/src/app/(tabs)/(library)/index.tsx
#: apps/web/src/components/dashboard/stats-section.tsx #: apps/web/src/components/dashboard/stats-section.tsx
msgid "Your library is empty" msgid "Your library is empty"
msgstr "" msgstr ""
+39 -7
View File
@@ -16,7 +16,9 @@ const {
userEpisodeWatches, userEpisodeWatches,
userTitleStatus, userTitleStatus,
userRatings, userRatings,
availabilityOffers, platforms,
titleAvailability,
userPlatforms,
titleRecommendations, titleRecommendations,
integrations, integrations,
} = schema; } = 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(); 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, titleId: string,
platformId: string,
overrides: { overrides: {
providerId?: number;
providerName?: string;
offerType?: "flatrate" | "rent" | "buy" | "free" | "ads"; offerType?: "flatrate" | "rent" | "buy" | "free" | "ads";
region?: string;
} = {}, } = {},
) { ) {
testDb testDb
.insert(availabilityOffers) .insert(titleAvailability)
.values({ .values({
titleId, titleId,
providerId: overrides.providerId ?? 8, platformId,
providerName: overrides.providerName ?? "Netflix",
offerType: overrides.offerType ?? "flatrate", offerType: overrides.offerType ?? "flatrate",
region: overrides.region ?? "US",
lastFetchedAt: new Date(),
}) })
.run(); .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") { export function insertIntegration(userId: string, provider: string, token = "test-token") {
const type = provider === "sonarr" || provider === "radarr" ? "list" : "webhook"; const type = provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
return testDb return testDb
+23
View File
@@ -261,6 +261,29 @@ export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
return data as TmdbWatchProviderResponse; 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 ────────────────────────────────────── // ─── Recommendations & Similar ──────────────────────────────────────
export async function getRecommendations(tmdbId: number, type: "movie" | "tv") { export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {