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
+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,
},