mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
+18
-71
@@ -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);
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { implement } from "@orpc/server";
|
||||
|
||||
import { contract } from "@sofa/api/contract";
|
||||
|
||||
export interface Context {
|
||||
|
||||
@@ -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,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 }>();
|
||||
|
||||
@@ -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(/\/$/, "") || "/";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 ?? "",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user