mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat: reorganize API around operation domains
Restructure the oRPC contract from 14 resource-oriented routers to 8 domain-oriented routers for a cleaner public API surface. - Consolidate 7 watch procedures into unified tracking.watch/unwatch with scope + ids input (movie, episode, season, series) - Split dashboard across tracking (stats, history), library (continueWatching, upcoming), and discover (recommendations) - Merge explore + search + discover into single discover router - Absorb integrations into account.integrations - Merge system.authConfig into system.publicInfo - Collapse 6 admin setting endpoints into admin.settings.get/update - Deduplicate platforms.list + explore.watchProviders into discover.platforms - Add symmetric unwatchMovie/unwatchSeries core functions - Rename titles.detail→get, titles.recommendations→similar, people.detail→get BREAKING CHANGE: All client API paths have changed. REST paths now mirror router structure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ export default function LoginScreen() {
|
||||
const [errorFields, setErrorFields] = useState<Set<string>>(new Set());
|
||||
const [isSignedIn, setIsSignedIn] = useState(false);
|
||||
|
||||
const authConfig = useQuery(orpc.system.authConfig.queryOptions());
|
||||
const publicInfo = useQuery(orpc.system.publicInfo.queryOptions());
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: { email: "", password: "" },
|
||||
@@ -76,9 +76,9 @@ export default function LoginScreen() {
|
||||
const statusCompletedColor = useCSSVariable("--color-status-completed") as string;
|
||||
const serverHost = splitUrl(getServerUrl()).host;
|
||||
|
||||
const showPasswordLogin = !authConfig.data?.passwordLoginDisabled;
|
||||
const showOidc = authConfig.data?.oidcEnabled;
|
||||
const showRegister = authConfig.data?.registrationOpen;
|
||||
const showPasswordLogin = !publicInfo.data?.passwordLoginDisabled;
|
||||
const showOidc = publicInfo.data?.oidcEnabled;
|
||||
const showRegister = publicInfo.data?.registrationOpen;
|
||||
|
||||
const clearFieldError = (name: string) => {
|
||||
if (errorFields.has(name)) {
|
||||
@@ -94,7 +94,7 @@ export default function LoginScreen() {
|
||||
<AuthScreen title="Sofa" subtitle={t`Sign in to continue`}>
|
||||
{showOidc &&
|
||||
(() => {
|
||||
const providerName = authConfig.data?.oidcProviderName ?? "SSO";
|
||||
const providerName = publicInfo.data?.oidcProviderName ?? "SSO";
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)} className="mb-4">
|
||||
<Button
|
||||
|
||||
@@ -18,7 +18,7 @@ const exploreContentContainerStyle = {
|
||||
export default function ExploreScreen() {
|
||||
const { t } = useLingui();
|
||||
const trending = useInfiniteQuery(
|
||||
orpc.explore.trending.infiniteOptions({
|
||||
orpc.discover.trending.infiniteOptions({
|
||||
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
@@ -26,16 +26,15 @@ export default function ExploreScreen() {
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
const popularMovies = useQuery(orpc.explore.popular.queryOptions({ input: { type: "movie" } }));
|
||||
const popularTv = useQuery(orpc.explore.popular.queryOptions({ input: { type: "tv" } }));
|
||||
const movieGenres = useQuery(orpc.explore.genres.queryOptions({ input: { type: "movie" } }));
|
||||
const tvGenres = useQuery(orpc.explore.genres.queryOptions({ input: { type: "tv" } }));
|
||||
const popularMovies = useQuery(orpc.discover.popular.queryOptions({ input: { type: "movie" } }));
|
||||
const popularTv = useQuery(orpc.discover.popular.queryOptions({ input: { type: "tv" } }));
|
||||
const movieGenres = useQuery(orpc.discover.genres.queryOptions({ input: { type: "movie" } }));
|
||||
const tvGenres = useQuery(orpc.discover.genres.queryOptions({ input: { type: "tv" } }));
|
||||
|
||||
const isRefreshing =
|
||||
trending.isRefetching || popularMovies.isRefetching || popularTv.isRefetching;
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.explore.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -51,20 +51,20 @@ export default function DashboardScreen() {
|
||||
"--color-status-completed",
|
||||
]) as [string, string, string, string];
|
||||
|
||||
const stats = useQuery(orpc.dashboard.stats.queryOptions());
|
||||
const stats = useQuery(orpc.tracking.stats.queryOptions());
|
||||
const movieHistory = useQuery(
|
||||
orpc.dashboard.watchHistory.queryOptions({
|
||||
orpc.tracking.history.queryOptions({
|
||||
input: { type: "movie", period: moviePeriod },
|
||||
}),
|
||||
);
|
||||
const episodeHistory = useQuery(
|
||||
orpc.dashboard.watchHistory.queryOptions({
|
||||
orpc.tracking.history.queryOptions({
|
||||
input: { type: "episode", period: episodePeriod },
|
||||
}),
|
||||
);
|
||||
const continueWatching = useQuery(orpc.dashboard.continueWatching.queryOptions());
|
||||
const continueWatching = useQuery(orpc.library.continueWatching.queryOptions());
|
||||
const library = useQuery(orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }));
|
||||
const recommendations = useQuery(orpc.dashboard.recommendations.queryOptions());
|
||||
const recommendations = useQuery(orpc.discover.recommendations.queryOptions());
|
||||
|
||||
const isRefreshing =
|
||||
stats.isRefetching ||
|
||||
@@ -74,7 +74,7 @@ export default function DashboardScreen() {
|
||||
episodeHistory.isRefetching;
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function UpcomingScreen() {
|
||||
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isRefetching } =
|
||||
useInfiniteQuery(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
orpc.library.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
limit: 20,
|
||||
@@ -83,7 +83,7 @@ export default function UpcomingScreen() {
|
||||
);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.upcoming.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.upcoming.key() });
|
||||
}, []);
|
||||
|
||||
const onEndReached = useCallback(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function SearchScreen() {
|
||||
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||
|
||||
const searchResults = useInfiniteQuery({
|
||||
...orpc.search.infiniteOptions({
|
||||
...orpc.discover.search.infiniteOptions({
|
||||
input:
|
||||
debouncedQuery.length > 0
|
||||
? (pageParam: number) => ({ query: debouncedQuery, page: pageParam })
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function SettingsScreen() {
|
||||
const isAdmin = session?.user?.role === "admin";
|
||||
const serverUrl = getServerUrl();
|
||||
|
||||
const authConfig = useQuery(orpc.system.authConfig.queryOptions());
|
||||
const publicInfo = useQuery(orpc.system.publicInfo.queryOptions());
|
||||
const { data: accounts } = useQuery({
|
||||
queryKey: ["auth", "listAccounts"],
|
||||
queryFn: async () => {
|
||||
@@ -100,7 +100,7 @@ export default function SettingsScreen() {
|
||||
});
|
||||
const hasPassword =
|
||||
accounts?.some((a: { providerId: string }) => a.providerId === "credential") ?? false;
|
||||
const showPasswordOption = hasPassword && !(authConfig.data?.passwordLoginDisabled ?? true);
|
||||
const showPasswordOption = hasPassword && !(publicInfo.data?.passwordLoginDisabled ?? true);
|
||||
|
||||
const systemHealth = useQuery({
|
||||
...orpc.admin.systemHealth.queryOptions(),
|
||||
@@ -162,40 +162,48 @@ export default function SettingsScreen() {
|
||||
|
||||
const hasAvatarImage = !!session?.user?.image;
|
||||
|
||||
const registration = useQuery({
|
||||
...orpc.admin.registration.queryOptions(),
|
||||
const adminSettings = useQuery({
|
||||
...orpc.admin.settings.get.queryOptions(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const toggleRegistration = useMutation(
|
||||
orpc.admin.toggleRegistration.mutationOptions({
|
||||
onSuccess: (_data, { open }) => {
|
||||
toast.success(open ? t`Registration opened` : t`Registration closed`);
|
||||
const updateAdminSettings = useMutation(
|
||||
orpc.admin.settings.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.admin.registration.key(),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t`Failed to update registration setting`),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateCheck = useQuery({
|
||||
...orpc.admin.updateCheck.queryOptions(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const toggleUpdateCheck = useMutation(
|
||||
orpc.admin.toggleUpdateCheck.mutationOptions({
|
||||
onSuccess: (_data, { enabled }) => {
|
||||
toast.success(enabled ? t`Update checks enabled` : t`Update checks disabled`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.admin.updateCheck.key(),
|
||||
queryKey: orpc.admin.settings.key(),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t`Failed to update setting`),
|
||||
}),
|
||||
);
|
||||
|
||||
const toggleRegistration = {
|
||||
mutate: ({ open }: { open: boolean }) => {
|
||||
updateAdminSettings.mutate(
|
||||
{ registration: { open } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(open ? t`Registration opened` : t`Registration closed`);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const toggleUpdateCheck = {
|
||||
mutate: ({ enabled }: { enabled: boolean }) => {
|
||||
updateAdminSettings.mutate(
|
||||
{ updateCheck: { enabled } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(enabled ? t`Update checks enabled` : t`Update checks disabled`);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const primaryFgColor = useCSSVariable("--color-primary-foreground") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
@@ -484,7 +492,7 @@ export default function SettingsScreen() {
|
||||
icon={IconUserPlus}
|
||||
right={
|
||||
<Switch
|
||||
value={registration.data?.open ?? false}
|
||||
value={adminSettings.data?.registration?.open ?? false}
|
||||
accessibilityLabel={t`Open registration`}
|
||||
onValueChange={(open) => toggleRegistration.mutate({ open })}
|
||||
/>
|
||||
@@ -495,15 +503,15 @@ export default function SettingsScreen() {
|
||||
icon={IconCloud}
|
||||
right={
|
||||
<Switch
|
||||
value={updateCheck.data?.enabled ?? false}
|
||||
value={adminSettings.data?.updateCheck?.enabled ?? false}
|
||||
accessibilityLabel={t`Check for updates`}
|
||||
onValueChange={(enabled) => toggleUpdateCheck.mutate({ enabled })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{(() => {
|
||||
const latestVersion = updateCheck.data?.updateCheck?.latestVersion;
|
||||
return updateCheck.data?.updateCheck?.updateAvailable ? (
|
||||
const latestVersion = adminSettings.data?.updateCheck?.latestVersion;
|
||||
return adminSettings.data?.updateCheck?.updateAvailable ? (
|
||||
<View className="py-3.5">
|
||||
<Text className="text-status-completed font-sans text-sm font-medium">
|
||||
<Trans>Update available: {latestVersion}</Trans>
|
||||
@@ -567,8 +575,8 @@ export default function SettingsScreen() {
|
||||
Native
|
||||
{Application.nativeApplicationVersion ? ` v${Application.nativeApplicationVersion}` : ""}
|
||||
{Application.nativeBuildVersion ? ` (${Application.nativeBuildVersion})` : ""}
|
||||
{updateCheck.data?.updateCheck?.currentVersion
|
||||
? ` · Server v${updateCheck.data.updateCheck.currentVersion}`
|
||||
{adminSettings.data?.updateCheck?.currentVersion
|
||||
? ` · Server v${adminSettings.data.updateCheck.currentVersion}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function PersonDetailScreen() {
|
||||
|
||||
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery(
|
||||
orpc.people.detail.infiniteOptions({
|
||||
orpc.people.get.infiniteOptions({
|
||||
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
|
||||
@@ -104,9 +104,9 @@ export default function TitleDetailScreen() {
|
||||
"--color-title-accent-foreground",
|
||||
]) as [string, string, string];
|
||||
|
||||
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
|
||||
const userInfo = useQuery(orpc.titles.userInfo.queryOptions({ input: { id } }));
|
||||
const recommendations = useQuery(orpc.titles.recommendations.queryOptions({ input: { id } }));
|
||||
const detail = useQuery(orpc.titles.get.queryOptions({ input: { id } }));
|
||||
const userInfo = useQuery(orpc.tracking.userInfo.queryOptions({ input: { id } }));
|
||||
const recommendations = useQuery(orpc.titles.similar.queryOptions({ input: { id } }));
|
||||
|
||||
const {
|
||||
updateStatus,
|
||||
@@ -425,7 +425,7 @@ export default function TitleDetailScreen() {
|
||||
|
||||
{title.type === "movie" && (
|
||||
<Pressable
|
||||
onPress={() => watchMovie.mutate({ id })}
|
||||
onPress={() => watchMovie.mutate({ scope: "movie", ids: [id] })}
|
||||
disabled={watchMovie.isPending}
|
||||
className="bg-title-accent flex-row items-center gap-1.5 rounded-lg px-4 py-2"
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function UpcomingSection() {
|
||||
const { t } = useLingui();
|
||||
const { push } = useRouter();
|
||||
const { data, isPending } = useQuery(
|
||||
orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
orpc.library.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
);
|
||||
|
||||
if (isPending) return null;
|
||||
|
||||
@@ -48,7 +48,7 @@ export function FilterableTitleRow({
|
||||
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||
|
||||
const discover = useInfiniteQuery({
|
||||
...orpc.discover.infiniteOptions({
|
||||
...orpc.discover.browse.infiniteOptions({
|
||||
input:
|
||||
selectedGenre != null
|
||||
? (pageParam: number) => ({
|
||||
|
||||
@@ -81,30 +81,30 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
||||
const toggleSetup = useCallback(() => setSetupOpen((v) => !v), []);
|
||||
|
||||
const connectMutation = useMutation(
|
||||
orpc.integrations.create.mutationOptions({
|
||||
orpc.account.integrations.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(t`${label} connected`);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.account.integrations.key() });
|
||||
},
|
||||
onError: () => toast.error(t`Failed to connect ${label}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const { mutate: deleteIntegration } = useMutation(
|
||||
orpc.integrations.delete.mutationOptions({
|
||||
orpc.account.integrations.delete.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(t`${label} disconnected`);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.account.integrations.key() });
|
||||
},
|
||||
onError: () => toast.error(t`Failed to disconnect ${label}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const { mutate: regenerateToken, isPending: isRegenerating } = useMutation(
|
||||
orpc.integrations.regenerateToken.mutationOptions({
|
||||
orpc.account.integrations.regenerateToken.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(t`${label} URL regenerated`);
|
||||
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.account.integrations.key() });
|
||||
},
|
||||
onError: () => toast.error(t`Failed to regenerate ${label} URL`),
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { orpc } from "@/lib/orpc";
|
||||
export function IntegrationsSection() {
|
||||
const { t, i18n } = useLingui();
|
||||
const configs = getIntegrationConfigs(i18n);
|
||||
const integrations = useQuery(orpc.integrations.list.queryOptions());
|
||||
const integrations = useQuery(orpc.account.integrations.list.queryOptions());
|
||||
|
||||
return (
|
||||
<View className="mb-6">
|
||||
|
||||
@@ -76,13 +76,15 @@ export function SeasonAccordion({
|
||||
|
||||
const { watchEpisode, unwatchEpisode, watchSeason } = useTitleActions({
|
||||
toasts: {
|
||||
watchEpisode: ({ id: epId }) => {
|
||||
watchEpisode: ({ ids }) => {
|
||||
const epId = ids[0];
|
||||
const ep = episodes.find((e) => e.id === epId);
|
||||
const sNum = season.seasonNumber;
|
||||
const eNum = ep?.episodeNumber;
|
||||
return ep ? t`Watched S${sNum} E${eNum}` : t`Episode watched`;
|
||||
},
|
||||
unwatchEpisode: ({ id: epId }) => {
|
||||
unwatchEpisode: ({ ids }) => {
|
||||
const epId = ids[0];
|
||||
const ep = episodes.find((e) => e.id === epId);
|
||||
const sNum = season.seasonNumber;
|
||||
const eNum = ep?.episodeNumber;
|
||||
@@ -99,9 +101,9 @@ export function SeasonAccordion({
|
||||
const handleEpisodeToggle = useCallback(
|
||||
(episodeId: string) => {
|
||||
if (watchedEpisodeIds.has(episodeId)) {
|
||||
unwatchEpisode.mutate({ id: episodeId });
|
||||
unwatchEpisode.mutate({ scope: "episode", ids: [episodeId] });
|
||||
} else {
|
||||
watchEpisode.mutate({ id: episodeId });
|
||||
watchEpisode.mutate({ scope: "episode", ids: [episodeId] });
|
||||
}
|
||||
},
|
||||
[watchedEpisodeIds, unwatchEpisode, watchEpisode],
|
||||
@@ -156,7 +158,7 @@ export function SeasonAccordion({
|
||||
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
|
||||
{watchedCount < episodes.length && (
|
||||
<Pressable
|
||||
onPress={() => watchSeason.mutate({ id: season.id })}
|
||||
onPress={() => watchSeason.mutate({ scope: "season", ids: [season.id] })}
|
||||
className="bg-secondary mx-4 mb-2 flex-row items-center justify-center rounded-lg py-2"
|
||||
>
|
||||
<Text className="text-title-accent font-sans text-xs font-medium">
|
||||
|
||||
@@ -18,15 +18,20 @@ function resolveToast<TInput>(
|
||||
return typeof override === "function" ? override(input) : override;
|
||||
}
|
||||
|
||||
interface WatchInput {
|
||||
scope: string;
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
interface UseTitleActionsOptions {
|
||||
toasts?: {
|
||||
quickAdd?: ToastOverride<{ id: string }>;
|
||||
updateStatus?: ToastOverride<{ id: string; status: string | null }>;
|
||||
watchMovie?: ToastOverride<{ id: string }>;
|
||||
watchMovie?: ToastOverride<WatchInput>;
|
||||
updateRating?: ToastOverride<{ id: string; stars: number }>;
|
||||
watchEpisode?: ToastOverride<{ id: string }>;
|
||||
unwatchEpisode?: ToastOverride<{ id: string }>;
|
||||
watchSeason?: ToastOverride<{ id: string }>;
|
||||
watchEpisode?: ToastOverride<WatchInput>;
|
||||
unwatchEpisode?: ToastOverride<WatchInput>;
|
||||
watchSeason?: ToastOverride<WatchInput>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +44,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
const toastOverrides = options?.toasts;
|
||||
|
||||
const quickAdd = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
orpc.tracking.quickAdd.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input));
|
||||
invalidateTitleQueries();
|
||||
@@ -53,7 +58,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
);
|
||||
|
||||
const updateStatus = useMutation(
|
||||
orpc.titles.updateStatus.mutationOptions({
|
||||
orpc.tracking.updateStatus.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
const statusMessages: Record<string, string> = {
|
||||
watchlist: t`Added to watchlist`,
|
||||
@@ -69,7 +74,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
);
|
||||
|
||||
const watchMovie = useMutation(
|
||||
orpc.titles.watchMovie.mutationOptions({
|
||||
orpc.tracking.watch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input));
|
||||
invalidateTitleQueries();
|
||||
@@ -79,7 +84,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
);
|
||||
|
||||
const updateRating = useMutation(
|
||||
orpc.titles.updateRating.mutationOptions({
|
||||
orpc.tracking.rate.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
const stars = input.stars;
|
||||
const defaultMsg =
|
||||
@@ -87,7 +92,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
? t`Rated ${plural(stars, { one: "# star", other: "# stars" })}`
|
||||
: t`Rating removed`;
|
||||
toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input));
|
||||
// Rating only invalidates title queries, not dashboard
|
||||
// Rating only invalidates title queries, not tracking
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
},
|
||||
onError: () => toast.error(t`Failed to update rating`),
|
||||
@@ -95,7 +100,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
);
|
||||
|
||||
const watchEpisode = useMutation(
|
||||
orpc.episodes.watch.mutationOptions({
|
||||
orpc.tracking.watch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input));
|
||||
invalidateTitleQueries();
|
||||
@@ -105,7 +110,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
);
|
||||
|
||||
const unwatchEpisode = useMutation(
|
||||
orpc.episodes.unwatch.mutationOptions({
|
||||
orpc.tracking.unwatch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.unwatchEpisode, t`Episode unwatched`, input));
|
||||
invalidateTitleQueries();
|
||||
@@ -115,7 +120,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
);
|
||||
|
||||
const watchSeason = useMutation(
|
||||
orpc.seasons.watch.mutationOptions({
|
||||
orpc.tracking.watch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.watchSeason, t`Season watched`, input));
|
||||
invalidateTitleQueries();
|
||||
|
||||
@@ -8,10 +8,11 @@ import { i18n } from "@sofa/i18n";
|
||||
|
||||
let widgetRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Invalidate title + dashboard queries. Used by most title mutations. */
|
||||
/** Invalidate title + tracking + library queries. Used by most title mutations. */
|
||||
export function invalidateTitleQueries() {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||
|
||||
// Debounce widget refresh to batch rapid mutations (e.g. watching multiple episodes)
|
||||
if (widgetRefreshTimer) clearTimeout(widgetRefreshTimer);
|
||||
@@ -28,7 +29,7 @@ export function invalidateTitleQueries() {
|
||||
export const titleActions = {
|
||||
async quickAdd(id: string, titleName?: string) {
|
||||
try {
|
||||
await client.titles.quickAdd({ id });
|
||||
await client.tracking.quickAdd({ id });
|
||||
toast.success(
|
||||
titleName
|
||||
? i18n._(msg`Added "${titleName}" to watchlist`)
|
||||
@@ -44,7 +45,7 @@ export const titleActions = {
|
||||
|
||||
async markMovieWatched(id: string, titleName?: string) {
|
||||
try {
|
||||
await client.titles.watchMovie({ id });
|
||||
await client.tracking.watch({ scope: "movie", ids: [id] });
|
||||
toast.success(
|
||||
titleName ? i18n._(msg`Marked "${titleName}" as watched`) : i18n._(msg`Marked as watched`),
|
||||
);
|
||||
@@ -56,7 +57,7 @@ export const titleActions = {
|
||||
|
||||
async removeFromLibrary(id: string) {
|
||||
try {
|
||||
await client.titles.updateStatus({ id, status: null });
|
||||
await client.tracking.updateStatus({ id, status: null });
|
||||
toast.success(i18n._(msg`Removed from library`));
|
||||
invalidateTitleQueries();
|
||||
} catch {
|
||||
@@ -66,7 +67,7 @@ export const titleActions = {
|
||||
|
||||
async rate(id: string, stars: number) {
|
||||
try {
|
||||
await client.titles.updateRating({ id, stars });
|
||||
await client.tracking.rate({ id, stars });
|
||||
toast.success(
|
||||
stars > 0
|
||||
? i18n._(msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`)
|
||||
@@ -80,7 +81,7 @@ export const titleActions = {
|
||||
|
||||
async watchEpisode(id: string) {
|
||||
try {
|
||||
await client.episodes.watch({ id });
|
||||
await client.tracking.watch({ scope: "episode", ids: [id] });
|
||||
toast.success(i18n._(msg`Episode watched`));
|
||||
invalidateTitleQueries();
|
||||
} catch {
|
||||
@@ -90,7 +91,7 @@ export const titleActions = {
|
||||
|
||||
async unwatchEpisode(id: string) {
|
||||
try {
|
||||
await client.episodes.unwatch({ id });
|
||||
await client.tracking.unwatch({ scope: "episode", ids: [id] });
|
||||
toast.success(i18n._(msg`Episode unwatched`));
|
||||
invalidateTitleQueries();
|
||||
} catch {
|
||||
@@ -100,7 +101,7 @@ export const titleActions = {
|
||||
|
||||
async markAllWatched(id: string, titleName?: string) {
|
||||
try {
|
||||
await client.titles.watchAll({ id });
|
||||
await client.tracking.watch({ scope: "series", ids: [id] });
|
||||
toast.success(
|
||||
titleName
|
||||
? i18n._(msg`Marked all episodes of "${titleName}" as watched`)
|
||||
@@ -114,7 +115,7 @@ export const titleActions = {
|
||||
|
||||
async watchSeason(id: string, seasonLabel?: string) {
|
||||
try {
|
||||
await client.seasons.watch({ id });
|
||||
await client.tracking.watch({ scope: "season", ids: [id] });
|
||||
toast.success(
|
||||
seasonLabel ? i18n._(msg`Watched all of ${seasonLabel}`) : i18n._(msg`Season watched`),
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("@/lib/widget-assets", () => ({
|
||||
|
||||
vi.mock("@/lib/orpc", () => ({
|
||||
client: {
|
||||
dashboard: {
|
||||
library: {
|
||||
continueWatching,
|
||||
upcoming,
|
||||
},
|
||||
|
||||
@@ -140,7 +140,7 @@ async function refreshContinueWatching(
|
||||
iconFilePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { items } = await client.dashboard.continueWatching();
|
||||
const { items } = await client.library.continueWatching();
|
||||
|
||||
if (items.length === 0) {
|
||||
widget.updateSnapshot(
|
||||
@@ -200,7 +200,7 @@ async function refreshUpcoming(
|
||||
iconFilePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { items } = await client.dashboard.upcoming({
|
||||
const { items } = await client.library.upcoming({
|
||||
days: 30,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
@@ -21,21 +21,15 @@ import { implementedRouter } from "./router";
|
||||
|
||||
export const schemaConverters = [new ZodToJsonSchemaConverter()];
|
||||
export const openApiTags = [
|
||||
{ name: "Titles", description: "Movie and TV show management" },
|
||||
{ name: "Episodes", description: "Episode watch tracking" },
|
||||
{ name: "Seasons", description: "Season watch tracking" },
|
||||
{ name: "Titles", description: "Movie and TV show metadata" },
|
||||
{ name: "Tracking", description: "Watch tracking, ratings, and status management" },
|
||||
{ name: "Library", description: "User library browsing and feeds" },
|
||||
{ name: "Discover", description: "Search, trending, and content discovery" },
|
||||
{ name: "People", description: "Cast and crew information" },
|
||||
{ name: "Dashboard", description: "User dashboard data" },
|
||||
{ name: "Explore", description: "Discover trending and popular content" },
|
||||
{ name: "Search", description: "Search for movies and TV shows" },
|
||||
{
|
||||
name: "Discover",
|
||||
description: "Advanced content discovery with filters",
|
||||
},
|
||||
{ name: "Account", description: "User account and integrations" },
|
||||
{ name: "System", description: "Server status and configuration" },
|
||||
{ name: "Integrations", description: "Media server integrations" },
|
||||
{ name: "Admin", description: "Server administration" },
|
||||
{ name: "Account", description: "User account management" },
|
||||
{ name: "Imports", description: "Data import from external services" },
|
||||
] as const;
|
||||
|
||||
const generator = new OpenAPIGenerator({
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { auth } from "@sofa/auth/server";
|
||||
import { AVATAR_DIR } from "@sofa/config";
|
||||
import {
|
||||
createOrUpdateIntegration,
|
||||
deleteIntegration as coreDeleteIntegration,
|
||||
listUserIntegrations,
|
||||
regenerateToken as coreRegenerateToken,
|
||||
serializeIntegration,
|
||||
} from "@sofa/core/integrations";
|
||||
import { getUserPlatformIdList, updateUserPlatforms } from "@sofa/core/platforms";
|
||||
|
||||
import { os } from "../context";
|
||||
@@ -27,7 +37,6 @@ export const uploadAvatar = os.account.uploadAvatar
|
||||
.handler(async ({ input: file, context }) => {
|
||||
await mkdir(AVATAR_DIR, { recursive: true });
|
||||
|
||||
// Write new avatar first (atomic: temp file + rename)
|
||||
const ext = MIME_TO_EXT[file.type] || "jpg";
|
||||
const filename = `${context.user.id}.${ext}`;
|
||||
const filePath = path.join(AVATAR_DIR, filename);
|
||||
@@ -35,7 +44,6 @@ export const uploadAvatar = os.account.uploadAvatar
|
||||
await Bun.write(tmpPath, file);
|
||||
await rename(tmpPath, filePath);
|
||||
|
||||
// Remove any previous avatar with a different extension
|
||||
const glob = new Bun.Glob(`${context.user.id}.*`);
|
||||
const existing = await Array.fromAsync(glob.scan(AVATAR_DIR));
|
||||
for (const match of existing) {
|
||||
@@ -44,7 +52,6 @@ export const uploadAvatar = os.account.uploadAvatar
|
||||
}
|
||||
}
|
||||
|
||||
// Update user via Better Auth
|
||||
const imageUrl = `/api/avatars/${context.user.id}?v=${Date.now()}`;
|
||||
await auth.api.updateUser({
|
||||
body: { image: imageUrl },
|
||||
@@ -75,3 +82,36 @@ export const updatePlatformsHandler = os.account.updatePlatforms
|
||||
.handler(async ({ input, context }) => {
|
||||
updateUserPlatforms(context.user.id, input.platformIds);
|
||||
});
|
||||
|
||||
// ─── Integrations ─────────────────────────────────────────────
|
||||
|
||||
export const integrationsList = os.account.integrations.list.use(authed).handler(({ context }) => {
|
||||
return listUserIntegrations(context.user.id);
|
||||
});
|
||||
|
||||
export const integrationsCreate = os.account.integrations.create
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
return createOrUpdateIntegration(context.user.id, input.provider, input.enabled);
|
||||
});
|
||||
|
||||
export const integrationsDelete = os.account.integrations.delete
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
coreDeleteIntegration(context.user.id, input.provider);
|
||||
});
|
||||
|
||||
export const integrationsRegenerateToken = os.account.integrations.regenerateToken
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const row = coreRegenerateToken(context.user.id, input.provider);
|
||||
|
||||
if (!row) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Integration not found",
|
||||
data: { code: AppErrorCode.INTEGRATION_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
|
||||
return serializeIntegration(row);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,44 @@ import { pauseJobs, rescheduleBackup, resumeJobs, triggerJob as triggerCronJob }
|
||||
import { os } from "../context";
|
||||
import { admin } from "../middleware";
|
||||
|
||||
// ─── Backups ───────────────────────────────────────────────────
|
||||
// ─── Settings (consolidated) ──────────────────────────────────
|
||||
|
||||
export const settingsGet = os.admin.settings.get.use(admin).handler(() => {
|
||||
const updateCheckEnabled = isUpdateCheckEnabled();
|
||||
const check = updateCheckEnabled ? getCachedUpdateCheck() : null;
|
||||
|
||||
return {
|
||||
registration: {
|
||||
open: getSetting("registrationOpen") === "true",
|
||||
},
|
||||
updateCheck: {
|
||||
enabled: updateCheckEnabled,
|
||||
updateAvailable: check?.updateAvailable ?? null,
|
||||
currentVersion: check?.currentVersion ?? null,
|
||||
latestVersion: check?.latestVersion ?? null,
|
||||
releaseUrl: check?.releaseUrl ?? null,
|
||||
lastCheckedAt: check?.lastCheckedAt ?? null,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: isTelemetryEnabled(),
|
||||
lastReportedAt: getSetting("telemetryLastReportedAt") ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const settingsUpdate = os.admin.settings.update.use(admin).handler(({ input }) => {
|
||||
if (input.registration) {
|
||||
setSetting("registrationOpen", String(input.registration.open));
|
||||
}
|
||||
if (input.updateCheck) {
|
||||
setSetting("updateCheckEnabled", String(input.updateCheck.enabled));
|
||||
}
|
||||
if (input.telemetry) {
|
||||
setSetting("telemetryEnabled", String(input.telemetry.enabled));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Backups ──────────────────────────────────────────────────
|
||||
|
||||
export const backupsList = os.admin.backups.list.use(admin).handler(async () => {
|
||||
const backups = await listBackups();
|
||||
@@ -42,7 +79,6 @@ export const backupsDelete = os.admin.backups.delete.use(admin).handler(async ({
|
||||
export const backupsRestore = os.admin.backups.restore
|
||||
.use(admin)
|
||||
.handler(async ({ input: file }) => {
|
||||
// Stream upload to disk to avoid buffering the entire file in memory
|
||||
await ensureBackupDir();
|
||||
const tmpPath = path.join(BACKUP_DIR, `.upload-${Date.now()}-${crypto.randomUUID()}.db`);
|
||||
pauseJobs();
|
||||
@@ -50,7 +86,6 @@ export const backupsRestore = os.admin.backups.restore
|
||||
await Bun.write(tmpPath, file);
|
||||
await restoreFromBackup(tmpPath);
|
||||
} catch (err) {
|
||||
// Clean up the upload file if restoreFromBackup didn't consume it
|
||||
const f = Bun.file(tmpPath);
|
||||
if (await f.exists()) await f.delete();
|
||||
if (err instanceof ORPCError) throw err;
|
||||
@@ -89,42 +124,7 @@ export const backupsUpdateSchedule = os.admin.backups.updateSchedule
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Registration ──────────────────────────────────────────────
|
||||
|
||||
export const registration = os.admin.registration.use(admin).handler(() => {
|
||||
return { open: getSetting("registrationOpen") === "true" };
|
||||
});
|
||||
|
||||
export const toggleRegistration = os.admin.toggleRegistration.use(admin).handler(({ input }) => {
|
||||
setSetting("registrationOpen", String(input.open));
|
||||
});
|
||||
|
||||
// ─── Update Check ──────────────────────────────────────────────
|
||||
|
||||
export const updateCheck = os.admin.updateCheck.use(admin).handler(() => {
|
||||
const enabled = isUpdateCheckEnabled();
|
||||
const check = enabled ? getCachedUpdateCheck() : null;
|
||||
return { enabled, updateCheck: check };
|
||||
});
|
||||
|
||||
export const toggleUpdateCheck = os.admin.toggleUpdateCheck.use(admin).handler(({ input }) => {
|
||||
setSetting("updateCheckEnabled", String(input.enabled));
|
||||
});
|
||||
|
||||
// ─── Telemetry ────────────────────────────────────────────────
|
||||
|
||||
export const telemetry = os.admin.telemetry.use(admin).handler(() => {
|
||||
return {
|
||||
enabled: isTelemetryEnabled(),
|
||||
lastReportedAt: getSetting("telemetryLastReportedAt"),
|
||||
};
|
||||
});
|
||||
|
||||
export const toggleTelemetry = os.admin.toggleTelemetry.use(admin).handler(({ input }) => {
|
||||
setSetting("telemetryEnabled", String(input.enabled));
|
||||
});
|
||||
|
||||
// ─── Jobs ──────────────────────────────────────────────────────
|
||||
// ─── Jobs ─────────────────────────────────────────────────────
|
||||
|
||||
export const triggerJob = os.admin.triggerJob.use(admin).handler(async ({ input }) => {
|
||||
const triggered = await triggerCronJob(input.name);
|
||||
@@ -147,7 +147,7 @@ export const purgeImageCache = os.admin.purgeImageCache
|
||||
.use(admin)
|
||||
.handler(async () => purgeImagesFn());
|
||||
|
||||
// ─── System Health ───────────────────────────────────────────────
|
||||
// ─── System Health ────────────────────────────────────────────
|
||||
|
||||
export const systemHealth = os.admin.systemHealth.use(admin).handler(async () => {
|
||||
return await getSystemHealth();
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import {
|
||||
getContinueWatchingFeed,
|
||||
getRecommendationsFeed,
|
||||
getUpcomingFeed,
|
||||
getUserStats,
|
||||
getWatchCount,
|
||||
getWatchHistory,
|
||||
} from "@sofa/core/discovery";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const;
|
||||
|
||||
export const stats = os.dashboard.stats.use(authed).handler(({ context }) => {
|
||||
return getUserStats(context.user.id);
|
||||
});
|
||||
|
||||
export const continueWatching = os.dashboard.continueWatching.use(authed).handler(({ context }) => {
|
||||
const feed = getContinueWatchingFeed(context.user.id);
|
||||
const items = feed.map((item) => ({
|
||||
title: {
|
||||
id: item.title.id,
|
||||
title: item.title.title,
|
||||
backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"),
|
||||
backdropThumbHash: item.title.backdropThumbHash,
|
||||
},
|
||||
nextEpisode: item.nextEpisode
|
||||
? {
|
||||
seasonNumber: item.nextEpisode.seasonNumber,
|
||||
episodeNumber: item.nextEpisode.episodeNumber,
|
||||
name: item.nextEpisode.name,
|
||||
stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"),
|
||||
stillThumbHash: item.nextEpisode.stillThumbHash,
|
||||
}
|
||||
: null,
|
||||
totalEpisodes: item.totalEpisodes,
|
||||
watchedEpisodes: item.watchedEpisodes,
|
||||
}));
|
||||
return { items };
|
||||
});
|
||||
|
||||
export const recommendations = os.dashboard.recommendations.use(authed).handler(({ context }) => {
|
||||
const feed = getRecommendationsFeed(context.user.id);
|
||||
const items = feed
|
||||
.filter((t): t is NonNullable<typeof t> => t != null)
|
||||
.slice(0, 10)
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
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,
|
||||
}));
|
||||
return { items };
|
||||
});
|
||||
|
||||
export const upcoming = os.dashboard.upcoming.use(authed).handler(({ input, context }) => {
|
||||
const result = getUpcomingFeed(context.user.id, {
|
||||
days: input.days,
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
mediaType: input.mediaType,
|
||||
statusFilter: input.statusFilter,
|
||||
});
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
...item,
|
||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
||||
backdropPath: tmdbImageUrl(item.backdropPath, "backdrops"),
|
||||
streamingProvider: item.streamingProvider
|
||||
? {
|
||||
...item.streamingProvider,
|
||||
logoPath: tmdbImageUrl(item.streamingProvider.logoPath, "logos"),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
});
|
||||
|
||||
export const watchHistory = os.dashboard.watchHistory.use(authed).handler(({ input, context }) => {
|
||||
const coreType = watchHistoryTypeMap[input.type];
|
||||
const count = getWatchCount(context.user.id, coreType, input.period);
|
||||
const history = getWatchHistory(context.user.id, coreType, input.period);
|
||||
return { count, history };
|
||||
});
|
||||
@@ -2,23 +2,320 @@ import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { WATCH_REGION } from "@sofa/config";
|
||||
import { getRecommendationsFeed } from "@sofa/core/discovery";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { getPlatformTmdbIds } from "@sofa/core/platforms";
|
||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { discover as discoverTmdb } from "@sofa/tmdb/client";
|
||||
import { ensureBrowsePersonsExist } from "@sofa/core/person";
|
||||
import { getPlatformTmdbIdMap, getPlatformTmdbIds, listPlatforms } from "@sofa/core/platforms";
|
||||
import { getDisplayStatusesByTitleIds, getEpisodeProgressByTitleIds } from "@sofa/core/tracking";
|
||||
import {
|
||||
discover as discoverTmdb,
|
||||
getGenres,
|
||||
getPopular,
|
||||
getTrending,
|
||||
searchMovies,
|
||||
searchMulti,
|
||||
searchPerson,
|
||||
searchTv,
|
||||
} from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const discover = os.discover.use(authed).handler(async ({ input, context }) => {
|
||||
function requireTmdb() {
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured",
|
||||
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trending ─────────────────────────────────────────────────
|
||||
|
||||
export const trending = os.discover.trending.use(authed).handler(async ({ input, context }) => {
|
||||
requireTmdb();
|
||||
|
||||
const data = await getTrending(input.type, "day", input.page);
|
||||
const results = (data.results ?? []) as Record<string, unknown>[];
|
||||
|
||||
const baseItems = results
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => {
|
||||
const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : "movie";
|
||||
return {
|
||||
tmdbId: r.id as number,
|
||||
type: mediaType as "movie" | "tv",
|
||||
title: ((r.title ?? r.name) as string) || "",
|
||||
posterPath: (r.poster_path as string) ?? null,
|
||||
releaseDate: (r.release_date as string | undefined) ?? null,
|
||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||
voteAverage: (r.vote_average as number | undefined) ?? null,
|
||||
};
|
||||
});
|
||||
const heroResult = results.find(
|
||||
(r) => r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
|
||||
);
|
||||
|
||||
const allBrowseItems = [
|
||||
...baseItems,
|
||||
...(heroResult
|
||||
? [
|
||||
{
|
||||
tmdbId: heroResult.id as number,
|
||||
type: heroResult.media_type as "movie" | "tv",
|
||||
title: ((heroResult.title ?? heroResult.name) as string | undefined) ?? "",
|
||||
posterPath: (heroResult.poster_path as string) ?? null,
|
||||
releaseDate: (heroResult.release_date as string | undefined) ?? null,
|
||||
firstAirDate: (heroResult.first_air_date as string | undefined) ?? null,
|
||||
voteAverage: (heroResult.vote_average as number | undefined) ?? null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const titleMap = ensureBrowseTitlesExist(allBrowseItems);
|
||||
|
||||
const items = baseItems.map((item) => {
|
||||
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
|
||||
return Object.assign(item, {
|
||||
id: entry?.id ?? "",
|
||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
||||
posterThumbHash: entry?.posterThumbHash ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const heroEntry = heroResult
|
||||
? titleMap.get(`${heroResult.id as number}-${heroResult.media_type as string}`)
|
||||
: undefined;
|
||||
const hero = heroResult
|
||||
? {
|
||||
id: heroEntry?.id ?? "",
|
||||
tmdbId: heroResult.id as number,
|
||||
type: heroResult.media_type as "movie" | "tv",
|
||||
title: ((heroResult.title ?? heroResult.name) as string | undefined) ?? "",
|
||||
overview: (heroResult.overview as string | undefined) ?? "",
|
||||
backdropPath: tmdbImageUrl((heroResult.backdrop_path as string) ?? null, "backdrops"),
|
||||
voteAverage: heroResult.vote_average as number,
|
||||
}
|
||||
: null;
|
||||
|
||||
const titleIds = items.map((r) => r.id);
|
||||
const [userStatuses, episodeProgress] =
|
||||
titleIds.length > 0
|
||||
? [
|
||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
return {
|
||||
items,
|
||||
hero,
|
||||
userStatuses,
|
||||
episodeProgress,
|
||||
page: (data as { page?: number }).page ?? input.page,
|
||||
totalPages: (data as { total_pages?: number }).total_pages ?? 1,
|
||||
totalResults: (data as { total_results?: number }).total_results ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Popular ──────────────────────────────────────────────────
|
||||
|
||||
export const popular = os.discover.popular.use(authed).handler(async ({ input, context }) => {
|
||||
requireTmdb();
|
||||
|
||||
const data = await getPopular(input.type, input.page);
|
||||
const baseItems = ((data.results ?? []) as Record<string, unknown>[])
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => ({
|
||||
tmdbId: r.id as number,
|
||||
type: input.type,
|
||||
title: ((r.title ?? r.name) as string) || "",
|
||||
posterPath: (r.poster_path as string) ?? null,
|
||||
releaseDate: (r.release_date as string | undefined) ?? null,
|
||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||
voteAverage: (r.vote_average as number | undefined) ?? null,
|
||||
}));
|
||||
|
||||
const titleMap = ensureBrowseTitlesExist(baseItems);
|
||||
const items = baseItems.map((item) => {
|
||||
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
|
||||
return Object.assign(item, {
|
||||
id: entry?.id ?? "",
|
||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
||||
posterThumbHash: entry?.posterThumbHash ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const titleIds = items.map((r) => r.id);
|
||||
const [userStatuses, episodeProgress] =
|
||||
titleIds.length > 0
|
||||
? [
|
||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
return {
|
||||
items,
|
||||
userStatuses,
|
||||
episodeProgress,
|
||||
page: data.page ?? input.page,
|
||||
totalPages: data.total_pages ?? 1,
|
||||
totalResults: data.total_results ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────
|
||||
|
||||
export const search = os.discover.search.use(authed).handler(async ({ input }) => {
|
||||
requireTmdb();
|
||||
|
||||
const query = input.query.trim();
|
||||
if (!query) {
|
||||
return { results: [], page: 1, totalPages: 0, totalResults: 0 };
|
||||
}
|
||||
const type = input.type ?? null;
|
||||
|
||||
if (type === "person") {
|
||||
const personResults = await searchPerson(query, input.page);
|
||||
const personItems = (personResults.results ?? []).map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: "person" as const,
|
||||
title: r.name ?? "",
|
||||
posterPath: null,
|
||||
profilePath: r.profile_path ?? null,
|
||||
overview: null,
|
||||
releaseDate: null,
|
||||
popularity: r.popularity ?? null,
|
||||
voteAverage: null,
|
||||
knownForDepartment: r.known_for_department ?? null,
|
||||
knownFor:
|
||||
(r.known_for
|
||||
?.slice(0, 3)
|
||||
.map((k) => k.title ?? (k as { name?: string }).name)
|
||||
.filter((s): s is string => !!s) as string[]) ?? null,
|
||||
}));
|
||||
const personMap = ensureBrowsePersonsExist(
|
||||
personItems.map((r) => ({
|
||||
tmdbId: r.tmdbId,
|
||||
name: r.title,
|
||||
profilePath: r.profilePath,
|
||||
knownForDepartment: r.knownForDepartment,
|
||||
popularity: r.popularity,
|
||||
})),
|
||||
);
|
||||
return {
|
||||
results: personItems.map((r) =>
|
||||
Object.assign(r, {
|
||||
id: personMap.get(r.tmdbId),
|
||||
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
|
||||
}),
|
||||
),
|
||||
page: personResults.page ?? input.page,
|
||||
totalPages: personResults.total_pages ?? 1,
|
||||
totalResults: personResults.total_results ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const raw =
|
||||
type === "movie"
|
||||
? await searchMovies(query, input.page)
|
||||
: type === "tv"
|
||||
? await searchTv(query, input.page)
|
||||
: await searchMulti(query, input.page);
|
||||
|
||||
type SearchResult = {
|
||||
id: number;
|
||||
media_type?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
overview?: string;
|
||||
poster_path?: string | null;
|
||||
profile_path?: string | null;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
popularity?: number;
|
||||
vote_average?: number;
|
||||
};
|
||||
|
||||
const mapped = ((raw.results ?? []) as SearchResult[])
|
||||
.map((r) => {
|
||||
if (r.media_type === "person") {
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: "person" as const,
|
||||
title: r.name ?? "Unknown",
|
||||
posterPath: null,
|
||||
profilePath: r.profile_path ?? null,
|
||||
overview: null,
|
||||
releaseDate: null,
|
||||
popularity: r.popularity ?? null,
|
||||
voteAverage: null,
|
||||
knownForDepartment: null,
|
||||
knownFor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type;
|
||||
if (!mediaType) return null;
|
||||
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: mediaType,
|
||||
title: r.title ?? r.name ?? "",
|
||||
overview: r.overview ?? null,
|
||||
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
||||
posterPath: r.poster_path ?? null,
|
||||
profilePath: null,
|
||||
popularity: r.popularity ?? null,
|
||||
voteAverage: r.vote_average ?? null,
|
||||
knownForDepartment: null,
|
||||
knownFor: null,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
const titleResults = mapped.filter(
|
||||
(r): r is typeof r & { type: "movie" | "tv" } => r.type !== "person",
|
||||
);
|
||||
const titleMap = ensureBrowseTitlesExist(titleResults);
|
||||
|
||||
const personResults = mapped.filter((r) => r.type === "person");
|
||||
const personMap = ensureBrowsePersonsExist(
|
||||
personResults.map((r) => ({
|
||||
tmdbId: r.tmdbId,
|
||||
name: r.title,
|
||||
profilePath: r.profilePath,
|
||||
knownForDepartment: r.knownForDepartment,
|
||||
popularity: r.popularity,
|
||||
})),
|
||||
);
|
||||
|
||||
const results = mapped.map((r) => {
|
||||
if (r.type === "person") {
|
||||
return Object.assign(r, {
|
||||
id: personMap.get(r.tmdbId),
|
||||
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
|
||||
});
|
||||
}
|
||||
const entry = titleMap.get(`${r.tmdbId}-${r.type}`);
|
||||
return Object.assign(r, { id: entry?.id, posterPath: tmdbImageUrl(r.posterPath, "posters") });
|
||||
});
|
||||
|
||||
return {
|
||||
results,
|
||||
page: raw.page ?? input.page,
|
||||
totalPages: raw.total_pages ?? 1,
|
||||
totalResults: raw.total_results ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Browse (filtered discovery) ──────────────────────────────
|
||||
|
||||
export const browse = os.discover.browse.use(authed).handler(async ({ input, context }) => {
|
||||
requireTmdb();
|
||||
|
||||
const params: Record<string, string> = {
|
||||
sort_by: input.sortBy ?? "popularity.desc",
|
||||
@@ -92,3 +389,53 @@ export const discover = os.discover.use(authed).handler(async ({ input, context
|
||||
totalResults: results.total_results ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Genres ───────────────────────────────────────────────────
|
||||
|
||||
export const genres = os.discover.genres.use(authed).handler(async ({ input }) => {
|
||||
requireTmdb();
|
||||
const data = await getGenres(input.type);
|
||||
return {
|
||||
genres: (data.genres ?? []).map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name ?? "",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Platforms ────────────────────────────────────────────────
|
||||
|
||||
export const platforms = os.discover.platforms.use(authed).handler(async () => {
|
||||
const allPlatforms = listPlatforms();
|
||||
const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id));
|
||||
return {
|
||||
platforms: allPlatforms.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [],
|
||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||
isSubscription: p.isSubscription,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Recommendations ──────────────────────────────────────────
|
||||
|
||||
export const recommendations = os.discover.recommendations.use(authed).handler(({ context }) => {
|
||||
const feed = getRecommendationsFeed(context.user.id);
|
||||
const items = feed
|
||||
.filter((t): t is NonNullable<typeof t> => t != null)
|
||||
.slice(0, 10)
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
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,
|
||||
}));
|
||||
return { items };
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { logEpisodeWatch, logEpisodeWatchBatch, unwatchEpisode } from "@sofa/core/tracking";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const watch = os.episodes.watch.use(authed).handler(({ input, context }) => {
|
||||
logEpisodeWatch(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const unwatch = os.episodes.unwatch.use(authed).handler(({ input, context }) => {
|
||||
unwatchEpisode(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const batchWatch = os.episodes.batchWatch.use(authed).handler(({ input, context }) => {
|
||||
logEpisodeWatchBatch(context.user.id, input.episodeIds);
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { getPlatformTmdbIdMap, listPlatforms } from "@sofa/core/platforms";
|
||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
function requireTmdb() {
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured",
|
||||
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const trending = os.explore.trending.use(authed).handler(async ({ input, context }) => {
|
||||
requireTmdb();
|
||||
|
||||
const data = await getTrending(input.type, "day", input.page);
|
||||
const results = (data.results ?? []) as Record<string, unknown>[];
|
||||
|
||||
const baseItems = results
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => {
|
||||
const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : "movie";
|
||||
return {
|
||||
tmdbId: r.id as number,
|
||||
type: mediaType as "movie" | "tv",
|
||||
title: ((r.title ?? r.name) as string) || "",
|
||||
posterPath: (r.poster_path as string) ?? null,
|
||||
releaseDate: (r.release_date as string | undefined) ?? null,
|
||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||
voteAverage: (r.vote_average as number | undefined) ?? null,
|
||||
};
|
||||
});
|
||||
const heroResult = results.find(
|
||||
(r) => r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
|
||||
);
|
||||
|
||||
// Batch-upsert all browse items (+ hero) into the titles table
|
||||
const allBrowseItems = [
|
||||
...baseItems,
|
||||
...(heroResult
|
||||
? [
|
||||
{
|
||||
tmdbId: heroResult.id as number,
|
||||
type: heroResult.media_type as "movie" | "tv",
|
||||
title: ((heroResult.title ?? heroResult.name) as string | undefined) ?? "",
|
||||
posterPath: (heroResult.poster_path as string) ?? null,
|
||||
releaseDate: (heroResult.release_date as string | undefined) ?? null,
|
||||
firstAirDate: (heroResult.first_air_date as string | undefined) ?? null,
|
||||
voteAverage: (heroResult.vote_average as number | undefined) ?? null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const titleMap = ensureBrowseTitlesExist(allBrowseItems);
|
||||
|
||||
const items = baseItems.map((item) => {
|
||||
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
|
||||
return Object.assign(item, {
|
||||
id: entry?.id ?? "",
|
||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
||||
posterThumbHash: entry?.posterThumbHash ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const heroEntry = heroResult
|
||||
? titleMap.get(`${heroResult.id as number}-${heroResult.media_type as string}`)
|
||||
: undefined;
|
||||
const hero = heroResult
|
||||
? {
|
||||
id: heroEntry?.id ?? "",
|
||||
tmdbId: heroResult.id as number,
|
||||
type: heroResult.media_type as "movie" | "tv",
|
||||
title: ((heroResult.title ?? heroResult.name) as string | undefined) ?? "",
|
||||
overview: (heroResult.overview as string | undefined) ?? "",
|
||||
backdropPath: tmdbImageUrl((heroResult.backdrop_path as string) ?? null, "backdrops"),
|
||||
voteAverage: heroResult.vote_average as number,
|
||||
}
|
||||
: null;
|
||||
|
||||
const titleIds = items.map((r) => r.id);
|
||||
const [userStatuses, episodeProgress] =
|
||||
titleIds.length > 0
|
||||
? [
|
||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
return {
|
||||
items,
|
||||
hero,
|
||||
userStatuses,
|
||||
episodeProgress,
|
||||
page: (data as { page?: number }).page ?? input.page,
|
||||
totalPages: (data as { total_pages?: number }).total_pages ?? 1,
|
||||
totalResults: (data as { total_results?: number }).total_results ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
export const popular = os.explore.popular.use(authed).handler(async ({ input, context }) => {
|
||||
requireTmdb();
|
||||
|
||||
const data = await getPopular(input.type, input.page);
|
||||
const baseItems = ((data.results ?? []) as Record<string, unknown>[])
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => ({
|
||||
tmdbId: r.id as number,
|
||||
type: input.type,
|
||||
title: ((r.title ?? r.name) as string) || "",
|
||||
posterPath: (r.poster_path as string) ?? null,
|
||||
releaseDate: (r.release_date as string | undefined) ?? null,
|
||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||
voteAverage: (r.vote_average as number | undefined) ?? null,
|
||||
}));
|
||||
|
||||
const titleMap = ensureBrowseTitlesExist(baseItems);
|
||||
const items = baseItems.map((item) => {
|
||||
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
|
||||
return Object.assign(item, {
|
||||
id: entry?.id ?? "",
|
||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
||||
posterThumbHash: entry?.posterThumbHash ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const titleIds = items.map((r) => r.id);
|
||||
const [userStatuses, episodeProgress] =
|
||||
titleIds.length > 0
|
||||
? [
|
||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
return {
|
||||
items,
|
||||
userStatuses,
|
||||
episodeProgress,
|
||||
page: data.page ?? input.page,
|
||||
totalPages: data.total_pages ?? 1,
|
||||
totalResults: data.total_results ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
export const genres = os.explore.genres.use(authed).handler(async ({ input }) => {
|
||||
requireTmdb();
|
||||
const data = await getGenres(input.type);
|
||||
return {
|
||||
genres: (data.genres ?? []).map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name ?? "",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
export const watchProviders = os.explore.watchProviders.use(authed).handler(async () => {
|
||||
const allPlatforms = listPlatforms();
|
||||
const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id));
|
||||
return {
|
||||
providers: allPlatforms.map((p) => ({
|
||||
id: p.id,
|
||||
tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [],
|
||||
name: p.name,
|
||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import {
|
||||
createOrUpdateIntegration,
|
||||
deleteIntegration as coreDeleteIntegration,
|
||||
listUserIntegrations,
|
||||
regenerateToken as coreRegenerateToken,
|
||||
serializeIntegration,
|
||||
} from "@sofa/core/integrations";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const list = os.integrations.list.use(authed).handler(({ context }) => {
|
||||
return listUserIntegrations(context.user.id);
|
||||
});
|
||||
|
||||
export const create = os.integrations.create.use(authed).handler(({ input, context }) => {
|
||||
return createOrUpdateIntegration(context.user.id, input.provider, input.enabled);
|
||||
});
|
||||
|
||||
export const deleteIntegration = os.integrations.delete
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
coreDeleteIntegration(context.user.id, input.provider);
|
||||
});
|
||||
|
||||
export const regenerateToken = os.integrations.regenerateToken
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const row = coreRegenerateToken(context.user.id, input.provider);
|
||||
|
||||
if (!row) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Integration not found",
|
||||
data: { code: AppErrorCode.INTEGRATION_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
|
||||
return serializeIntegration(row);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getContinueWatchingFeed, getUpcomingFeed } from "@sofa/core/discovery";
|
||||
import { getFilteredLibraryFeed, getLibraryGenresList } from "@sofa/core/library";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
@@ -45,3 +46,51 @@ export const list = os.library.list.use(authed).handler(({ input, context }) =>
|
||||
export const genres = os.library.genres.use(authed).handler(({ context }) => {
|
||||
return { genres: getLibraryGenresList(context.user.id) };
|
||||
});
|
||||
|
||||
export const continueWatching = os.library.continueWatching.use(authed).handler(({ context }) => {
|
||||
const feed = getContinueWatchingFeed(context.user.id);
|
||||
const items = feed.map((item) => ({
|
||||
title: {
|
||||
id: item.title.id,
|
||||
title: item.title.title,
|
||||
backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"),
|
||||
backdropThumbHash: item.title.backdropThumbHash,
|
||||
},
|
||||
nextEpisode: item.nextEpisode
|
||||
? {
|
||||
seasonNumber: item.nextEpisode.seasonNumber,
|
||||
episodeNumber: item.nextEpisode.episodeNumber,
|
||||
name: item.nextEpisode.name,
|
||||
stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"),
|
||||
stillThumbHash: item.nextEpisode.stillThumbHash,
|
||||
}
|
||||
: null,
|
||||
totalEpisodes: item.totalEpisodes,
|
||||
watchedEpisodes: item.watchedEpisodes,
|
||||
}));
|
||||
return { items };
|
||||
});
|
||||
|
||||
export const upcoming = os.library.upcoming.use(authed).handler(({ input, context }) => {
|
||||
const result = getUpcomingFeed(context.user.id, {
|
||||
days: input.days,
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
mediaType: input.mediaType,
|
||||
statusFilter: input.statusFilter,
|
||||
});
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
...item,
|
||||
posterPath: tmdbImageUrl(item.posterPath, "posters"),
|
||||
backdropPath: tmdbImageUrl(item.backdropPath, "backdrops"),
|
||||
streamingProvider: item.streamingProvider
|
||||
? {
|
||||
...item.streamingProvider,
|
||||
logoPath: tmdbImageUrl(item.streamingProvider.logoPath, "logos"),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const detail = os.people.detail.use(authed).handler(async ({ input, context }) => {
|
||||
export const get = os.people.get.use(authed).handler(async ({ input, context }) => {
|
||||
const person = await getOrFetchPerson(input.id);
|
||||
if (!person)
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { listPlatforms, getPlatformTmdbIdMap } 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();
|
||||
const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id));
|
||||
return {
|
||||
platforms: allPlatforms.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [],
|
||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||
isSubscription: p.isSubscription,
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { ensureBrowsePersonsExist } from "@sofa/core/person";
|
||||
import { searchMovies, searchMulti, searchPerson, searchTv } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const search = os.search.use(authed).handler(async ({ input }) => {
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured",
|
||||
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||
});
|
||||
}
|
||||
|
||||
const query = input.query.trim();
|
||||
if (!query) {
|
||||
return { results: [], page: 1, totalPages: 0, totalResults: 0 };
|
||||
}
|
||||
const type = input.type ?? null;
|
||||
|
||||
if (type === "person") {
|
||||
const personResults = await searchPerson(query, input.page);
|
||||
const personItems = (personResults.results ?? []).map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: "person" as const,
|
||||
title: r.name ?? "",
|
||||
posterPath: null,
|
||||
profilePath: r.profile_path ?? null,
|
||||
overview: null,
|
||||
releaseDate: null,
|
||||
popularity: r.popularity ?? null,
|
||||
voteAverage: null,
|
||||
knownForDepartment: r.known_for_department ?? null,
|
||||
knownFor:
|
||||
(r.known_for
|
||||
?.slice(0, 3)
|
||||
.map((k) => k.title ?? (k as { name?: string }).name)
|
||||
.filter((s): s is string => !!s) as string[]) ?? null,
|
||||
}));
|
||||
const personMap = ensureBrowsePersonsExist(
|
||||
personItems.map((r) => ({
|
||||
tmdbId: r.tmdbId,
|
||||
name: r.title,
|
||||
profilePath: r.profilePath,
|
||||
knownForDepartment: r.knownForDepartment,
|
||||
popularity: r.popularity,
|
||||
})),
|
||||
);
|
||||
return {
|
||||
results: personItems.map((r) =>
|
||||
Object.assign(r, {
|
||||
id: personMap.get(r.tmdbId),
|
||||
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
|
||||
}),
|
||||
),
|
||||
page: personResults.page ?? input.page,
|
||||
totalPages: personResults.total_pages ?? 1,
|
||||
totalResults: personResults.total_results ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const raw =
|
||||
type === "movie"
|
||||
? await searchMovies(query, input.page)
|
||||
: type === "tv"
|
||||
? await searchTv(query, input.page)
|
||||
: await searchMulti(query, input.page);
|
||||
|
||||
type SearchResult = {
|
||||
id: number;
|
||||
media_type?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
overview?: string;
|
||||
poster_path?: string | null;
|
||||
profile_path?: string | null;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
popularity?: number;
|
||||
vote_average?: number;
|
||||
};
|
||||
|
||||
const mapped = ((raw.results ?? []) as SearchResult[])
|
||||
.map((r) => {
|
||||
if (r.media_type === "person") {
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: "person" as const,
|
||||
title: r.name ?? "Unknown",
|
||||
posterPath: null,
|
||||
profilePath: r.profile_path ?? null,
|
||||
overview: null,
|
||||
releaseDate: null,
|
||||
popularity: r.popularity ?? null,
|
||||
voteAverage: null,
|
||||
knownForDepartment: null,
|
||||
knownFor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type;
|
||||
if (!mediaType) return null;
|
||||
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: mediaType,
|
||||
title: r.title ?? r.name ?? "",
|
||||
overview: r.overview ?? null,
|
||||
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
||||
posterPath: r.poster_path ?? null,
|
||||
profilePath: null,
|
||||
popularity: r.popularity ?? null,
|
||||
voteAverage: r.vote_average ?? null,
|
||||
knownForDepartment: null,
|
||||
knownFor: null,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
// Batch-import movie/TV results so they have internal IDs
|
||||
const titleResults = mapped.filter(
|
||||
(r): r is typeof r & { type: "movie" | "tv" } => r.type !== "person",
|
||||
);
|
||||
const titleMap = ensureBrowseTitlesExist(titleResults);
|
||||
|
||||
// Batch-import person results so they have internal IDs
|
||||
const personResults = mapped.filter((r) => r.type === "person");
|
||||
const personMap = ensureBrowsePersonsExist(
|
||||
personResults.map((r) => ({
|
||||
tmdbId: r.tmdbId,
|
||||
name: r.title,
|
||||
profilePath: r.profilePath,
|
||||
knownForDepartment: r.knownForDepartment,
|
||||
popularity: r.popularity,
|
||||
})),
|
||||
);
|
||||
|
||||
const results = mapped.map((r) => {
|
||||
if (r.type === "person") {
|
||||
return Object.assign(r, {
|
||||
id: personMap.get(r.tmdbId),
|
||||
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
|
||||
});
|
||||
}
|
||||
const entry = titleMap.get(`${r.tmdbId}-${r.type}`);
|
||||
return Object.assign(r, { id: entry?.id, posterPath: tmdbImageUrl(r.posterPath, "posters") });
|
||||
});
|
||||
|
||||
return {
|
||||
results,
|
||||
page: raw.page ?? input.page,
|
||||
totalPages: raw.total_pages ?? 1,
|
||||
totalResults: raw.total_results ?? 0,
|
||||
};
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { unwatchSeason, watchSeason } from "@sofa/core/tracking";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const watch = os.seasons.watch.use(authed).handler(({ input, context }) => {
|
||||
watchSeason(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const unwatch = os.seasons.unwatch.use(authed).handler(({ input, context }) => {
|
||||
unwatchSeason(context.user.id, input.id);
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const status = os.system.status.use(authed).handler(() => {
|
||||
return {
|
||||
publicApiUrl: process.env.PUBLIC_API_URL || "https://public-api.sofa.watch",
|
||||
};
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
// Well-known TMDB poster paths for the background collage
|
||||
const posterPaths = [
|
||||
@@ -26,22 +27,20 @@ export const publicInfo = os.system.publicInfo.handler(async () => {
|
||||
.map((p) => tmdbImageUrl(p, "posters", "w300"))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const oidcEnabled = isOidcConfigured();
|
||||
|
||||
return {
|
||||
instanceId: getInstanceId(),
|
||||
tmdbConfigured: isTmdbConfigured(),
|
||||
userCount: getUserCount(),
|
||||
registrationOpen: isRegistrationOpen(),
|
||||
posterUrls,
|
||||
};
|
||||
});
|
||||
|
||||
export const authConfig = os.system.authConfig.handler(async () => {
|
||||
const oidcEnabled = isOidcConfigured();
|
||||
return {
|
||||
oidcEnabled,
|
||||
oidcProviderName: oidcEnabled ? getOidcProviderName() : null,
|
||||
passwordLoginDisabled: isPasswordLoginDisabled(),
|
||||
registrationOpen: isRegistrationOpen(),
|
||||
userCount: getUserCount(),
|
||||
};
|
||||
});
|
||||
|
||||
export const status = os.system.status.use(authed).handler(() => {
|
||||
return { publicApiUrl: process.env.PUBLIC_API_URL ?? "https://public-api.sofa.watch" };
|
||||
});
|
||||
|
||||
@@ -2,25 +2,13 @@ import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { getRecommendationsForTitle } from "@sofa/core/discovery";
|
||||
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
||||
import {
|
||||
getDisplayStatusesByTitleIds,
|
||||
getUserTitleInfo,
|
||||
logMovieWatch,
|
||||
markAllEpisodesWatched,
|
||||
quickAddTitle,
|
||||
rateTitleStars,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
} from "@sofa/core/tracking";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getOrFetchTitle } from "@sofa/core/metadata";
|
||||
import { getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
const log = createLogger("titles");
|
||||
|
||||
export const detail = os.titles.detail.use(authed).handler(async ({ input, context }) => {
|
||||
export const get = os.titles.get.use(authed).handler(async ({ input, context }) => {
|
||||
const result = await getOrFetchTitle(input.id, context.user.id);
|
||||
if (!result)
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
@@ -30,59 +18,11 @@ export const detail = os.titles.detail.use(authed).handler(async ({ input, conte
|
||||
return result;
|
||||
});
|
||||
|
||||
export const updateStatus = os.titles.updateStatus.use(authed).handler(({ input, context }) => {
|
||||
if (input.status === null) {
|
||||
removeTitleStatus(context.user.id, input.id);
|
||||
} else {
|
||||
setTitleStatus(context.user.id, input.id, input.status);
|
||||
}
|
||||
});
|
||||
|
||||
export const updateRating = os.titles.updateRating.use(authed).handler(({ input, context }) => {
|
||||
rateTitleStars(context.user.id, input.id, input.stars);
|
||||
});
|
||||
|
||||
export const watchMovie = os.titles.watchMovie.use(authed).handler(({ input, context }) => {
|
||||
logMovieWatch(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const watchAll = os.titles.watchAll.use(authed).handler(({ input, context }) => {
|
||||
markAllEpisodesWatched(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const userInfo = os.titles.userInfo.use(authed).handler(({ input, context }) => {
|
||||
const info = getUserTitleInfo(context.user.id, input.id);
|
||||
if (!info.status) return { ...info, status: null };
|
||||
|
||||
// Convert stored status to display status
|
||||
const displayStatuses = getDisplayStatusesByTitleIds(context.user.id, [input.id]);
|
||||
return { ...info, status: displayStatuses[input.id] ?? null };
|
||||
});
|
||||
|
||||
export const recommendations = os.titles.recommendations
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const recs = getRecommendationsForTitle(input.id);
|
||||
const userStatuses = getDisplayStatusesByTitleIds(
|
||||
context.user.id,
|
||||
recs.map((r) => r.id),
|
||||
);
|
||||
return { recommendations: recs, userStatuses };
|
||||
});
|
||||
|
||||
export const quickAdd = os.titles.quickAdd.use(authed).handler(async ({ input, context }) => {
|
||||
const result = quickAddTitle(context.user.id, input.id);
|
||||
if (!result) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Title not found",
|
||||
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger full TMDB import if still a shell (fire-and-forget)
|
||||
getOrFetchTitleByTmdbId(result.tmdbId, result.type as "movie" | "tv").catch((err) => {
|
||||
log.warn(`Failed to import ${result.type} TMDB ${result.tmdbId}:`, err);
|
||||
});
|
||||
|
||||
return { id: result.id, alreadyAdded: result.alreadyAdded };
|
||||
export const similar = os.titles.similar.use(authed).handler(({ input, context }) => {
|
||||
const recs = getRecommendationsForTitle(input.id);
|
||||
const userStatuses = getDisplayStatusesByTitleIds(
|
||||
context.user.id,
|
||||
recs.map((r) => r.id),
|
||||
);
|
||||
return { recommendations: recs, userStatuses };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import type { WatchScopeType } from "@sofa/api/schemas";
|
||||
import { getUserStats, getWatchCount, getWatchHistory } from "@sofa/core/discovery";
|
||||
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
||||
import {
|
||||
getDisplayStatusesByTitleIds,
|
||||
getUserTitleInfo,
|
||||
logEpisodeWatch,
|
||||
logEpisodeWatchBatch,
|
||||
logMovieWatch,
|
||||
markAllEpisodesWatched,
|
||||
quickAddTitle,
|
||||
rateTitleStars,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
unwatchEpisode,
|
||||
unwatchMovie,
|
||||
unwatchSeason,
|
||||
unwatchSeries,
|
||||
watchSeason,
|
||||
} from "@sofa/core/tracking";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
const log = createLogger("tracking");
|
||||
|
||||
const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const;
|
||||
|
||||
// ─── Watch handlers by scope ──────────────────────────────────
|
||||
|
||||
function handleWatch(userId: string, scope: WatchScopeType, ids: string[]) {
|
||||
switch (scope) {
|
||||
case "movie":
|
||||
for (const id of ids) logMovieWatch(userId, id);
|
||||
break;
|
||||
case "episode":
|
||||
if (ids.length === 1) {
|
||||
logEpisodeWatch(userId, ids[0]);
|
||||
} else {
|
||||
logEpisodeWatchBatch(userId, ids);
|
||||
}
|
||||
break;
|
||||
case "season":
|
||||
for (const id of ids) watchSeason(userId, id);
|
||||
break;
|
||||
case "series":
|
||||
for (const id of ids) markAllEpisodesWatched(userId, id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnwatch(userId: string, scope: WatchScopeType, ids: string[]) {
|
||||
switch (scope) {
|
||||
case "movie":
|
||||
for (const id of ids) unwatchMovie(userId, id);
|
||||
break;
|
||||
case "episode":
|
||||
for (const id of ids) unwatchEpisode(userId, id);
|
||||
break;
|
||||
case "season":
|
||||
for (const id of ids) unwatchSeason(userId, id);
|
||||
break;
|
||||
case "series":
|
||||
for (const id of ids) unwatchSeries(userId, id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Procedures ───────────────────────────────────────────────
|
||||
|
||||
export const watch = os.tracking.watch.use(authed).handler(({ input, context }) => {
|
||||
handleWatch(context.user.id, input.scope, input.ids);
|
||||
});
|
||||
|
||||
export const unwatch = os.tracking.unwatch.use(authed).handler(({ input, context }) => {
|
||||
handleUnwatch(context.user.id, input.scope, input.ids);
|
||||
});
|
||||
|
||||
export const updateStatus = os.tracking.updateStatus.use(authed).handler(({ input, context }) => {
|
||||
if (input.status === null) {
|
||||
removeTitleStatus(context.user.id, input.id);
|
||||
} else {
|
||||
setTitleStatus(context.user.id, input.id, input.status);
|
||||
}
|
||||
});
|
||||
|
||||
export const rate = os.tracking.rate.use(authed).handler(({ input, context }) => {
|
||||
rateTitleStars(context.user.id, input.id, input.stars);
|
||||
});
|
||||
|
||||
export const userInfo = os.tracking.userInfo.use(authed).handler(({ input, context }) => {
|
||||
const info = getUserTitleInfo(context.user.id, input.id);
|
||||
if (!info.status) return { ...info, status: null };
|
||||
|
||||
const displayStatuses = getDisplayStatusesByTitleIds(context.user.id, [input.id]);
|
||||
return { ...info, status: displayStatuses[input.id] ?? null };
|
||||
});
|
||||
|
||||
export const quickAdd = os.tracking.quickAdd.use(authed).handler(async ({ input, context }) => {
|
||||
const result = quickAddTitle(context.user.id, input.id);
|
||||
if (!result) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Title not found",
|
||||
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger full TMDB import if still a shell (fire-and-forget)
|
||||
getOrFetchTitleByTmdbId(result.tmdbId, result.type as "movie" | "tv").catch((err) => {
|
||||
log.warn(`Failed to import ${result.type} TMDB ${result.tmdbId}:`, err);
|
||||
});
|
||||
|
||||
return { id: result.id, alreadyAdded: result.alreadyAdded };
|
||||
});
|
||||
|
||||
export const stats = os.tracking.stats.use(authed).handler(({ context }) => {
|
||||
return getUserStats(context.user.id);
|
||||
});
|
||||
|
||||
export const history = os.tracking.history.use(authed).handler(({ input, context }) => {
|
||||
const coreType = watchHistoryTypeMap[input.type];
|
||||
const count = getWatchCount(context.user.id, coreType, input.period);
|
||||
const watchHistory = getWatchHistory(context.user.id, coreType, input.period);
|
||||
return { count, history: watchHistory };
|
||||
});
|
||||
@@ -1,75 +1,69 @@
|
||||
import { os } from "./context";
|
||||
import * as account from "./procedures/account";
|
||||
import * as admin from "./procedures/admin";
|
||||
import * as dashboard from "./procedures/dashboard";
|
||||
import { discover } from "./procedures/discover";
|
||||
import * as episodes from "./procedures/episodes";
|
||||
import * as explore from "./procedures/explore";
|
||||
import * as discover from "./procedures/discover";
|
||||
import * as imports from "./procedures/imports";
|
||||
import * as integrations from "./procedures/integrations";
|
||||
import * as library from "./procedures/library";
|
||||
import * as people from "./procedures/people";
|
||||
import * as platformProcs from "./procedures/platforms";
|
||||
import { search } from "./procedures/search";
|
||||
import * as seasons from "./procedures/seasons";
|
||||
import * as status from "./procedures/status";
|
||||
import * as system from "./procedures/system";
|
||||
import * as titles from "./procedures/titles";
|
||||
import * as tracking from "./procedures/tracking";
|
||||
|
||||
export const implementedRouter = {
|
||||
titles: {
|
||||
get: titles.get,
|
||||
similar: titles.similar,
|
||||
},
|
||||
tracking: {
|
||||
watch: tracking.watch,
|
||||
unwatch: tracking.unwatch,
|
||||
updateStatus: tracking.updateStatus,
|
||||
rate: tracking.rate,
|
||||
userInfo: tracking.userInfo,
|
||||
quickAdd: tracking.quickAdd,
|
||||
stats: tracking.stats,
|
||||
history: tracking.history,
|
||||
},
|
||||
library: {
|
||||
list: library.list,
|
||||
genres: library.genres,
|
||||
continueWatching: library.continueWatching,
|
||||
upcoming: library.upcoming,
|
||||
},
|
||||
titles: {
|
||||
detail: titles.detail,
|
||||
updateStatus: titles.updateStatus,
|
||||
updateRating: titles.updateRating,
|
||||
watchMovie: titles.watchMovie,
|
||||
watchAll: titles.watchAll,
|
||||
userInfo: titles.userInfo,
|
||||
recommendations: titles.recommendations,
|
||||
quickAdd: titles.quickAdd,
|
||||
},
|
||||
episodes: {
|
||||
watch: episodes.watch,
|
||||
unwatch: episodes.unwatch,
|
||||
batchWatch: episodes.batchWatch,
|
||||
},
|
||||
seasons: {
|
||||
watch: seasons.watch,
|
||||
unwatch: seasons.unwatch,
|
||||
discover: {
|
||||
trending: discover.trending,
|
||||
popular: discover.popular,
|
||||
search: discover.search,
|
||||
browse: discover.browse,
|
||||
genres: discover.genres,
|
||||
platforms: discover.platforms,
|
||||
recommendations: discover.recommendations,
|
||||
},
|
||||
people: {
|
||||
detail: people.detail,
|
||||
get: people.get,
|
||||
},
|
||||
dashboard: {
|
||||
stats: dashboard.stats,
|
||||
continueWatching: dashboard.continueWatching,
|
||||
upcoming: dashboard.upcoming,
|
||||
recommendations: dashboard.recommendations,
|
||||
watchHistory: dashboard.watchHistory,
|
||||
account: {
|
||||
updateName: account.updateName,
|
||||
uploadAvatar: account.uploadAvatar,
|
||||
removeAvatar: account.removeAvatar,
|
||||
platforms: account.platforms,
|
||||
updatePlatforms: account.updatePlatformsHandler,
|
||||
integrations: {
|
||||
list: account.integrationsList,
|
||||
create: account.integrationsCreate,
|
||||
delete: account.integrationsDelete,
|
||||
regenerateToken: account.integrationsRegenerateToken,
|
||||
},
|
||||
},
|
||||
explore: {
|
||||
trending: explore.trending,
|
||||
popular: explore.popular,
|
||||
genres: explore.genres,
|
||||
watchProviders: explore.watchProviders,
|
||||
},
|
||||
search,
|
||||
discover,
|
||||
system: {
|
||||
publicInfo: system.publicInfo,
|
||||
authConfig: system.authConfig,
|
||||
status: status.status,
|
||||
},
|
||||
integrations: {
|
||||
list: integrations.list,
|
||||
create: integrations.create,
|
||||
delete: integrations.deleteIntegration,
|
||||
regenerateToken: integrations.regenerateToken,
|
||||
status: system.status,
|
||||
},
|
||||
admin: {
|
||||
settings: {
|
||||
get: admin.settingsGet,
|
||||
update: admin.settingsUpdate,
|
||||
},
|
||||
backups: {
|
||||
list: admin.backupsList,
|
||||
create: admin.backupsCreate,
|
||||
@@ -78,27 +72,11 @@ export const implementedRouter = {
|
||||
schedule: admin.backupsSchedule,
|
||||
updateSchedule: admin.backupsUpdateSchedule,
|
||||
},
|
||||
registration: admin.registration,
|
||||
toggleRegistration: admin.toggleRegistration,
|
||||
updateCheck: admin.updateCheck,
|
||||
toggleUpdateCheck: admin.toggleUpdateCheck,
|
||||
telemetry: admin.telemetry,
|
||||
toggleTelemetry: admin.toggleTelemetry,
|
||||
triggerJob: admin.triggerJob,
|
||||
purgeMetadataCache: admin.purgeMetadataCache,
|
||||
purgeImageCache: admin.purgeImageCache,
|
||||
systemHealth: admin.systemHealth,
|
||||
},
|
||||
account: {
|
||||
updateName: account.updateName,
|
||||
uploadAvatar: account.uploadAvatar,
|
||||
removeAvatar: account.removeAvatar,
|
||||
platforms: account.platforms,
|
||||
updatePlatforms: account.updatePlatformsHandler,
|
||||
},
|
||||
platforms: {
|
||||
list: platformProcs.list,
|
||||
},
|
||||
imports: {
|
||||
parseFile: imports.parseFile,
|
||||
parsePayload: imports.parsePayload,
|
||||
|
||||
@@ -101,7 +101,7 @@ export function CommandPalette() {
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const trimmedQuery = debouncedQuery.trim();
|
||||
const { data: searchData, isLoading: loading } = useQuery(
|
||||
orpc.search.queryOptions({
|
||||
orpc.discover.search.queryOptions({
|
||||
input: trimmedQuery ? { query: trimmedQuery } : skipToken,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ContinueWatchingList, ContinueWatchingSectionSkeleton } from "./continu
|
||||
import { FeedSection } from "./feed-section";
|
||||
|
||||
export function ContinueWatchingSection() {
|
||||
const { data, isPending } = useQuery(orpc.dashboard.continueWatching.queryOptions());
|
||||
const { data, isPending } = useQuery(orpc.library.continueWatching.queryOptions());
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FeedSection } from "./feed-section";
|
||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||
|
||||
export function RecommendationsSection() {
|
||||
const { data, isPending } = useQuery(orpc.dashboard.recommendations.queryOptions());
|
||||
const { data, isPending } = useQuery(orpc.discover.recommendations.queryOptions());
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
|
||||
@@ -158,12 +158,12 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
|
||||
|
||||
const { data: movieStats } = useQuery(
|
||||
orpc.dashboard.watchHistory.queryOptions({
|
||||
orpc.tracking.history.queryOptions({
|
||||
input: { type: "movie", period: moviePeriod },
|
||||
}),
|
||||
);
|
||||
const { data: episodeStats } = useQuery(
|
||||
orpc.dashboard.watchHistory.queryOptions({
|
||||
orpc.tracking.history.queryOptions({
|
||||
input: { type: "episode", period: episodePeriod },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { orpc } from "@/lib/orpc/client";
|
||||
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
|
||||
|
||||
export function StatsSection() {
|
||||
const { data: stats, isPending } = useQuery(orpc.dashboard.stats.queryOptions());
|
||||
const { data: stats, isPending } = useQuery(orpc.tracking.stats.queryOptions());
|
||||
|
||||
if (isPending) return <StatsSectionSkeleton />;
|
||||
if (!stats) return null;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { UpcomingRow } from "./upcoming-item";
|
||||
|
||||
export function UpcomingSection() {
|
||||
const { data, isPending } = useQuery(
|
||||
orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
orpc.library.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
);
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -69,11 +69,11 @@ export function DiscoverSection() {
|
||||
const [language, setLanguage] = useState<string | undefined>(undefined);
|
||||
const [platformId, setPlatformId] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data: genreData } = useQuery(orpc.explore.genres.queryOptions({ input: { type } }));
|
||||
const { data: providerData } = useQuery(orpc.platforms.list.queryOptions());
|
||||
const { data: genreData } = useQuery(orpc.discover.genres.queryOptions({ input: { type } }));
|
||||
const { data: providerData } = useQuery(orpc.discover.platforms.queryOptions());
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery(
|
||||
orpc.discover.infiniteOptions({
|
||||
orpc.discover.browse.infiniteOptions({
|
||||
input: (pageParam: number) => ({
|
||||
type,
|
||||
genreId,
|
||||
|
||||
@@ -54,7 +54,7 @@ export function FilterableTitleRow({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery(
|
||||
orpc.discover.infiniteOptions({
|
||||
orpc.discover.browse.infiniteOptions({
|
||||
input:
|
||||
selectedGenre != null
|
||||
? (pageParam: number) => ({
|
||||
|
||||
@@ -33,7 +33,7 @@ export function PersonDetailSkeleton() {
|
||||
|
||||
export function PersonDetailClient({ id }: { id: string }) {
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
orpc.people.detail.infiniteOptions({
|
||||
orpc.people.get.infiniteOptions({
|
||||
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function IntegrationCard({
|
||||
const providerInput = provider as "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
||||
|
||||
const connectMutation = useMutation(
|
||||
orpc.integrations.create.mutationOptions({
|
||||
orpc.account.integrations.create.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
||||
toast.success(t`${label} connected`);
|
||||
@@ -93,7 +93,7 @@ export function IntegrationCard({
|
||||
);
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
orpc.integrations.delete.mutationOptions({
|
||||
orpc.account.integrations.delete.mutationOptions({
|
||||
onMutate: () => {
|
||||
let previous: IntegrationConnection[] = [];
|
||||
setConnections((prev) => {
|
||||
@@ -111,7 +111,7 @@ export function IntegrationCard({
|
||||
);
|
||||
|
||||
const regenerateTokenMutation = useMutation(
|
||||
orpc.integrations.regenerateToken.mutationOptions({
|
||||
orpc.account.integrations.regenerateToken.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setConnections((prev) =>
|
||||
prev.map((c) => (c.provider === provider ? { ...c, token: result.token } : c)),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IntegrationCard, type IntegrationConnection } from "./integration-card"
|
||||
import { INTEGRATION_CONFIGS } from "./integration-configs";
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { data, isPending } = useQuery(orpc.integrations.list.queryOptions());
|
||||
const { data, isPending } = useQuery(orpc.account.integrations.list.queryOptions());
|
||||
const [localConnections, setLocalConnections] = useState<IntegrationConnection[] | null>(null);
|
||||
|
||||
// Use local state if user has modified connections, else use query data
|
||||
|
||||
@@ -11,12 +11,12 @@ import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
export function RegistrationSection() {
|
||||
const { t } = useLingui();
|
||||
const { data, isPending: isLoading } = useQuery(orpc.admin.registration.queryOptions());
|
||||
const { data, isPending: isLoading } = useQuery(orpc.admin.settings.get.queryOptions());
|
||||
const [registrationOpen, setRegistrationOpen] = useState<boolean | null>(null);
|
||||
const currentOpen = registrationOpen ?? data?.open ?? false;
|
||||
const currentOpen = registrationOpen ?? data?.registration.open ?? false;
|
||||
const [optimisticOpen, setOptimisticOpen] = useOptimistic(currentOpen);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const toggleMutation = useMutation(orpc.admin.toggleRegistration.mutationOptions());
|
||||
const toggleMutation = useMutation(orpc.admin.settings.update.mutationOptions());
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -30,7 +30,7 @@ export function RegistrationSection() {
|
||||
startTransition(async () => {
|
||||
setOptimisticOpen(checked);
|
||||
try {
|
||||
await toggleMutation.mutateAsync({ open: checked });
|
||||
await toggleMutation.mutateAsync({ registration: { open: checked } });
|
||||
setRegistrationOpen(checked);
|
||||
toast.success(checked ? t`Registration opened` : t`Registration closed`);
|
||||
} catch {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function StreamingServicesSection() {
|
||||
const saveCounterRef = useRef(0);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const platformsQuery = useQuery(orpc.platforms.list.queryOptions());
|
||||
const platformsQuery = useQuery(orpc.discover.platforms.queryOptions());
|
||||
const userPlatformsQuery = useQuery(orpc.account.platforms.queryOptions());
|
||||
|
||||
// Initialize selected IDs from server data
|
||||
|
||||
@@ -11,12 +11,12 @@ import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
export function UpdateCheckSection() {
|
||||
const { t } = useLingui();
|
||||
const { data, isPending: isLoading } = useQuery(orpc.admin.updateCheck.queryOptions());
|
||||
const { data, isPending: isLoading } = useQuery(orpc.admin.settings.get.queryOptions());
|
||||
const [localEnabled, setLocalEnabled] = useState<boolean | null>(null);
|
||||
const currentEnabled = localEnabled ?? data?.enabled ?? true;
|
||||
const currentEnabled = localEnabled ?? data?.updateCheck.enabled ?? true;
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(currentEnabled);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const toggleMutation = useMutation(orpc.admin.toggleUpdateCheck.mutationOptions());
|
||||
const toggleMutation = useMutation(orpc.admin.settings.update.mutationOptions());
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -30,7 +30,7 @@ export function UpdateCheckSection() {
|
||||
startTransition(async () => {
|
||||
setOptimisticEnabled(checked);
|
||||
try {
|
||||
await toggleMutation.mutateAsync({ enabled: checked });
|
||||
await toggleMutation.mutateAsync({ updateCheck: { enabled: checked } });
|
||||
setLocalEnabled(checked);
|
||||
toast.success(checked ? t`Update checks enabled` : t`Update checks disabled`);
|
||||
} catch {
|
||||
|
||||
@@ -89,7 +89,7 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
|
||||
const [optimisticStatus, setOptimisticStatus] = useState<TitleStatus | null>(null);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
orpc.tracking.quickAdd.mutationOptions({
|
||||
onSuccess: () => setOptimisticStatus("in_watchlist"),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useTitleUserInfo() {
|
||||
const { titleId } = useTitleContext();
|
||||
const { data: session } = useSession();
|
||||
const { data } = useQuery({
|
||||
...orpc.titles.userInfo.queryOptions({ input: { id: titleId } }),
|
||||
...orpc.tracking.userInfo.queryOptions({ input: { id: titleId } }),
|
||||
enabled: !!session,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -27,7 +27,7 @@ function RecommendationsSkeleton() {
|
||||
|
||||
export function TitleRecommendations({ titleId }: { titleId: string }) {
|
||||
const { data, isLoading } = useQuery(
|
||||
orpc.titles.recommendations.queryOptions({ input: { id: titleId } }),
|
||||
orpc.titles.similar.queryOptions({ input: { id: titleId } }),
|
||||
);
|
||||
|
||||
if (isLoading) return <RecommendationsSkeleton />;
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useTitleActions() {
|
||||
const { t } = useLingui();
|
||||
const { titleId, titleName, seasons, setWatchingEp } = useTitleContext();
|
||||
const queryClient = useQueryClient();
|
||||
const userInfoKey = orpc.titles.userInfo.queryKey({ input: { id: titleId } });
|
||||
const userInfoKey = orpc.tracking.userInfo.queryKey({ input: { id: titleId } });
|
||||
|
||||
const getUserInfo = useCallback(
|
||||
() =>
|
||||
@@ -40,15 +40,10 @@ export function useTitleActions() {
|
||||
[queryClient, userInfoKey],
|
||||
);
|
||||
|
||||
const { mutateAsync: batchWatch } = useMutation(orpc.episodes.batchWatch.mutationOptions());
|
||||
const { mutateAsync: updateStatus } = useMutation(orpc.titles.updateStatus.mutationOptions());
|
||||
const { mutateAsync: updateRating } = useMutation(orpc.titles.updateRating.mutationOptions());
|
||||
const { mutateAsync: watchMovie } = useMutation(orpc.titles.watchMovie.mutationOptions());
|
||||
const { mutateAsync: unwatchEp } = useMutation(orpc.episodes.unwatch.mutationOptions());
|
||||
const { mutateAsync: watchEp } = useMutation(orpc.episodes.watch.mutationOptions());
|
||||
const { mutateAsync: watchSeason } = useMutation(orpc.seasons.watch.mutationOptions());
|
||||
const { mutateAsync: unwatchSeason } = useMutation(orpc.seasons.unwatch.mutationOptions());
|
||||
const { mutateAsync: watchAll } = useMutation(orpc.titles.watchAll.mutationOptions());
|
||||
const { mutateAsync: watch } = useMutation(orpc.tracking.watch.mutationOptions());
|
||||
const { mutateAsync: unwatch } = useMutation(orpc.tracking.unwatch.mutationOptions());
|
||||
const { mutateAsync: updateStatus } = useMutation(orpc.tracking.updateStatus.mutationOptions());
|
||||
const { mutateAsync: updateRating } = useMutation(orpc.tracking.rate.mutationOptions());
|
||||
|
||||
const catchUp = useCallback(
|
||||
async (episodeIds: string[]) => {
|
||||
@@ -64,7 +59,7 @@ export function useTitleActions() {
|
||||
}));
|
||||
|
||||
try {
|
||||
await batchWatch({ episodeIds });
|
||||
await watch({ scope: "episode", ids: episodeIds });
|
||||
await queryClient.invalidateQueries({ queryKey: userInfoKey });
|
||||
const count = episodeIds.length;
|
||||
toast.success(
|
||||
@@ -79,7 +74,7 @@ export function useTitleActions() {
|
||||
toast.error(t`Failed to catch up`);
|
||||
}
|
||||
},
|
||||
[getUserInfo, setUserInfo, batchWatch, queryClient, userInfoKey, t],
|
||||
[getUserInfo, setUserInfo, watch, queryClient, userInfoKey, t],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
@@ -132,13 +127,13 @@ export function useTitleActions() {
|
||||
const prevStatus = getUserInfo().status;
|
||||
setUserInfo((old) => ({ ...old, status: "completed" }));
|
||||
try {
|
||||
await watchMovie({ id: titleId });
|
||||
await watch({ scope: "movie", ids: [titleId] });
|
||||
toast.success(t`Marked "${titleName}" as watched`);
|
||||
} catch {
|
||||
setUserInfo((old) => ({ ...old, status: prevStatus }));
|
||||
toast.error(t`Failed to mark as watched`);
|
||||
}
|
||||
}, [getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, titleName, watchMovie, t]);
|
||||
}, [getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, titleName, watch, t]);
|
||||
|
||||
const handleWatchEpisode = useCallback(
|
||||
async (episodeId: string, seasonNum: number, epNum: number, isWatched: boolean) => {
|
||||
@@ -156,7 +151,7 @@ export function useTitleActions() {
|
||||
}));
|
||||
|
||||
try {
|
||||
await unwatchEp({ id: episodeId });
|
||||
await unwatch({ scope: "episode", ids: [episodeId] });
|
||||
toast.success(t`Unwatched S${seasonNum} E${epNum}`);
|
||||
} catch {
|
||||
setUserInfo((old) => ({
|
||||
@@ -181,7 +176,7 @@ export function useTitleActions() {
|
||||
}));
|
||||
|
||||
try {
|
||||
await watchEp({ id: episodeId });
|
||||
await watch({ scope: "episode", ids: [episodeId] });
|
||||
|
||||
const watchedSet = new Set(getUserInfo().episodeWatches);
|
||||
const previousUnwatched: string[] = [];
|
||||
@@ -231,8 +226,8 @@ export function useTitleActions() {
|
||||
setWatchingEp,
|
||||
seasons,
|
||||
catchUp,
|
||||
unwatchEp,
|
||||
watchEp,
|
||||
unwatch,
|
||||
watch,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -257,7 +252,7 @@ export function useTitleActions() {
|
||||
}));
|
||||
|
||||
try {
|
||||
await watchSeason({ id: season.id });
|
||||
await watch({ scope: "season", ids: [season.id] });
|
||||
await queryClient.invalidateQueries({ queryKey: userInfoKey });
|
||||
|
||||
const currentWatchSet = new Set(getUserInfo().episodeWatches);
|
||||
@@ -296,7 +291,7 @@ export function useTitleActions() {
|
||||
toast.error(t`Failed to mark some episodes`);
|
||||
}
|
||||
},
|
||||
[getUserInfo, setUserInfo, seasons, catchUp, watchSeason, queryClient, userInfoKey, t],
|
||||
[getUserInfo, setUserInfo, seasons, catchUp, watch, queryClient, userInfoKey, t],
|
||||
);
|
||||
|
||||
const handleUnmarkSeason = useCallback(
|
||||
@@ -312,7 +307,7 @@ export function useTitleActions() {
|
||||
}));
|
||||
|
||||
try {
|
||||
await unwatchSeason({ id: season.id });
|
||||
await unwatch({ scope: "season", ids: [season.id] });
|
||||
const seasonNumber = season.seasonNumber;
|
||||
const seasonLabel = season.name ?? t`Season ${seasonNumber}`;
|
||||
toast.success(t`Unwatched all of ${seasonLabel}`);
|
||||
@@ -325,7 +320,7 @@ export function useTitleActions() {
|
||||
toast.error(t`Failed to unmark some episodes`);
|
||||
}
|
||||
},
|
||||
[getUserInfo, setUserInfo, queryClient, userInfoKey, unwatchSeason, t],
|
||||
[getUserInfo, setUserInfo, queryClient, userInfoKey, unwatch, t],
|
||||
);
|
||||
|
||||
const handleMarkAllWatched = useCallback(async () => {
|
||||
@@ -339,7 +334,7 @@ export function useTitleActions() {
|
||||
status: old.status ?? "watching",
|
||||
}));
|
||||
try {
|
||||
await watchAll({ id: titleId });
|
||||
await watch({ scope: "series", ids: [titleId] });
|
||||
// Refresh to get server-derived display status (caught_up / completed)
|
||||
await queryClient.invalidateQueries({ queryKey: userInfoKey });
|
||||
toast.success(t`Marked all episodes as watched`);
|
||||
@@ -351,7 +346,7 @@ export function useTitleActions() {
|
||||
}));
|
||||
toast.error(t`Failed to mark all episodes as watched`);
|
||||
}
|
||||
}, [getUserInfo, setUserInfo, seasons, titleId, watchAll, queryClient, userInfoKey, t]);
|
||||
}, [getUserInfo, setUserInfo, seasons, titleId, watch, queryClient, userInfoKey, t]);
|
||||
|
||||
return {
|
||||
handleStatusChange,
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { updateToastDismissedVersionAtom } from "@/lib/atoms/update-check";
|
||||
import type { UpdateCheckResult } from "@sofa/api/schemas";
|
||||
import type { AdminSettings } from "@sofa/api/schemas";
|
||||
|
||||
export function UpdateToast({ data }: { data: UpdateCheckResult | null }) {
|
||||
export function UpdateToast({ data }: { data: AdminSettings["updateCheck"] | null }) {
|
||||
const { t } = useLingui();
|
||||
const [dismissedVersion, setDismissedVersion] = useAtom(updateToastDismissedVersionAtom);
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ export const Route = createFileRoute("/_app")({
|
||||
let updateCheck = null;
|
||||
if (session.user.role === "admin") {
|
||||
try {
|
||||
({ updateCheck } = await client.admin.updateCheck({}));
|
||||
const settings = await client.admin.settings.get({});
|
||||
updateCheck = settings.updateCheck;
|
||||
} catch {
|
||||
// Silently ignore — update check is non-critical
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ export const Route = createFileRoute("/_app/dashboard")({
|
||||
staleTime: 30_000,
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.dashboard.continueWatching.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.dashboard.recommendations.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.tracking.stats.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.library.continueWatching.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.discover.recommendations.queryOptions()),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
orpc.library.upcoming.queryOptions({ input: { days: 7, limit: 5 } }),
|
||||
),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }),
|
||||
|
||||
@@ -17,7 +17,7 @@ export const Route = createFileRoute("/_app/explore")({
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.explore.trending.infiniteOptions({
|
||||
orpc.discover.trending.infiniteOptions({
|
||||
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
@@ -26,16 +26,16 @@ export const Route = createFileRoute("/_app/explore")({
|
||||
}),
|
||||
),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||
orpc.discover.popular.queryOptions({ input: { type: "movie" } }),
|
||||
),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
|
||||
orpc.discover.popular.queryOptions({ input: { type: "tv" } }),
|
||||
),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
|
||||
orpc.discover.genres.queryOptions({ input: { type: "movie" } }),
|
||||
),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
|
||||
orpc.discover.genres.queryOptions({ input: { type: "tv" } }),
|
||||
),
|
||||
]);
|
||||
},
|
||||
@@ -81,7 +81,7 @@ function ExplorePage() {
|
||||
hasNextPage: hasNextTrending,
|
||||
isFetchingNextPage: isFetchingNextTrending,
|
||||
} = useInfiniteQuery(
|
||||
orpc.explore.trending.infiniteOptions({
|
||||
orpc.discover.trending.infiniteOptions({
|
||||
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
@@ -91,16 +91,16 @@ function ExplorePage() {
|
||||
);
|
||||
|
||||
const { data: popularMoviesData, isPending: moviesPending } = useQuery(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||
orpc.discover.popular.queryOptions({ input: { type: "movie" } }),
|
||||
);
|
||||
const { data: popularTvData, isPending: tvPending } = useQuery(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
|
||||
orpc.discover.popular.queryOptions({ input: { type: "tv" } }),
|
||||
);
|
||||
const { data: movieGenreData } = useQuery(
|
||||
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
|
||||
orpc.discover.genres.queryOptions({ input: { type: "movie" } }),
|
||||
);
|
||||
const { data: tvGenreData } = useQuery(
|
||||
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
|
||||
orpc.discover.genres.queryOptions({ input: { type: "tv" } }),
|
||||
);
|
||||
|
||||
const isPending = trendingPending || moviesPending || tvPending;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { client, orpc } from "@/lib/orpc/client";
|
||||
|
||||
export const Route = createFileRoute("/_app/onboarding")({
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureQueryData(orpc.platforms.list.queryOptions());
|
||||
await context.queryClient.ensureQueryData(orpc.discover.platforms.queryOptions());
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Get Started — Sofa" }] }),
|
||||
errorComponent: RouteError,
|
||||
@@ -23,7 +23,7 @@ function OnboardingPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const platformsQuery = useQuery(orpc.platforms.list.queryOptions());
|
||||
const platformsQuery = useQuery(orpc.discover.platforms.queryOptions());
|
||||
const platforms = platformsQuery.data?.platforms ?? [];
|
||||
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const Route = createFileRoute("/_app/people/$id")({
|
||||
loader: async ({ params, context }) => {
|
||||
try {
|
||||
const data = await context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.people.detail.infiniteOptions({
|
||||
orpc.people.get.infiniteOptions({
|
||||
input: (pageParam: number) => ({
|
||||
id: params.id,
|
||||
page: pageParam,
|
||||
|
||||
@@ -35,9 +35,9 @@ export const Route = createFileRoute("/_app/settings")({
|
||||
staleTime: 30_000,
|
||||
loader: async ({ context }) => {
|
||||
const promises: Promise<unknown>[] = [
|
||||
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.account.integrations.list.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.system.status.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.platforms.list.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.discover.platforms.queryOptions()),
|
||||
context.queryClient.ensureQueryData(orpc.account.platforms.queryOptions()),
|
||||
];
|
||||
const isAdmin = context.session.user.role === "admin";
|
||||
|
||||
@@ -21,10 +21,10 @@ export const Route = createFileRoute("/_app/titles/$id")({
|
||||
try {
|
||||
const [titleResult, userInfo] = await Promise.all([
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.titles.detail.queryOptions({ input: { id: params.id } }),
|
||||
orpc.titles.get.queryOptions({ input: { id: params.id } }),
|
||||
),
|
||||
context.queryClient
|
||||
.ensureQueryData(orpc.titles.userInfo.queryOptions({ input: { id: params.id } }))
|
||||
.ensureQueryData(orpc.tracking.userInfo.queryOptions({ input: { id: params.id } }))
|
||||
.catch(() => null),
|
||||
]);
|
||||
return { ...titleResult, userInfo };
|
||||
|
||||
@@ -23,7 +23,7 @@ export const Route = createFileRoute("/_app/upcoming")({
|
||||
staleTime: 30_000,
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
orpc.library.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
limit: 20,
|
||||
@@ -70,7 +70,7 @@ function UpcomingPage() {
|
||||
const statusFilter = search.status ?? "all";
|
||||
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
orpc.library.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
limit: 20,
|
||||
|
||||
@@ -5,25 +5,25 @@ import { client } from "@/lib/orpc/client";
|
||||
|
||||
export const Route = createFileRoute("/_auth/login")({
|
||||
beforeLoad: async () => {
|
||||
const authConfig = await client.system.authConfig({});
|
||||
if (authConfig.userCount === 0) throw redirect({ to: "/register" });
|
||||
const publicInfo = await client.system.publicInfo({});
|
||||
if (publicInfo.userCount === 0) throw redirect({ to: "/register" });
|
||||
|
||||
return { authConfig };
|
||||
return { publicInfo };
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Sign in — Sofa" }] }),
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const { authConfig } = Route.useRouteContext();
|
||||
const { publicInfo } = Route.useRouteContext();
|
||||
return (
|
||||
<AuthForm
|
||||
mode="login"
|
||||
authConfig={{
|
||||
oidcEnabled: authConfig.oidcEnabled,
|
||||
oidcProviderName: authConfig.oidcProviderName,
|
||||
passwordLoginDisabled: authConfig.passwordLoginDisabled,
|
||||
registrationOpen: authConfig.registrationOpen,
|
||||
oidcEnabled: publicInfo.oidcEnabled,
|
||||
oidcProviderName: publicInfo.oidcProviderName,
|
||||
passwordLoginDisabled: publicInfo.passwordLoginDisabled,
|
||||
registrationOpen: publicInfo.registrationOpen,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,17 +7,17 @@ import { client } from "@/lib/orpc/client";
|
||||
|
||||
export const Route = createFileRoute("/_auth/register")({
|
||||
beforeLoad: async () => {
|
||||
const authConfig = await client.system.authConfig({});
|
||||
return { authConfig };
|
||||
const publicInfo = await client.system.publicInfo({});
|
||||
return { publicInfo };
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Create account — Sofa" }] }),
|
||||
component: RegisterPage,
|
||||
});
|
||||
|
||||
function RegisterPage() {
|
||||
const { authConfig } = Route.useRouteContext();
|
||||
const { publicInfo } = Route.useRouteContext();
|
||||
|
||||
if (!authConfig.registrationOpen) {
|
||||
if (!publicInfo.registrationOpen) {
|
||||
return (
|
||||
<div className="relative mx-auto w-full max-w-sm">
|
||||
<div className="bg-primary/3 absolute -inset-4 rounded-2xl blur-2xl" />
|
||||
@@ -50,9 +50,9 @@ function RegisterPage() {
|
||||
<AuthForm
|
||||
mode="register"
|
||||
authConfig={{
|
||||
oidcEnabled: authConfig.oidcEnabled,
|
||||
oidcProviderName: authConfig.oidcProviderName,
|
||||
passwordLoginDisabled: authConfig.passwordLoginDisabled,
|
||||
oidcEnabled: publicInfo.oidcEnabled,
|
||||
oidcProviderName: publicInfo.oidcProviderName,
|
||||
passwordLoginDisabled: publicInfo.passwordLoginDisabled,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Create a new media server integration or update an existing one. Generates a unique webhook token for the provider.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/integrations","method":"post"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/account/integrations","method":"post"}]} />
|
||||
+1
-1
@@ -14,4 +14,4 @@ _openapi:
|
||||
|
||||
Remove a media server integration and all its event history.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/integrations/{provider}","method":"delete"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/account/integrations/{provider}","method":"delete"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch all configured media server integrations for the current user, including recent webhook/sync events for each.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/integrations","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/account/integrations","method":"get"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Generate a new webhook token for an integration. The old token is immediately invalidated.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/integrations/{provider}/regenerate-token","method":"post"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/account/integrations/{provider}/regenerate-token","method":"post"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Get registration status
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Check whether new user registration is currently open or closed.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Check whether new user registration is currently open or closed.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/admin/registration","method":"get"}]} />
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Get telemetry status
|
||||
title: Get admin settings
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
@@ -8,12 +8,12 @@ _openapi:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Fetch whether anonymous telemetry is enabled and when the last report
|
||||
was sent.
|
||||
Fetch all admin-configurable settings: registration, update checks,
|
||||
and telemetry.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Fetch whether anonymous telemetry is enabled and when the last report was sent.
|
||||
Fetch all admin-configurable settings: registration, update checks, and telemetry.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/admin/telemetry","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/admin/settings","method":"get"}]} />
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Update admin settings
|
||||
full: true
|
||||
_openapi:
|
||||
method: PATCH
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Partially update admin settings. Only provided sections are changed;
|
||||
omitted sections keep their current values.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Partially update admin settings. Only provided sections are changed; omitted sections keep their current values.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/admin/settings","method":"patch"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Toggle registration
|
||||
full: true
|
||||
_openapi:
|
||||
method: PUT
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Open or close new user registration.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Open or close new user registration.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/admin/registration","method":"put"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Toggle telemetry
|
||||
full: true
|
||||
_openapi:
|
||||
method: PUT
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Enable or disable anonymous telemetry reporting.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Enable or disable anonymous telemetry reporting.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/admin/telemetry","method":"put"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Toggle update checks
|
||||
full: true
|
||||
_openapi:
|
||||
method: PUT
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Enable or disable automatic update checks against the public API.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Enable or disable automatic update checks against the public API.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/admin/update-check","method":"put"}]} />
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Discover titles by genre
|
||||
title: Browse titles with filters
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Browse movies or TV shows filtered by genre, sorted by popularity. Returns user statuses and episode progress.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/discover","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/discover/browse","method":"get"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch the list of TMDB genres for movies or TV shows. Used to populate genre filters for discovery.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/explore/genres","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/discover/genres","method":"get"}]} />
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Get update check status
|
||||
title: List available streaming platforms
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
@@ -8,12 +8,12 @@ _openapi:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Fetch whether automatic update checks are enabled, and the latest
|
||||
cached check result if available.
|
||||
Fetch all available streaming platforms, ordered by popularity.
|
||||
Includes subscription status for filtering.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Fetch whether automatic update checks are enabled, and the latest cached check result if available.
|
||||
Fetch all available streaming platforms, ordered by popularity. Includes subscription status for filtering.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/admin/update-check","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/discover/platforms","method":"get"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch currently popular movies or TV shows from TMDB with the user's tracking statuses.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/explore/popular","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/discover/popular","method":"get"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch personalized title recommendations based on the user's library and watch history.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/dashboard/recommendations","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/discover/recommendations","method":"get"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Full-text search across movies, TV shows, and people via TMDB. Optionally filter by media type.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/search","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/discover/search","method":"get"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch today's trending movies and/or TV shows from TMDB, including a featured hero title and the user's statuses.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/explore/trending","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/discover/trending","method":"get"}]} />
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
title: Batch mark episodes watched
|
||||
full: true
|
||||
_openapi:
|
||||
method: POST
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Record watch events for multiple episodes at once. Useful for marking
|
||||
a range of episodes as watched.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Record watch events for multiple episodes at once. Useful for marking a range of episodes as watched.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/episodes/batch-watch","method":"post"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Mark episode unwatched
|
||||
full: true
|
||||
_openapi:
|
||||
method: POST
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Remove the watch record for a single TV episode.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Remove the watch record for a single TV episode.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/episodes/{id}/unwatch","method":"post"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Mark episode watched
|
||||
full: true
|
||||
_openapi:
|
||||
method: POST
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Record a watch event for a single TV episode.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Record a watch event for a single TV episode.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/episodes/{id}/watch","method":"post"}]} />
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
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"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch TV shows the user is currently watching, with the next unwatched episode and progress for each.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/dashboard/continue-watching","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/library/continue-watching","method":"get"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch upcoming episodes and movie releases for titles in the user's library, sorted by date. Supports cursor-based pagination.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/dashboard/upcoming","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/library/upcoming","method":"get"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: List all platforms
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Fetch all available streaming platforms, ordered by popularity.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Fetch all available streaming platforms, ordered by popularity.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/platforms","method":"get"}]} />
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
title: Mark season unwatched
|
||||
full: true
|
||||
_openapi:
|
||||
method: POST
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Remove all watch records for episodes in a season in a single
|
||||
operation.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Remove all watch records for episodes in a season in a single operation.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/seasons/{id}/unwatch","method":"post"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Mark season watched
|
||||
full: true
|
||||
_openapi:
|
||||
method: POST
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Mark all episodes in a season as watched in a single operation.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Mark all episodes in a season as watched in a single operation.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/seasons/{id}/watch","method":"post"}]} />
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
title: Get authentication config
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Fetch the authentication configuration including OIDC availability,
|
||||
password login status, and registration state. Does not require
|
||||
authentication.
|
||||
---
|
||||
|
||||
{/* 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 authentication configuration including OIDC availability, password login status, and registration state. Does not require authentication.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/system/auth-config","method":"get"}]} />
|
||||
@@ -8,12 +8,12 @@ _openapi:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Fetch public information about this Sofa instance. Does not require
|
||||
authentication. Used by the login screen to display instance details.
|
||||
Fetch public information about this Sofa instance including
|
||||
authentication configuration. Does not require authentication.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Fetch public information about this Sofa instance. Does not require authentication. Used by the login screen to display instance details.
|
||||
Fetch public information about this Sofa instance including authentication configuration. Does not require authentication.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/system/public-info","method":"get"}]} />
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Get title recommendations
|
||||
title: Get similar titles
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch similar titles based on locally cached recommendation data, along with the user's statuses for each.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/{id}/recommendations","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/{id}/similar","method":"get"}]} />
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
title: Mark all episodes watched
|
||||
full: true
|
||||
_openapi:
|
||||
method: POST
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Mark every episode of a TV show as watched. Requires seasons to be
|
||||
hydrated first.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Mark every episode of a TV show as watched. Requires seasons to be hydrated first.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/{id}/watch-all","method":"post"}]} />
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Mark movie as watched
|
||||
full: true
|
||||
_openapi:
|
||||
method: POST
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Log a watch event for a movie. Automatically sets status to completed.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
Log a watch event for a movie. Automatically sets status to completed.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/{id}/watch","method":"post"}]} />
|
||||
+1
-1
@@ -16,4 +16,4 @@ _openapi:
|
||||
|
||||
Fetch the user's watch counts grouped by time period. Useful for rendering activity charts.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/dashboard/watch-history","method":"get"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/history","method":"get"}]} />
|
||||
+1
-1
@@ -17,4 +17,4 @@ _openapi:
|
||||
|
||||
Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/{id}/quick-add","method":"post"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/titles/{id}/quick-add","method":"post"}]} />
|
||||
+1
-1
@@ -14,4 +14,4 @@ _openapi:
|
||||
|
||||
Set a 0-5 star rating for a title. Use 0 to clear the rating.
|
||||
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/titles/{id}/rating","method":"put"}]} />
|
||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/titles/{id}/rating","method":"put"}]} />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user