refactor: organize API around operation domains (#24)

* 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>

* 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>

* fix: updateStatus NOT_FOUND error + rate() invalidation in native

- updateStatus now throws NOT_FOUND when quickAddTitle returns null
  (title doesn't exist), instead of silently succeeding
- Add NOT_FOUND error to updateStatus contract definition
- Fix rate() in native title-actions.ts to call invalidateTitleQueries()
  instead of only invalidating orpc.titles.key() — mirrors the fix
  already applied to the hook-based path in d2ddc0f

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: expand mobile app docs and add Play Store badge

- Add the Google Play badge to the README alongside the App Store badge
- Expand the mobile app docs with the Android/Play Store release details
- Refresh docs site dependencies and related package versions for the updated docs stack

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 15:44:05 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3906f6021d
commit dce333e128
123 changed files with 3566 additions and 4325 deletions
+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, getUserStats, getUpcomingFeed } from "@sofa/core/discovery";
import { getFilteredLibraryFeed, getLibraryGenresList } from "@sofa/core/library";
import { tmdbImageUrl } from "@sofa/tmdb/image";
@@ -45,3 +46,56 @@ 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 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) => ({
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 };
});
+124
View File
@@ -0,0 +1,124 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import type { WatchScopeType } from "@sofa/api/schemas";
import { getWatchCount, getWatchHistory } from "@sofa/core/discovery";
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import {
getDisplayStatusesByTitleIds,
getUserTitleInfo,
logEpisodeWatch,
logEpisodeWatchBatch,
logMovieWatch,
markAllEpisodesWatched,
quickAddTitle,
rateTitleStars,
removeTitleStatus,
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 ──────────────────────────────────
// 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":
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(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) {
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
}
if (!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);
});
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 stats = os.tracking.stats.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 };
});
+43 -66
View File
@@ -1,75 +1,68 @@
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,
stats: tracking.stats,
},
library: {
list: library.list,
genres: library.genres,
stats: library.stats,
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 +71,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,