fix: address review feedback on API reorganization

- Fix updateRating invalidation in native app — was only invalidating
  title queries, now calls invalidateTitleQueries() to also refresh
  tracking.userInfo (drives the rating UI)
- Make unwatchMovie status revert consistent with unwatchSeries — revert
  any non-watchlist status, not just "completed"
- Add sync invariant comment on handleWatch/handleUnwatch loops
- Remove unused queryClient import in native use-title-actions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:58:26 -04:00
co-authored by Claude Opus 4.6
parent 73e5c96bd8
commit d2ddc0fd34
23 changed files with 188 additions and 386 deletions
+8 -8
View File
@@ -51,14 +51,14 @@ export default function DashboardScreen() {
"--color-status-completed",
]) as [string, string, string, string];
const stats = useQuery(orpc.tracking.stats.queryOptions());
const libraryStats = useQuery(orpc.library.stats.queryOptions());
const movieHistory = useQuery(
orpc.tracking.history.queryOptions({
orpc.tracking.stats.queryOptions({
input: { type: "movie", period: moviePeriod },
}),
);
const episodeHistory = useQuery(
orpc.tracking.history.queryOptions({
orpc.tracking.stats.queryOptions({
input: { type: "episode", period: episodePeriod },
}),
);
@@ -67,7 +67,7 @@ export default function DashboardScreen() {
const recommendations = useQuery(orpc.discover.recommendations.queryOptions());
const isRefreshing =
stats.isRefetching ||
libraryStats.isRefetching ||
continueWatching.isRefetching ||
library.isRefetching ||
movieHistory.isRefetching ||
@@ -82,8 +82,8 @@ export default function DashboardScreen() {
const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0;
const hasRecommendations = (recommendations.data?.items?.length ?? 0) > 0;
const movieCount = movieHistory.data?.count ?? stats.data?.moviesThisMonth;
const episodeCount = episodeHistory.data?.count ?? stats.data?.episodesThisWeek;
const movieCount = movieHistory.data?.count;
const episodeCount = episodeHistory.data?.count;
const periodLabels: Record<TimePeriod, string> = {
today: t`today`,
@@ -144,7 +144,7 @@ export default function DashboardScreen() {
<View className="flex-row gap-3">
<StatsCard
label={t`In Library`}
value={stats.data?.librarySize}
value={libraryStats.data?.size}
icon={IconBooks}
color="text-status-watchlist"
tintColor={watchlistColor}
@@ -152,7 +152,7 @@ export default function DashboardScreen() {
/>
<StatsCard
label={t`Completed`}
value={stats.data?.completed}
value={libraryStats.data?.completed}
icon={IconCheck}
color="text-status-completed"
tintColor={completedColor}
@@ -246,9 +246,12 @@ export default function LibraryScreen() {
enabled: true,
});
const { quickAdd } = useTitleActions();
const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]);
const addingId = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback(
(id: string) => updateStatus.mutate({ id, status: "watchlist" }),
[updateStatus],
);
const addingId = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null;
const allItems = useMemo(
() => libraryQuery.data?.pages.flatMap((page) => page.items) ?? [],
@@ -32,13 +32,13 @@ export default function SearchScreen() {
}),
});
const { quickAdd: quickAddMutation } = useTitleActions();
const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback(
(id: string) => {
quickAddMutation.mutate({ id });
updateStatus.mutate({ id, status: "watchlist" });
},
[quickAddMutation],
[updateStatus],
);
// Memoize mapped results to maintain stable references
@@ -57,7 +57,7 @@ export default function SearchScreen() {
[searchResults.data?.pages],
);
const addingId = quickAddMutation.isPending ? (quickAddMutation.variables?.id ?? null) : null;
const addingId = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null;
const renderItem = useCallback(
({ item }: { item: SearchResultItem }) => (
+4 -4
View File
@@ -62,12 +62,12 @@ export default function PersonDetailScreen() {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const { quickAdd } = useTitleActions();
const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback(
(titleId: string) => quickAdd.mutate({ id: titleId }),
[quickAdd],
(titleId: string) => updateStatus.mutate({ id: titleId, status: "watchlist" }),
[updateStatus],
);
const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
const addingKey = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null;
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(
+3 -10
View File
@@ -108,12 +108,7 @@ export default function TitleDetailScreen() {
const userInfo = useQuery(orpc.tracking.userInfo.queryOptions({ input: { id } }));
const recommendations = useQuery(orpc.titles.similar.queryOptions({ input: { id } }));
const {
updateStatus,
updateRating,
watchMovie,
quickAdd: quickAddMutation,
} = useTitleActions({
const { updateStatus, updateRating, watchMovie } = useTitleActions({
toasts: {
watchMovie: () => {
const name = title?.title;
@@ -413,14 +408,12 @@ export default function TitleDetailScreen() {
currentStatus={userInfo.data?.status ?? null}
onStatusChange={(status) => {
if (status === "in_watchlist") {
quickAddMutation.mutate({ id });
updateStatus.mutate({ id, status: "watchlist" });
} else {
updateStatus.mutate({ id, status: null });
}
}}
isPending={
updateStatus.isPending || quickAddMutation.isPending || watchMovie.isPending
}
isPending={updateStatus.isPending || watchMovie.isPending}
/>
{title.type === "movie" && (
@@ -29,9 +29,12 @@ export function HorizontalPosterRow({
items: PosterRowItem[];
isLoading?: boolean;
}) {
const { quickAdd } = useTitleActions();
const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]);
const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null;
const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback(
(id: string) => updateStatus.mutate({ id, status: "watchlist" }),
[updateStatus],
);
const addingKey = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null;
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => (
@@ -131,7 +131,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
<Link.MenuAction
title={t`Add to Watchlist`}
icon="bookmark"
onPress={() => titleActions.quickAdd(item.id, item.title)}
onPress={() => titleActions.addToWatchlist(item.id, item.title)}
/>
</Link.Menu>
</Link>
+1 -19
View File
@@ -3,7 +3,6 @@ import { useLingui } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { invalidateTitleQueries } from "@/lib/title-actions";
import { toast } from "@/lib/toast";
@@ -25,7 +24,6 @@ interface WatchInput {
interface UseTitleActionsOptions {
toasts?: {
quickAdd?: ToastOverride<{ id: string }>;
updateStatus?: ToastOverride<{ id: string; status: string | null }>;
watchMovie?: ToastOverride<WatchInput>;
updateRating?: ToastOverride<{ id: string; stars: number }>;
@@ -43,20 +41,6 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const { t } = useLingui();
const toastOverrides = options?.toasts;
const quickAdd = useMutation(
orpc.tracking.quickAdd.mutationOptions({
onSuccess: (_data, input) => {
toast.success(resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input));
invalidateTitleQueries();
},
onError: () => {
toast.error(t`Failed to add to watchlist`);
// Refetch so optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
}),
);
const updateStatus = useMutation(
orpc.tracking.updateStatus.mutationOptions({
onSuccess: (_data, input) => {
@@ -92,8 +76,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 tracking
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
invalidateTitleQueries();
},
onError: () => toast.error(t`Failed to update rating`),
}),
@@ -130,7 +113,6 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
);
return {
quickAdd,
updateStatus,
watchMovie,
updateRating,
+2 -2
View File
@@ -27,9 +27,9 @@ export function invalidateTitleQueries() {
* Each method calls the RPC, shows a toast, and invalidates the relevant queries.
*/
export const titleActions = {
async quickAdd(id: string, titleName?: string) {
async addToWatchlist(id: string, titleName?: string) {
try {
await client.tracking.quickAdd({ id });
await client.tracking.updateStatus({ id, status: "watchlist" });
toast.success(
titleName
? i18n._(msg`Added "${titleName}" to watchlist`)
+6 -1
View File
@@ -1,4 +1,4 @@
import { getContinueWatchingFeed, getUpcomingFeed } from "@sofa/core/discovery";
import { getContinueWatchingFeed, getUserStats, getUpcomingFeed } from "@sofa/core/discovery";
import { getFilteredLibraryFeed, getLibraryGenresList } from "@sofa/core/library";
import { tmdbImageUrl } from "@sofa/tmdb/image";
@@ -47,6 +47,11 @@ export const genres = os.library.genres.use(authed).handler(({ context }) => {
return { genres: getLibraryGenresList(context.user.id) };
});
export const stats = os.library.stats.use(authed).handler(({ context }) => {
const userStats = getUserStats(context.user.id);
return { size: userStats.librarySize, completed: userStats.completed };
});
export const continueWatching = os.library.continueWatching.use(authed).handler(({ context }) => {
const feed = getContinueWatchingFeed(context.user.id);
const items = feed.map((item) => ({
+22 -36
View File
@@ -1,8 +1,5 @@
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 { getWatchCount, getWatchHistory } from "@sofa/core/discovery";
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import {
getDisplayStatusesByTitleIds,
@@ -14,7 +11,6 @@ import {
quickAddTitle,
rateTitleStars,
removeTitleStatus,
setTitleStatus,
unwatchEpisode,
unwatchMovie,
unwatchSeason,
@@ -32,6 +28,8 @@ const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const;
// ─── Watch handlers by scope ──────────────────────────────────
// All tracking core functions are synchronous (bun:sqlite). If any become
// async, these loops need to be awaited to surface errors properly.
function handleWatch(userId: string, scope: WatchScopeType, ids: string[]) {
switch (scope) {
case "movie":
@@ -80,13 +78,22 @@ 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 updateStatus = os.tracking.updateStatus
.use(authed)
.handler(async ({ input, context }) => {
if (input.status === null) {
removeTitleStatus(context.user.id, input.id);
return;
}
// Auto-import from TMDB if the title is a shell (absorbs quickAdd logic)
const result = quickAddTitle(context.user.id, input.id);
if (result && !result.alreadyAdded) {
getOrFetchTitleByTmdbId(result.tmdbId, result.type as "movie" | "tv").catch((err) => {
log.warn(`Failed to import ${result.type} TMDB ${result.tmdbId}:`, err);
});
}
});
export const rate = os.tracking.rate.use(authed).handler(({ input, context }) => {
rateTitleStars(context.user.id, input.id, input.stars);
@@ -100,30 +107,9 @@ export const userInfo = os.tracking.userInfo.use(authed).handler(({ input, conte
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 }) => {
export const stats = os.tracking.stats.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 };
const history = getWatchHistory(context.user.id, coreType, input.period);
return { count, history };
});
+1 -2
View File
@@ -20,13 +20,12 @@ export const implementedRouter = {
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,
stats: library.stats,
continueWatching: library.continueWatching,
upcoming: library.upcoming,
},
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import type { DashboardStats, HistoryBucket, TimePeriod } from "@sofa/api/schemas";
import type { HistoryBucket, LibraryStats, TimePeriod } from "@sofa/api/schemas";
import { Sparkline } from "./sparkline";
@@ -152,27 +152,38 @@ function PeriodSelector({
);
}
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
interface WatchStats {
count: number;
history: HistoryBucket[];
}
interface StatsDisplayProps {
movieStats: WatchStats;
episodeStats: WatchStats;
libraryStats: LibraryStats;
}
export function StatsDisplay({ movieStats, episodeStats, libraryStats }: StatsDisplayProps) {
const { t } = useLingui();
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
const { data: movieStats } = useQuery(
orpc.tracking.history.queryOptions({
const { data: moviePeriodStats } = useQuery(
orpc.tracking.stats.queryOptions({
input: { type: "movie", period: moviePeriod },
}),
);
const { data: episodeStats } = useQuery(
orpc.tracking.history.queryOptions({
const { data: episodePeriodStats } = useQuery(
orpc.tracking.stats.queryOptions({
input: { type: "episode", period: episodePeriod },
}),
);
const movieCount = movieStats?.count ?? stats.moviesThisMonth;
const movieHistory = movieStats?.history;
const movieCount = moviePeriodStats?.count ?? movieStats.count;
const movieHistory = moviePeriodStats?.history ?? movieStats.history;
const episodeCount = episodeStats?.count ?? stats.episodesThisWeek;
const episodeHistory = episodeStats?.history;
const episodeCount = episodePeriodStats?.count ?? episodeStats.count;
const episodeHistory = episodePeriodStats?.history ?? episodeStats.history;
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
@@ -206,7 +217,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
icon={IconBooks}
color="text-status-watchlist"
bgColor="bg-status-watchlist/10"
value={stats.librarySize}
value={libraryStats.size}
index={2}
label={t`In Library`}
/>
@@ -214,7 +225,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
icon={IconCheck}
color="text-status-completed"
bgColor="bg-status-completed/10"
value={stats.completed}
value={libraryStats.completed}
index={3}
label={t`Completed`}
/>
@@ -8,20 +8,34 @@ import { orpc } from "@/lib/orpc/client";
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
export function StatsSection() {
const { data: stats, isPending } = useQuery(orpc.tracking.stats.queryOptions());
const { data: movieStats, isPending: moviesPending } = useQuery(
orpc.tracking.stats.queryOptions({ input: { type: "movie", period: "this_month" } }),
);
const { data: episodeStats, isPending: episodesPending } = useQuery(
orpc.tracking.stats.queryOptions({ input: { type: "episode", period: "this_week" } }),
);
const { data: libraryStats, isPending: libraryPending } = useQuery(
orpc.library.stats.queryOptions(),
);
const isPending = moviesPending || episodesPending || libraryPending;
if (isPending) return <StatsSectionSkeleton />;
if (!stats) return null;
if (!movieStats || !episodeStats || !libraryStats) return null;
const isEmpty =
stats.moviesThisMonth === 0 &&
stats.episodesThisWeek === 0 &&
stats.librarySize === 0 &&
stats.completed === 0;
movieStats.count === 0 &&
episodeStats.count === 0 &&
libraryStats.size === 0 &&
libraryStats.completed === 0;
return (
<>
<StatsDisplay stats={stats} />
<StatsDisplay
movieStats={movieStats}
episodeStats={episodeStats}
libraryStats={libraryStats}
/>
{isEmpty && (
<div className="border-border/50 flex flex-col items-center gap-4 rounded-xl border border-dashed py-16 text-center">
<div className="bg-primary/10 rounded-full p-4">
+6 -6
View File
@@ -88,8 +88,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
const statusConfig = useStatusConfig();
const [optimisticStatus, setOptimisticStatus] = useState<TitleStatus | null>(null);
const quickAddMutation = useMutation(
orpc.tracking.quickAdd.mutationOptions({
const addToWatchlistMutation = useMutation(
orpc.tracking.updateStatus.mutationOptions({
onSuccess: () => setOptimisticStatus("in_watchlist"),
}),
);
@@ -101,8 +101,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (quickAddMutation.isPending || isAdded) return;
quickAddMutation.mutate({ id });
if (addToWatchlistMutation.isPending || isAdded) return;
addToWatchlistMutation.mutate({ id, status: "watchlist" });
}
if (isAdded && config) {
@@ -127,8 +127,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
className="absolute end-2 top-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
render={<button type="button" />}
>
{!quickAddMutation.isPending && <IconPlus className="size-4" />}
{quickAddMutation.isPending && <IconLoader className="size-4 animate-spin" />}
{!addToWatchlistMutation.isPending && <IconPlus className="size-4" />}
{addToWatchlistMutation.isPending && <IconLoader className="size-4 animate-spin" />}
</TooltipTrigger>
<TooltipContent side="bottom">{t`Add to Watchlist`}</TooltipContent>
</Tooltip>
+7 -1
View File
@@ -17,7 +17,13 @@ export const Route = createFileRoute("/_app/dashboard")({
staleTime: 30_000,
loader: async ({ context }) => {
await Promise.all([
context.queryClient.ensureQueryData(orpc.tracking.stats.queryOptions()),
context.queryClient.ensureQueryData(
orpc.tracking.stats.queryOptions({ input: { type: "movie", period: "this_month" } }),
),
context.queryClient.ensureQueryData(
orpc.tracking.stats.queryOptions({ input: { type: "episode", period: "this_week" } }),
),
context.queryClient.ensureQueryData(orpc.library.stats.queryOptions()),
context.queryClient.ensureQueryData(orpc.library.continueWatching.queryOptions()),
context.queryClient.ensureQueryData(orpc.discover.recommendations.queryOptions()),
context.queryClient.ensureQueryData(
@@ -1,5 +1,5 @@
---
title: Get watch history
title: Get library statistics
full: true
_openapi:
method: GET
@@ -7,13 +7,11 @@ _openapi:
structuredData:
headings: []
contents:
- content: >-
Fetch the user's watch counts grouped by time period. Useful for
rendering activity charts.
- content: 'Fetch aggregate library counts: total titles and completed titles.'
---
{/* 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 user's watch counts grouped by time period. Useful for rendering activity charts.
Fetch aggregate library counts: total titles and completed titles.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/history","method":"get"}]} />
<APIPage document={"./public/openapi.json"} operations={[{"path":"/library/stats","method":"get"}]} />
@@ -1,20 +0,0 @@
---
title: Quick add title to library
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: >-
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.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
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":"/tracking/titles/{id}/quick-add","method":"post"}]} />
@@ -8,12 +8,12 @@ _openapi:
headings: []
contents:
- content: >-
Fetch summary counts for the current user: movies watched this month,
episodes this week, library size, and completed titles.
Fetch the user's watch counts grouped by time period. Useful for
rendering activity charts and dashboard counters.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.
Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/stats","method":"get"}]} />
+43 -188
View File
@@ -2127,198 +2127,11 @@
]
}
},
"/tracking/titles/{id}/quick-add": {
"post": {
"operationId": "tracking.quickAdd",
"summary": "Quick add title to library",
"description": "Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
"tags": [
"Tracking"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 1,
"description": "Internal UUIDv7 identifier"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Title ID and whether it was already in the library",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Internal title ID"
},
"alreadyAdded": {
"type": "boolean",
"description": "True if the title was already in the user's library"
}
},
"required": [
"id",
"alreadyAdded"
],
"description": "Result of a quick-add operation"
}
}
}
},
"404": {
"description": "404",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "NOT_FOUND"
},
"status": {
"const": 404
},
"message": {
"type": "string",
"default": "Title not found"
},
"data": {
"type": "object",
"properties": {
"code": {
"const": "TITLE_NOT_FOUND"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message",
"data"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/tracking/stats": {
"get": {
"operationId": "tracking.stats",
"summary": "Get watch statistics",
"description": "Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.",
"tags": [
"Tracking"
],
"responses": {
"200": {
"description": "Aggregate watch statistics",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"moviesThisMonth": {
"type": "number",
"description": "Movies watched in the current calendar month"
},
"episodesThisWeek": {
"type": "number",
"description": "Episodes watched in the current calendar week"
},
"librarySize": {
"type": "number",
"description": "Total titles in the user's library"
},
"completed": {
"type": "number",
"description": "Total titles with completed status"
}
},
"required": [
"moviesThisMonth",
"episodesThisWeek",
"librarySize",
"completed"
],
"description": "Aggregate watch statistics for the dashboard"
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/tracking/history": {
"get": {
"operationId": "tracking.history",
"summary": "Get watch history",
"description": "Fetch the user's watch counts grouped by time period. Useful for rendering activity charts.",
"description": "Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.",
"tags": [
"Tracking"
],
@@ -2821,6 +2634,48 @@
]
}
},
"/library/stats": {
"get": {
"operationId": "library.stats",
"summary": "Get library statistics",
"description": "Fetch aggregate library counts: total titles and completed titles.",
"tags": [
"Library"
],
"responses": {
"200": {
"description": "Library size and completed count",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"size": {
"type": "number",
"description": "Total titles in the user's library"
},
"completed": {
"type": "number",
"description": "Total titles with completed status"
}
},
"required": [
"size",
"completed"
],
"description": "Aggregate library statistics"
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/library/continue-watching": {
"get": {
"operationId": "library.continueWatching",
+12 -32
View File
@@ -11,7 +11,6 @@ import {
ContinueWatchingOutput,
CreateImportJobInput,
CreateIntegrationInput,
DashboardStatsOutput,
DiscoverInput,
DiscoverOutput,
DiscoverRecommendationsOutput,
@@ -26,6 +25,7 @@ import {
LibraryGenresOutput,
LibraryListInput,
LibraryListOutput,
LibraryStatsOutput,
MediaTypeParam,
PageParam,
PaginatedInput,
@@ -38,7 +38,6 @@ import {
PublicInfoOutput,
PurgeImageCacheOutput,
PurgeMetadataCacheOutput,
QuickAddOutput,
RestoreBackupInput,
SearchInput,
SearchOutput,
@@ -166,24 +165,6 @@ export const contract = {
})
.input(IdParam)
.output(UserInfoOutput),
quickAdd: oc
.route({
method: "POST",
path: "/tracking/titles/{id}/quick-add",
tags: ["Tracking"],
summary: "Quick add title to library",
description:
"Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
successDescription: "Title ID and whether it was already in the library",
})
.input(IdParam)
.output(QuickAddOutput)
.errors({
NOT_FOUND: {
message: "Title not found",
data: appErrorData(AppErrorCode.TITLE_NOT_FOUND),
},
}),
stats: oc
.route({
method: "GET",
@@ -191,18 +172,7 @@ export const contract = {
tags: ["Tracking"],
summary: "Get watch statistics",
description:
"Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.",
successDescription: "Aggregate watch statistics",
})
.output(DashboardStatsOutput),
history: oc
.route({
method: "GET",
path: "/tracking/history",
tags: ["Tracking"],
summary: "Get watch history",
description:
"Fetch the user's watch counts grouped by time period. Useful for rendering activity charts.",
"Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.",
successDescription: "Watch counts bucketed by time period",
})
.input(WatchHistoryInput)
@@ -234,6 +204,16 @@ export const contract = {
successDescription: "Genres present in the library",
})
.output(LibraryGenresOutput),
stats: oc
.route({
method: "GET",
path: "/library/stats",
tags: ["Library"],
summary: "Get library statistics",
description: "Fetch aggregate library counts: total titles and completed titles.",
successDescription: "Library size and completed count",
})
.output(LibraryStatsOutput),
continueWatching: oc
.route({
method: "GET",
+4 -17
View File
@@ -537,14 +537,12 @@ export const PersonDetailOutput = z
// ─── Dashboard outputs ─────────────────────────────────────────
export const DashboardStatsOutput = z
export const LibraryStatsOutput = z
.object({
moviesThisMonth: z.number().describe("Movies watched in the current calendar month"),
episodesThisWeek: z.number().describe("Episodes watched in the current calendar week"),
librarySize: z.number().describe("Total titles in the user's library"),
size: z.number().describe("Total titles in the user's library"),
completed: z.number().describe("Total titles with completed status"),
})
.meta({ description: "Aggregate watch statistics for the dashboard" });
.meta({ description: "Aggregate library statistics" });
export const ContinueWatchingOutput = z
.object({
@@ -1000,17 +998,6 @@ export const PurgeImageCacheOutput = z
})
.meta({ description: "Result of purging the image cache from disk" });
// ─── Quick add output ──────────────────────────────────────────
export const QuickAddOutput = z
.object({
id: z.string().describe("Internal title ID"),
alreadyAdded: z.boolean().describe("True if the title was already in the user's library"),
})
.meta({
description: "Result of a quick-add operation",
});
// ─── System outputs ───────────────────────────────────────────
export const PublicInfoOutput = z
@@ -1225,7 +1212,7 @@ export type BackupInfo = z.infer<typeof BackupSchema>;
export type CastMember = z.infer<typeof CastMemberSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
export type CronJobName = z.infer<typeof cronJobName>;
export type DashboardStats = z.infer<typeof DashboardStatsOutput>;
export type LibraryStats = z.infer<typeof LibraryStatsOutput>;
export type Episode = z.infer<typeof EpisodeSchema>;
export type HistoryBucket = z.infer<typeof HistoryBucketSchema>;
export type PersonCredit = z.infer<typeof PersonCreditSchema>;
+1 -1
View File
@@ -163,7 +163,7 @@ export function unwatchMovie(userId: string, titleId: string) {
deleteMovieWatches(userId, titleId);
const existing = getTitleStatus(userId, titleId);
if (existing?.status === "completed") {
if (existing && existing.status !== "watchlist") {
setTitleStatus(userId, titleId, "watchlist");
}
}