refactor: replace Biome with oxlint + oxfmt

Migrate the entire monorepo from Biome 2.4.7 to oxlint 1.56.0 (linter)
and oxfmt 0.41.0 (formatter) for faster lint/format and broader rule
coverage.

- Add `.oxlintrc.json` with React, TypeScript, unicorn, import plugins
  and correctness/suspicious categories
- Add `.oxfmtrc.json` with 2-space indent, import sorting, and Tailwind
  class sorting (all 30+ custom className attributes migrated)
- Add `docs/.oxlintrc.json` and `docs/.oxfmtrc.json` with Next.js plugin
- Update all 12 workspace package.json scripts: `oxlint`, `oxfmt`,
  `oxfmt --check`
- Add `format:check` turbo task and CI step
- Update VS Code settings/extensions to use `oxc.oxc-vscode`
- Update CI path triggers from `biome.json` to new config files
- Remove all `biome-ignore` comments and fix shadowed variables
- Delete `biome.json` and `docs/biome.json`
- Reformat entire codebase with oxfmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 13:26:50 -04:00
co-authored by Claude Opus 4.6
parent a80c8c9a0c
commit 2b0c683b7b
359 changed files with 3894 additions and 7208 deletions
+18 -71
View File
@@ -1,3 +1,5 @@
import { Cron } from "croner";
import { refreshAvailability } from "@sofa/core/availability";
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits";
@@ -16,10 +18,7 @@ import {
} from "@sofa/core/metadata";
import { getSetting } from "@sofa/core/settings";
import { performTelemetryReport } from "@sofa/core/telemetry";
import {
generateTitleBackdropThumbHash,
generateTitlePosterThumbHash,
} from "@sofa/core/thumbhash";
import { generateTitleBackdropThumbHash, generateTitlePosterThumbHash } from "@sofa/core/thumbhash";
import { performUpdateCheck } from "@sofa/core/update-check";
import { db } from "@sofa/db/client";
import { and, eq, inArray, isNotNull, lt, or, sql } from "@sofa/db/helpers";
@@ -35,7 +34,6 @@ import {
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getTvDetails } from "@sofa/tmdb/client";
import { Cron } from "croner";
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
@@ -132,14 +130,8 @@ function getThumbhashBackfillTitleIds(): string[] {
.from(titles)
.where(
or(
and(
isNotNull(titles.posterPath),
sql`${titles.posterThumbHash} IS NULL`,
),
and(
isNotNull(titles.backdropPath),
sql`${titles.backdropThumbHash} IS NULL`,
),
and(isNotNull(titles.posterPath), sql`${titles.posterThumbHash} IS NULL`),
and(isNotNull(titles.backdropPath), sql`${titles.backdropThumbHash} IS NULL`),
),
)
.all()
@@ -150,12 +142,7 @@ function getThumbhashBackfillTitleIds(): string[] {
db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(
and(
isNotNull(seasons.posterPath),
sql`${seasons.posterThumbHash} IS NULL`,
),
)
.where(and(isNotNull(seasons.posterPath), sql`${seasons.posterThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId),
@@ -166,12 +153,7 @@ function getThumbhashBackfillTitleIds(): string[] {
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(
and(
isNotNull(episodes.stillPath),
sql`${episodes.stillThumbHash} IS NULL`,
),
)
.where(and(isNotNull(episodes.stillPath), sql`${episodes.stillThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId),
@@ -182,12 +164,7 @@ function getThumbhashBackfillTitleIds(): string[] {
.select({ titleId: titleCast.titleId })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(
and(
isNotNull(persons.profilePath),
sql`${persons.profileThumbHash} IS NULL`,
),
)
.where(and(isNotNull(persons.profilePath), sql`${persons.profileThumbHash} IS NULL`))
.groupBy(titleCast.titleId)
.all()
.map((row) => row.titleId),
@@ -207,12 +184,7 @@ async function nightlyRefreshLibrary() {
const staleLibrary = db
.select({ id: titles.id })
.from(titles)
.where(
and(
inArray(titles.id, libraryIds),
lt(titles.lastFetchedAt, libraryStale),
),
)
.where(and(inArray(titles.id, libraryIds), lt(titles.lastFetchedAt, libraryStale)))
.all();
for (const { id } of staleLibrary) {
@@ -224,12 +196,7 @@ async function nightlyRefreshLibrary() {
const nonLibrary = db
.select()
.from(titles)
.where(
and(
isNotNull(titles.lastFetchedAt),
lt(titles.lastFetchedAt, nonLibraryStale),
),
)
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, nonLibraryStale)))
.limit(50)
.all();
@@ -282,9 +249,7 @@ async function refreshAvailabilityJob() {
async function refreshRecommendationsJob() {
const libraryIds = getLibraryTitleIds();
log.debug(
`Refreshing recommendations for ${libraryIds.length} library titles`,
);
log.debug(`Refreshing recommendations for ${libraryIds.length} library titles`);
for (const titleId of libraryIds) {
await refreshRecommendations(titleId);
@@ -316,12 +281,7 @@ async function refreshTvChildrenJob() {
? db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(
and(
inArray(seasons.titleId, tvIds),
lt(seasons.lastFetchedAt, stale),
),
)
.where(and(inArray(seasons.titleId, tvIds), lt(seasons.lastFetchedAt, stale)))
.groupBy(seasons.titleId)
.all()
.map((r) => r.titleId)
@@ -340,17 +300,11 @@ async function refreshTvChildrenJob() {
async function cacheImagesJob() {
const titleIds = getThumbhashBackfillTitleIds();
log.debug(
`Caching images for ${titleIds.length} titles needing art backfill`,
);
log.debug(`Caching images for ${titleIds.length} titles needing art backfill`);
for (const titleId of titleIds) {
try {
const title = db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) continue;
// Phase 1: warm the image cache so thumbhash generation can read from disk
@@ -370,18 +324,14 @@ async function cacheImagesJob() {
hashTasks.push(generateTitlePosterThumbHash(titleId, title.posterPath));
}
if (!title.backdropThumbHash && title.backdropPath) {
hashTasks.push(
generateTitleBackdropThumbHash(titleId, title.backdropPath),
);
hashTasks.push(generateTitleBackdropThumbHash(titleId, title.backdropPath));
}
if (title.type === "tv") {
hashTasks.push(syncTvChildArt(titleId, { warmCache: false }));
}
hashTasks.push(
syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }),
);
hashTasks.push(syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }));
await Promise.all(hashTasks);
} catch (err) {
@@ -404,9 +354,7 @@ async function refreshCreditsJob() {
.limit(1)
.get();
const needsRefresh =
!castEntry ||
(castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
const needsRefresh = !castEntry || (castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
if (needsRefresh) {
await refreshCredits(titleId);
@@ -453,8 +401,7 @@ export function buildBackupCron(
}
function getBackupCronFromSettings(): string {
const frequency = (getSetting("backupScheduleFrequency") ??
"1d") as BackupFrequency;
const frequency = (getSetting("backupScheduleFrequency") ?? "1d") as BackupFrequency;
const time = getSetting("backupScheduleTime") ?? "02:00";
const dayOfWeek = Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10);
return buildBackupCron(frequency, time, dayOfWeek);
+5 -3
View File
@@ -1,3 +1,7 @@
import { type Context, Hono } from "hono";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import { CACHE_DIR } from "@sofa/config";
import { ensureBackupDir } from "@sofa/core/backup";
import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache";
@@ -5,9 +9,7 @@ import { registerJobScheduleProvider } from "@sofa/core/system-health";
import { closeDatabase } from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate";
import { createLogger } from "@sofa/logger";
import { type Context, Hono } from "hono";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import { getJobSchedules, startJobs, stopJobs } from "./cron";
import { handler as rpcHandler } from "./orpc/handler";
import { openApiHandler } from "./orpc/openapi-handler";
+1
View File
@@ -1,4 +1,5 @@
import { implement } from "@orpc/server";
import { contract } from "@sofa/api/contract";
export interface Context {
+2
View File
@@ -1,6 +1,8 @@
import { onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import { createLogger } from "@sofa/logger";
import { router } from "./router";
const log = createLogger("orpc");
+1
View File
@@ -1,5 +1,6 @@
import { oo } from "@orpc/openapi";
import { os as baseOs, ORPCError } from "@orpc/server";
import { auth } from "@sofa/auth/server";
const base = baseOs.$context<{ headers: Headers }>();
+4 -7
View File
@@ -2,12 +2,10 @@ import { SmartCoercionPlugin } from "@orpc/json-schema";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
import { onError } from "@orpc/server";
import { createLogger } from "@sofa/logger";
import {
generateOpenApiSpec,
openApiTags,
schemaConverters,
} from "./openapi-spec";
import { generateOpenApiSpec, openApiTags, schemaConverters } from "./openapi-spec";
import { implementedRouter } from "./router";
const log = createLogger("openapi");
@@ -81,8 +79,7 @@ export const openApiHandler = new OpenAPIHandler(implementedRouter, {
],
interceptors: [
async (options) => {
const requestPathname =
options.request.url.pathname.replace(/\/$/, "") || "/";
const requestPathname = options.request.url.pathname.replace(/\/$/, "") || "/";
const prefix = options.prefix?.replace(/\/$/, "") || "";
const specPath = `${prefix}/spec.json`.replace(/\/$/, "") || "/";
+11 -29
View File
@@ -1,8 +1,6 @@
import {
OpenAPIGenerator,
type OpenAPIGeneratorGenerateOptions,
} from "@orpc/openapi";
import { OpenAPIGenerator, type OpenAPIGeneratorGenerateOptions } from "@orpc/openapi";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import {
BackupSchema,
CastMemberSchema,
@@ -18,6 +16,7 @@ import {
SystemHealthSchema,
TmdbBrowseItem,
} from "@sofa/api/schemas";
import { implementedRouter } from "./router";
export const schemaConverters = [new ZodToJsonSchemaConverter()];
@@ -43,16 +42,7 @@ const generator = new OpenAPIGenerator({
schemaConverters,
});
const httpMethods = [
"get",
"put",
"post",
"delete",
"options",
"head",
"patch",
"trace",
] as const;
const httpMethods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const;
type OpenApiSpec = Awaited<ReturnType<OpenAPIGenerator["generate"]>>;
@@ -102,14 +92,10 @@ function normalizeSchema(schema: unknown): unknown {
if (isRecord(normalized.properties)) {
const nextProperties = Object.fromEntries(
Object.entries(normalized.properties).flatMap(
([name, propertySchema]) => {
const nextPropertySchema = normalizeSchema(propertySchema);
return nextPropertySchema === undefined
? []
: [[name, nextPropertySchema]];
},
),
Object.entries(normalized.properties).flatMap(([name, propertySchema]) => {
const nextPropertySchema = normalizeSchema(propertySchema);
return nextPropertySchema === undefined ? [] : [[name, nextPropertySchema]];
}),
);
if (Object.keys(nextProperties).length > 0) {
@@ -121,8 +107,7 @@ function normalizeSchema(schema: unknown): unknown {
if (Array.isArray(normalized.required)) {
const propertyNames = new Set(Object.keys(nextProperties));
const nextRequired = normalized.required.filter(
(name): name is string =>
typeof name === "string" && propertyNames.has(name),
(name): name is string => typeof name === "string" && propertyNames.has(name),
);
if (nextRequired.length > 0) {
@@ -143,9 +128,7 @@ function normalizeSchema(schema: unknown): unknown {
}
if (isRecord(normalized.additionalProperties)) {
const nextAdditionalProperties = normalizeSchema(
normalized.additionalProperties,
);
const nextAdditionalProperties = normalizeSchema(normalized.additionalProperties);
if (nextAdditionalProperties === undefined) {
delete normalized.additionalProperties;
@@ -190,8 +173,7 @@ export function normalizeOpenApiSpec<T extends OpenApiSpec>(spec: T): T {
const nextContent = normalizeContent(operation.requestBody.content);
if (nextContent) {
operation.requestBody.content =
nextContent as typeof operation.requestBody.content;
operation.requestBody.content = nextContent as typeof operation.requestBody.content;
} else {
delete operation.requestBody;
}
+17 -19
View File
@@ -1,7 +1,9 @@
import { mkdir, rename } from "node:fs/promises";
import path from "node:path";
import { auth } from "@sofa/auth/server";
import { AVATAR_DIR } from "@sofa/config";
import { os } from "../context";
import { authed } from "../middleware";
@@ -12,14 +14,12 @@ const MIME_TO_EXT: Record<string, string> = {
"image/gif": "gif",
};
export const updateName = os.account.updateName
.use(authed)
.handler(async ({ input, context }) => {
await auth.api.updateUser({
body: { name: input.name },
headers: context.headers,
});
export const updateName = os.account.updateName.use(authed).handler(async ({ input, context }) => {
await auth.api.updateUser({
body: { name: input.name },
headers: context.headers,
});
});
export const uploadAvatar = os.account.uploadAvatar
.use(authed)
@@ -53,16 +53,14 @@ export const uploadAvatar = os.account.uploadAvatar
return { imageUrl };
});
export const removeAvatar = os.account.removeAvatar
.use(authed)
.handler(async ({ context }) => {
const glob = new Bun.Glob(`${context.user.id}.*`);
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
for (const match of matches) {
await Bun.file(path.join(AVATAR_DIR, match)).delete();
}
await auth.api.updateUser({
body: { image: "" },
headers: context.headers,
});
export const removeAvatar = os.account.removeAvatar.use(authed).handler(async ({ context }) => {
const glob = new Bun.Glob(`${context.user.id}.*`);
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
for (const match of matches) {
await Bun.file(path.join(AVATAR_DIR, match)).delete();
}
await auth.api.updateUser({
body: { image: "" },
headers: context.headers,
});
});
+61 -92
View File
@@ -1,5 +1,7 @@
import path from "node:path";
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { BACKUP_DIR } from "@sofa/config";
import {
@@ -16,59 +18,48 @@ import {
import { getSetting, setSetting } from "@sofa/core/settings";
import { getSystemHealth } from "@sofa/core/system-health";
import { isTelemetryEnabled } from "@sofa/core/telemetry";
import {
getCachedUpdateCheck,
isUpdateCheckEnabled,
} from "@sofa/core/update-check";
import { getCachedUpdateCheck, isUpdateCheckEnabled } from "@sofa/core/update-check";
import { rescheduleBackup, triggerJob as triggerCronJob } from "../../cron";
import { os } from "../context";
import { admin } from "../middleware";
// ─── Backups ───────────────────────────────────────────────────
export const backupsList = os.admin.backups.list
.use(admin)
.handler(async () => {
const backups = await listBackups();
return { backups };
});
export const backupsList = os.admin.backups.list.use(admin).handler(async () => {
const backups = await listBackups();
return { backups };
});
export const backupsCreate = os.admin.backups.create
.use(admin)
.handler(async () => {
return await createBackup();
});
export const backupsCreate = os.admin.backups.create.use(admin).handler(async () => {
return await createBackup();
});
export const backupsDelete = os.admin.backups.delete
.use(admin)
.handler(async ({ input }) => {
try {
await deleteBackup(input.filename);
} catch (err) {
if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("not found")) {
throw new ORPCError("NOT_FOUND", {
message: msg,
data: { code: AppErrorCode.BACKUP_NOT_FOUND },
});
}
throw new ORPCError("BAD_REQUEST", {
export const backupsDelete = os.admin.backups.delete.use(admin).handler(async ({ input }) => {
try {
await deleteBackup(input.filename);
} catch (err) {
if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("not found")) {
throw new ORPCError("NOT_FOUND", {
message: msg,
data: { code: AppErrorCode.BACKUP_DELETE_FAILED },
data: { code: AppErrorCode.BACKUP_NOT_FOUND },
});
}
});
throw new ORPCError("BAD_REQUEST", {
message: msg,
data: { code: AppErrorCode.BACKUP_DELETE_FAILED },
});
}
});
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`,
);
const tmpPath = path.join(BACKUP_DIR, `.upload-${Date.now()}-${crypto.randomUUID()}.db`);
try {
await Bun.write(tmpPath, file);
await restoreFromBackup(tmpPath);
@@ -85,35 +76,23 @@ export const backupsRestore = os.admin.backups.restore
}
});
export const backupsSchedule = os.admin.backups.schedule
.use(admin)
.handler(() => {
return {
enabled: getSetting("scheduledBackups") === "true",
maxRetention: Number.parseInt(
getSetting("maxBackupRetention") ?? "7",
10,
),
frequency: (getSetting("backupScheduleFrequency") ?? "1d") as
| "6h"
| "12h"
| "1d"
| "7d",
time: getSetting("backupScheduleTime") ?? "02:00",
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
};
});
export const backupsSchedule = os.admin.backups.schedule.use(admin).handler(() => {
return {
enabled: getSetting("scheduledBackups") === "true",
maxRetention: Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10),
frequency: (getSetting("backupScheduleFrequency") ?? "1d") as "6h" | "12h" | "1d" | "7d",
time: getSetting("backupScheduleTime") ?? "02:00",
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
};
});
export const backupsUpdateSchedule = os.admin.backups.updateSchedule
.use(admin)
.handler(({ input }) => {
if (input.enabled !== undefined)
setSetting("scheduledBackups", String(input.enabled));
if (input.frequency !== undefined)
setSetting("backupScheduleFrequency", input.frequency);
if (input.enabled !== undefined) setSetting("scheduledBackups", String(input.enabled));
if (input.frequency !== undefined) setSetting("backupScheduleFrequency", input.frequency);
if (input.time !== undefined) setSetting("backupScheduleTime", input.time);
if (input.dayOfWeek !== undefined)
setSetting("backupScheduleDow", String(input.dayOfWeek));
if (input.dayOfWeek !== undefined) setSetting("backupScheduleDow", String(input.dayOfWeek));
if (input.maxRetention !== undefined)
setSetting("maxBackupRetention", String(input.maxRetention));
@@ -128,11 +107,9 @@ 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));
});
export const toggleRegistration = os.admin.toggleRegistration.use(admin).handler(({ input }) => {
setSetting("registrationOpen", String(input.open));
});
// ─── Update Check ──────────────────────────────────────────────
@@ -142,11 +119,9 @@ export const updateCheck = os.admin.updateCheck.use(admin).handler(() => {
return { enabled, updateCheck: check };
});
export const toggleUpdateCheck = os.admin.toggleUpdateCheck
.use(admin)
.handler(({ input }) => {
setSetting("updateCheckEnabled", String(input.enabled));
});
export const toggleUpdateCheck = os.admin.toggleUpdateCheck.use(admin).handler(({ input }) => {
setSetting("updateCheckEnabled", String(input.enabled));
});
// ─── Telemetry ────────────────────────────────────────────────
@@ -157,26 +132,22 @@ export const telemetry = os.admin.telemetry.use(admin).handler(() => {
};
});
export const toggleTelemetry = os.admin.toggleTelemetry
.use(admin)
.handler(({ input }) => {
setSetting("telemetryEnabled", String(input.enabled));
});
export const toggleTelemetry = os.admin.toggleTelemetry.use(admin).handler(({ input }) => {
setSetting("telemetryEnabled", String(input.enabled));
});
// ─── Jobs ──────────────────────────────────────────────────────
export const triggerJob = os.admin.triggerJob
.use(admin)
.handler(async ({ input }) => {
const triggered = await triggerCronJob(input.name);
if (!triggered) {
throw new ORPCError("NOT_FOUND", {
message: "Job not found",
data: { code: AppErrorCode.JOB_NOT_FOUND },
});
}
return { ok: true as const };
});
export const triggerJob = os.admin.triggerJob.use(admin).handler(async ({ input }) => {
const triggered = await triggerCronJob(input.name);
if (!triggered) {
throw new ORPCError("NOT_FOUND", {
message: "Job not found",
data: { code: AppErrorCode.JOB_NOT_FOUND },
});
}
return { ok: true as const };
});
// ─── Purge ────────────────────────────────────────────────────
@@ -190,8 +161,6 @@ export const purgeImageCache = os.admin.purgeImageCache
// ─── System Health ───────────────────────────────────────────────
export const systemHealth = os.admin.systemHealth
.use(admin)
.handler(async () => {
return await getSystemHealth();
});
export const systemHealth = os.admin.systemHealth.use(admin).handler(async () => {
return await getSystemHealth();
});
+61 -68
View File
@@ -7,6 +7,7 @@ import {
getWatchHistory,
} from "@sofa/core/discovery";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
@@ -16,43 +17,59 @@ 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 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 library = os.dashboard.library
.use(authed)
.handler(({ input, context }) => {
const {
items: feed,
page,
totalPages,
totalResults,
} = getLibraryFeed(context.user.id, input.page, input.limit);
const items = feed.map((t) => ({
id: t.titleId,
export const library = os.dashboard.library.use(authed).handler(({ input, context }) => {
const {
items: feed,
page,
totalPages,
totalResults,
} = getLibraryFeed(context.user.id, input.page, input.limit);
const items = feed.map((t) => ({
id: t.titleId,
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,
userStatus: t.userStatus,
}));
return { items, page, totalPages, totalResults };
});
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,
@@ -61,37 +78,13 @@ export const library = os.dashboard.library
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
userStatus: t.userStatus,
}));
return { items, page, totalPages, totalResults };
});
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 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 };
});
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 };
});
+62 -65
View File
@@ -1,80 +1,77 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import {
getEpisodeProgressByTitleIds,
getUserStatusesByTitleIds,
} from "@sofa/core/tracking";
import { getEpisodeProgressByTitleIds, getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { discover as discoverTmdb } 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 }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured",
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
});
}
const results = await discoverTmdb(
input.type,
{
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(input.genreId),
},
input.page,
);
type DiscoverResult = NonNullable<typeof results.results>[number] & {
title?: string;
name?: string;
release_date?: string;
first_air_date?: string;
};
const baseItems = ((results.results ?? []) as DiscoverResult[])
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: input.type,
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: (r.release_date as string | undefined) ?? null,
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? null,
}));
const titleMap = ensureBrowseTitlesExist(baseItems);
const items = baseItems.map((item) => {
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
return {
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
export const discover = os.discover.use(authed).handler(async ({ input, context }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured",
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
});
}
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
titleIds.length > 0
? [
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
const results = await discoverTmdb(
input.type,
{
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(input.genreId),
},
input.page,
);
type DiscoverResult = NonNullable<typeof results.results>[number] & {
title?: string;
name?: string;
release_date?: string;
first_air_date?: string;
};
const baseItems = ((results.results ?? []) as DiscoverResult[])
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: input.type,
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: (r.release_date as string | undefined) ?? null,
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? null,
}));
const titleMap = ensureBrowseTitlesExist(baseItems);
const items = baseItems.map((item) => {
const entry = titleMap.get(`${item.tmdbId}-${item.type}`);
return {
items,
userStatuses,
episodeProgress,
page: results.page ?? input.page,
totalPages: results.total_pages ?? 1,
totalResults: results.total_results ?? 0,
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
titleIds.length > 0
? [
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
return {
items,
userStatuses,
episodeProgress,
page: results.page ?? input.page,
totalPages: results.total_pages ?? 1,
totalResults: results.total_results ?? 0,
};
});
+11 -20
View File
@@ -1,25 +1,16 @@
import {
logEpisodeWatch,
logEpisodeWatchBatch,
unwatchEpisode,
} from "@sofa/core/tracking";
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 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 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);
});
export const batchWatch = os.episodes.batchWatch.use(authed).handler(({ input, context }) => {
logEpisodeWatchBatch(context.user.id, input.episodeIds);
});
+125 -153
View File
@@ -1,13 +1,12 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import {
getEpisodeProgressByTitleIds,
getUserStatusesByTitleIds,
} from "@sofa/core/tracking";
import { getEpisodeProgressByTitleIds, getUserStatusesByTitleIds } 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";
@@ -20,172 +19,145 @@ function requireTmdb() {
}
}
export const trending = os.explore.trending
.use(authed)
.handler(async ({ input, context }) => {
requireTmdb();
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 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: tmdbImageUrl(
(r.poster_path as string) ?? null,
"posters",
),
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: tmdbImageUrl(
(heroResult.poster_path as string) ?? null,
"posters",
),
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}`);
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 {
...item,
id: entry?.id ?? "",
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
? [
getUserStatusesByTitleIds(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,
type: mediaType as "movie" | "tv",
title: ((r.title ?? r.name) as string) || "",
posterPath: tmdbImageUrl((r.poster_path as string) ?? null, "posters"),
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 {
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const heroResult = results.find(
(r) => r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
);
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
titleIds.length > 0
? [
getUserStatusesByTitleIds(context.user.id, titleIds),
getEpisodeProgressByTitleIds(context.user.id, titleIds),
]
: [{}, {}];
// 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: tmdbImageUrl((heroResult.poster_path as string) ?? null, "posters"),
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 {
items,
userStatuses,
episodeProgress,
page: data.page ?? input.page,
totalPages: data.total_pages ?? 1,
totalResults: data.total_results ?? 0,
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
export const genres = os.explore.genres
.use(authed)
.handler(async ({ input }) => {
requireTmdb();
const data = await getGenres(input.type);
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
? [
getUserStatusesByTitleIds(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: tmdbImageUrl((r.poster_path as string) ?? null, "posters"),
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 {
genres: (data.genres ?? []).map((g) => ({
id: g.id,
name: g.name ?? "",
})),
...item,
id: entry?.id ?? "",
posterThumbHash: entry?.posterThumbHash ?? null,
};
});
const titleIds = items.map((r) => r.id);
const [userStatuses, episodeProgress] =
titleIds.length > 0
? [
getUserStatusesByTitleIds(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 ?? "",
})),
};
});
+169 -189
View File
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import type { ParseResult } from "@sofa/core/imports";
import {
@@ -13,216 +14,195 @@ import { db } from "@sofa/db/client";
import { and, eq, inArray } from "@sofa/db/helpers";
import { importJobs } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { os } from "../context";
import { authed } from "../middleware";
const log = createLogger("imports");
export const parseFile = os.imports.parseFile
.use(authed)
.handler(async ({ input }) => {
const { source, file } = input;
let result: ParseResult;
export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input }) => {
const { source, file } = input;
let result: ParseResult;
switch (source) {
case "letterboxd":
result = await parseLetterboxdExport(file);
break;
case "trakt": {
let json: unknown;
try {
json = await file.json();
} catch {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid JSON file",
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
}
result = parseTraktPayload(
json as Parameters<typeof parseTraktPayload>[0],
);
break;
}
case "simkl": {
let json: unknown;
try {
json = await file.json();
} catch {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid JSON file",
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
}
result = parseSimklPayload(
json as Parameters<typeof parseSimklPayload>[0],
);
break;
}
}
return {
data: result.data,
warnings: result.warnings,
diagnostics: result.diagnostics,
stats: {
movies: result.data.movies.length,
episodes: result.data.episodes.length,
watchlist: result.data.watchlist.length,
ratings: result.data.ratings.length,
},
};
});
export const parsePayload = os.imports.parsePayload
.use(authed)
.handler(({ input }) => {
const { data } = input;
return {
data,
warnings: [],
diagnostics: { unresolved: countUnresolved(data), unsupported: 0 },
stats: {
movies: data.movies.length,
episodes: data.episodes.length,
watchlist: data.watchlist.length,
ratings: data.ratings.length,
},
};
});
export const createJob = os.imports.createJob
.use(authed)
.handler(async ({ input, context }) => {
const { data, options } = input;
// Guard against oversized payloads
const totalItems =
data.movies.length +
data.episodes.length +
data.watchlist.length +
data.ratings.length;
if (totalItems > 100_000) {
throw new ORPCError("BAD_REQUEST", {
message: "Import payload too large",
data: { code: AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE },
});
}
// Prevent concurrent imports per user.
// Auto-cancel stale *pending* jobs (server crashed before worker started).
// Running jobs are never auto-cancelled — there's no heartbeat to
// distinguish active work from a dead worker, and killing a healthy
// long-running import is worse than making the user manually cancel.
const PENDING_STALE_MS = 5 * 60 * 1000; // 5 minutes
const now = Date.now();
const existing = db
.select()
.from(importJobs)
.where(
and(
eq(importJobs.userId, context.user.id),
inArray(importJobs.status, ["pending", "running"]),
),
)
.get();
if (existing) {
const isPending = existing.status === "pending";
const isStale =
isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS;
if (isStale) {
// Mark as cancelled so the worker loop also stops if it starts late
db.update(importJobs)
.set({
status: "cancelled",
finishedAt: new Date(),
currentMessage: "Import timed out (stale job auto-cancelled)",
})
.where(eq(importJobs.id, existing.id))
.run();
log.warn(`Auto-cancelled stale import job ${existing.id}`);
} else {
throw new ORPCError("CONFLICT", {
message: "An import is already in progress",
data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING },
switch (source) {
case "letterboxd":
result = await parseLetterboxdExport(file);
break;
case "trakt": {
let json: unknown;
try {
json = await file.json();
} catch {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid JSON file",
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
}
result = parseTraktPayload(json as Parameters<typeof parseTraktPayload>[0]);
break;
}
case "simkl": {
let json: unknown;
try {
json = await file.json();
} catch {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid JSON file",
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
}
result = parseSimklPayload(json as Parameters<typeof parseSimklPayload>[0]);
break;
}
}
const job = db
.insert(importJobs)
.values({
userId: context.user.id,
source: data.source,
status: "pending",
payload: JSON.stringify(data),
importWatches: options.importWatches,
importWatchlist: options.importWatchlist,
importRatings: options.importRatings,
createdAt: new Date(),
})
.returning()
.get();
return {
data: result.data,
warnings: result.warnings,
diagnostics: result.diagnostics,
stats: {
movies: result.data.movies.length,
episodes: result.data.episodes.length,
watchlist: result.data.watchlist.length,
ratings: result.data.ratings.length,
},
};
});
// Fire-and-forget processing
processImportJob(job.id).catch((err) => {
log.error(`Import job ${job.id} failed:`, err);
export const parsePayload = os.imports.parsePayload.use(authed).handler(({ input }) => {
const { data } = input;
return {
data,
warnings: [],
diagnostics: { unresolved: countUnresolved(data), unsupported: 0 },
stats: {
movies: data.movies.length,
episodes: data.episodes.length,
watchlist: data.watchlist.length,
ratings: data.ratings.length,
},
};
});
export const createJob = os.imports.createJob.use(authed).handler(async ({ input, context }) => {
const { data, options } = input;
// Guard against oversized payloads
const totalItems =
data.movies.length + data.episodes.length + data.watchlist.length + data.ratings.length;
if (totalItems > 100_000) {
throw new ORPCError("BAD_REQUEST", {
message: "Import payload too large",
data: { code: AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE },
});
}
return readImportJob(job.id);
});
export const getJob = os.imports.getJob
.use(authed)
.handler(({ input, context }) => {
return readImportJob(input.id, context.user.id);
});
export const cancelJob = os.imports.cancelJob
.use(authed)
.handler(({ input, context }) => {
const job = readImportJob(input.id, context.user.id);
if (job.status !== "pending" && job.status !== "running") {
throw new ORPCError("BAD_REQUEST", {
message: "Can only cancel pending or running jobs",
data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL },
// Prevent concurrent imports per user.
// Auto-cancel stale *pending* jobs (server crashed before worker started).
// Running jobs are never auto-cancelled — there's no heartbeat to
// distinguish active work from a dead worker, and killing a healthy
// long-running import is worse than making the user manually cancel.
const PENDING_STALE_MS = 5 * 60 * 1000; // 5 minutes
const now = Date.now();
const existing = db
.select()
.from(importJobs)
.where(
and(
eq(importJobs.userId, context.user.id),
inArray(importJobs.status, ["pending", "running"]),
),
)
.get();
if (existing) {
const isPending = existing.status === "pending";
const isStale = isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS;
if (isStale) {
// Mark as cancelled so the worker loop also stops if it starts late
db.update(importJobs)
.set({
status: "cancelled",
finishedAt: new Date(),
currentMessage: "Import timed out (stale job auto-cancelled)",
})
.where(eq(importJobs.id, existing.id))
.run();
log.warn(`Auto-cancelled stale import job ${existing.id}`);
} else {
throw new ORPCError("CONFLICT", {
message: "An import is already in progress",
data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING },
});
}
db.update(importJobs)
.set({ status: "cancelled" })
.where(eq(importJobs.id, input.id))
.run();
return readImportJob(input.id);
}
const job = db
.insert(importJobs)
.values({
userId: context.user.id,
source: data.source,
status: "pending",
payload: JSON.stringify(data),
importWatches: options.importWatches,
importWatchlist: options.importWatchlist,
importRatings: options.importRatings,
createdAt: new Date(),
})
.returning()
.get();
// Fire-and-forget processing
processImportJob(job.id).catch((err) => {
log.error(`Import job ${job.id} failed:`, err);
});
export const jobEvents = os.imports.jobEvents
.use(authed)
.handler(async function* ({ input, context }) {
readImportJob(input.id, context.user.id);
return readImportJob(job.id);
});
const JOB_POLL_INTERVAL = 500;
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
const startedAt = Date.now();
export const getJob = os.imports.getJob.use(authed).handler(({ input, context }) => {
return readImportJob(input.id, context.user.id);
});
while (true) {
const job = readImportJob(input.id);
const isTerminal =
job.status === "success" ||
job.status === "error" ||
job.status === "cancelled";
export const cancelJob = os.imports.cancelJob.use(authed).handler(({ input, context }) => {
const job = readImportJob(input.id, context.user.id);
if (job.status !== "pending" && job.status !== "running") {
throw new ORPCError("BAD_REQUEST", {
message: "Can only cancel pending or running jobs",
data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL },
});
}
db.update(importJobs).set({ status: "cancelled" }).where(eq(importJobs.id, input.id)).run();
return readImportJob(input.id);
});
yield {
type: (isTerminal ? "complete" : "progress") as "complete" | "progress",
job,
};
export const jobEvents = os.imports.jobEvents.use(authed).handler(async function* ({
input,
context,
}) {
readImportJob(input.id, context.user.id);
if (isTerminal) return;
const JOB_POLL_INTERVAL = 500;
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
const startedAt = Date.now();
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
yield { type: "timeout" as const, job };
return;
}
while (true) {
const job = readImportJob(input.id);
const isTerminal =
job.status === "success" || job.status === "error" || job.status === "cancelled";
await new Promise((resolve) => setTimeout(resolve, JOB_POLL_INTERVAL));
yield {
type: (isTerminal ? "complete" : "progress") as "complete" | "progress",
job,
};
if (isTerminal) return;
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
yield { type: "timeout" as const, job };
return;
}
});
await new Promise((resolve) => setTimeout(resolve, JOB_POLL_INTERVAL));
}
});
+37 -53
View File
@@ -1,8 +1,10 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { db } from "@sofa/db/client";
import { and, desc, eq } from "@sofa/db/helpers";
import { integrationEvents, integrations } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
@@ -13,9 +15,7 @@ function integrationTypeFor(provider: string): "webhook" | "list" {
}
function generateToken() {
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
"hex",
);
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
}
function serializeIntegration(row: {
@@ -41,10 +41,7 @@ export const list = os.integrations.list.use(authed).handler(({ context }) => {
.where(eq(integrations.userId, context.user.id))
.all();
const eventsByIntegration = new Map<
string,
(typeof integrationEvents.$inferSelect)[]
>();
const eventsByIntegration = new Map<string, (typeof integrationEvents.$inferSelect)[]>();
for (const integration of userIntegrations) {
const events = db
.select()
@@ -74,58 +71,48 @@ export const list = os.integrations.list.use(authed).handler(({ context }) => {
return { integrations: result };
});
export const create = os.integrations.create
.use(authed)
.handler(({ input, context }) => {
const existing = db
.select()
.from(integrations)
.where(
and(
eq(integrations.userId, context.user.id),
eq(integrations.provider, input.provider),
),
)
.get();
export const create = os.integrations.create.use(authed).handler(({ input, context }) => {
const existing = db
.select()
.from(integrations)
.where(and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)))
.get();
if (existing) {
if (input.enabled !== undefined) {
const row = db
.update(integrations)
.set({ enabled: input.enabled })
.where(eq(integrations.id, existing.id))
.returning()
.get();
return serializeIntegration(row);
}
return serializeIntegration(existing);
if (existing) {
if (input.enabled !== undefined) {
const row = db
.update(integrations)
.set({ enabled: input.enabled })
.where(eq(integrations.id, existing.id))
.returning()
.get();
return serializeIntegration(row);
}
return serializeIntegration(existing);
}
const row = db
.insert(integrations)
.values({
userId: context.user.id,
provider: input.provider,
type: integrationTypeFor(input.provider),
token: generateToken(),
enabled: input.enabled ?? true,
createdAt: new Date(),
})
.returning()
.get();
const row = db
.insert(integrations)
.values({
userId: context.user.id,
provider: input.provider,
type: integrationTypeFor(input.provider),
token: generateToken(),
enabled: input.enabled ?? true,
createdAt: new Date(),
})
.returning()
.get();
return serializeIntegration(row);
});
return serializeIntegration(row);
});
export const deleteIntegration = os.integrations.delete
.use(authed)
.handler(({ input, context }) => {
db.delete(integrations)
.where(
and(
eq(integrations.userId, context.user.id),
eq(integrations.provider, input.provider),
),
and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)),
)
.run();
});
@@ -137,10 +124,7 @@ export const regenerateToken = os.integrations.regenerateToken
.update(integrations)
.set({ token: generateToken() })
.where(
and(
eq(integrations.userId, context.user.id),
eq(integrations.provider, input.provider),
),
and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)),
)
.returning()
.get();
+25 -25
View File
@@ -1,36 +1,36 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { os } from "../context";
import { authed } from "../middleware";
export const detail = os.people.detail
.use(authed)
.handler(async ({ input, context }) => {
const person = await getOrFetchPerson(input.id);
if (!person)
throw new ORPCError("NOT_FOUND", {
message: "Person not found",
data: { code: AppErrorCode.PERSON_NOT_FOUND },
});
export const detail = os.people.detail.use(authed).handler(async ({ input, context }) => {
const person = await getOrFetchPerson(input.id);
if (!person)
throw new ORPCError("NOT_FOUND", {
message: "Person not found",
data: { code: AppErrorCode.PERSON_NOT_FOUND },
});
const allCredits = await fetchFullFilmography(person.id);
const allCredits = await fetchFullFilmography(person.id);
const start = (input.page - 1) * input.limit;
const pageCredits = allCredits.slice(start, start + input.limit);
const start = (input.page - 1) * input.limit;
const pageCredits = allCredits.slice(start, start + input.limit);
const userStatuses = getUserStatusesByTitleIds(
context.user.id,
pageCredits.map((c) => c.titleId),
);
const userStatuses = getUserStatusesByTitleIds(
context.user.id,
pageCredits.map((c) => c.titleId),
);
return {
person,
filmography: pageCredits,
userStatuses,
page: input.page,
totalPages: Math.max(1, Math.ceil(allCredits.length / input.limit)),
totalResults: allCredits.length,
};
});
return {
person,
filmography: pageCredits,
userStatuses,
page: input.page,
totalPages: Math.max(1, Math.ceil(allCredits.length / input.limit)),
totalResults: allCredits.length,
};
});
+4 -8
View File
@@ -1,15 +1,12 @@
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 { 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";
@@ -105,8 +102,7 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
};
}
const mediaType =
r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type;
const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type;
if (!mediaType) return null;
return {
+11 -18
View File
@@ -2,25 +2,18 @@ import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { episodes } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
export const watch = os.seasons.watch
.use(authed)
.handler(({ input, context }) => {
const seasonEps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, input.id))
.all();
logEpisodeWatchBatch(
context.user.id,
seasonEps.map((ep) => ep.id),
);
});
export const watch = os.seasons.watch.use(authed).handler(({ input, context }) => {
const seasonEps = db.select().from(episodes).where(eq(episodes.seasonId, input.id)).all();
logEpisodeWatchBatch(
context.user.id,
seasonEps.map((ep) => ep.id),
);
});
export const unwatch = os.seasons.unwatch
.use(authed)
.handler(({ input, context }) => {
unwatchSeason(context.user.id, input.id);
});
export const unwatch = os.seasons.unwatch.use(authed).handler(({ input, context }) => {
unwatchSeason(context.user.id, input.id);
});
+3 -10
View File
@@ -1,15 +1,8 @@
import {
getOidcProviderName,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@sofa/auth/config";
import {
getInstanceId,
getUserCount,
isRegistrationOpen,
} from "@sofa/core/settings";
import { getOidcProviderName, isOidcConfigured, isPasswordLoginDisabled } from "@sofa/auth/config";
import { getInstanceId, getUserCount, isRegistrationOpen } from "@sofa/core/settings";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
// Well-known TMDB poster paths for the background collage
+55 -74
View File
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { getRecommendationsForTitle } from "@sofa/core/discovery";
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
@@ -14,54 +15,43 @@ import {
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { titles, userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
export const detail = os.titles.detail
.use(authed)
.handler(async ({ input }) => {
const result = await getOrFetchTitle(input.id);
if (!result)
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
return result;
});
export const detail = os.titles.detail.use(authed).handler(async ({ input }) => {
const result = await getOrFetchTitle(input.id);
if (!result)
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
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 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 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 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 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 }) => {
return getUserTitleInfo(context.user.id, input.id);
});
export const userInfo = os.titles.userInfo.use(authed).handler(({ input, context }) => {
return getUserTitleInfo(context.user.id, input.id);
});
export const recommendations = os.titles.recommendations
.use(authed)
@@ -74,41 +64,32 @@ export const recommendations = os.titles.recommendations
return { recommendations: recs, userStatuses };
});
export const quickAdd = os.titles.quickAdd
.use(authed)
.handler(async ({ input, context }) => {
// Look up the title (it exists as a shell from browse/search import)
const title = db
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles)
.where(eq(titles.id, input.id))
.get();
if (!title) {
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
}
export const quickAdd = os.titles.quickAdd.use(authed).handler(async ({ input, context }) => {
// Look up the title (it exists as a shell from browse/search import)
const title = db
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles)
.where(eq(titles.id, input.id))
.get();
if (!title) {
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
}
// Trigger full TMDB import if still a shell
getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(
() => {},
);
// Trigger full TMDB import if still a shell
getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(() => {});
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, context.user.id),
eq(userTitleStatus.titleId, title.id),
),
)
.get();
const existing = db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, context.user.id), eq(userTitleStatus.titleId, title.id)))
.get();
if (!existing) {
setTitleStatus(context.user.id, title.id, "watchlist");
}
if (!existing) {
setTitleStatus(context.user.id, title.id, "watchlist");
}
return { id: title.id, alreadyAdded: !!existing };
});
return { id: title.id, alreadyAdded: !!existing };
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { auth } from "@sofa/auth/server";
import { Hono } from "hono";
import { auth } from "@sofa/auth/server";
const app = new Hono();
app.all("/*", (c) => auth.handler(c.req.raw));
+3 -1
View File
@@ -1,7 +1,9 @@
import path from "node:path";
import { Hono } from "hono";
import { auth } from "@sofa/auth/server";
import { AVATAR_DIR } from "@sofa/config";
import { Hono } from "hono";
const app = new Hono();
+3 -1
View File
@@ -1,7 +1,9 @@
import path from "node:path";
import { Hono } from "hono";
import { auth } from "@sofa/auth/server";
import { getBackupPath } from "@sofa/core/backup";
import { Hono } from "hono";
const app = new Hono();
+2 -1
View File
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import { getInstanceId } from "@sofa/core/settings";
import { createLogger } from "@sofa/logger";
import { Hono } from "hono";
const log = createLogger("health");
+4 -8
View File
@@ -1,15 +1,11 @@
import path from "node:path";
import { fetchAndMaybeCache, imageCacheEnabled } from "@sofa/core/image-cache";
import { Hono } from "hono";
import { z } from "zod";
const categorySchema = z.enum([
"posters",
"backdrops",
"stills",
"logos",
"profiles",
]);
import { fetchAndMaybeCache, imageCacheEnabled } from "@sofa/core/image-cache";
const categorySchema = z.enum(["posters", "backdrops", "stills", "logos", "profiles"]);
const app = new Hono();
+2 -6
View File
@@ -1,11 +1,7 @@
import {
getRadarrList,
getSonarrList,
parseStatusParam,
resolveListToken,
} from "@sofa/core/lists";
import { Hono } from "hono";
import { getRadarrList, getSonarrList, parseStatusParam, resolveListToken } from "@sofa/core/lists";
const app = new Hono();
app.get("/:token", async (c) => {
+3 -6
View File
@@ -1,3 +1,5 @@
import { Hono } from "hono";
import type { WebhookEvent } from "@sofa/core/webhooks";
import {
parseEmbyPayload,
@@ -9,7 +11,6 @@ import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { integrations } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { Hono } from "hono";
const log = createLogger("webhooks");
@@ -19,11 +20,7 @@ app.post("/:token", async (c) => {
const token = c.req.param("token");
// Look up connection by token — this IS the auth
const connection = db
.select()
.from(integrations)
.where(eq(integrations.token, token))
.get();
const connection = db.select().from(integrations).where(eq(integrations.token, token)).get();
if (!connection || !connection.enabled) {
// Always return 200 to avoid retry storms from media servers