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:
2026-03-26 12:36:49 -04:00
co-authored by Claude Opus 4.6
parent 3906f6021d
commit 73e5c96bd8
112 changed files with 3291 additions and 4015 deletions
+5 -5
View File
@@ -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() });
}, []);
+6 -6
View File
@@ -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 })
+40 -32
View File
@@ -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>
+1 -1
View File
@@ -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) =>
+4 -4
View File
@@ -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">
+17 -12
View File
@@ -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();
+11 -10
View File
@@ -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`),
);
+1 -1
View File
@@ -33,7 +33,7 @@ vi.mock("@/lib/widget-assets", () => ({
vi.mock("@/lib/orpc", () => ({
client: {
dashboard: {
library: {
continueWatching,
upcoming,
},
+2 -2
View File
@@ -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,
});
+6 -12
View File
@@ -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({
+43 -3
View File
@@ -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);
});
+40 -40
View File
@@ -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 };
});
+351 -4
View File
@@ -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);
});
-177
View File
@@ -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,
};
});
+1 -1
View File
@@ -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,
})),
};
});
-161
View File
@@ -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",
};
});
+7 -8
View File
@@ -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" };
});
+10 -70
View File
@@ -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 };
});
+129
View File
@@ -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 };
});
+44 -66
View File
@@ -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,
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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);
+2 -1
View File
@@ -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
}
+4 -4
View File
@@ -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 } }),
+10 -10
View File
@@ -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;
+2 -2
View File
@@ -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) => {
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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 };
+2 -2
View File
@@ -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,
+8 -8
View File
@@ -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 -7
View File
@@ -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,
}}
/>
);
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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"}]} />
@@ -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