feat(server): serve normalized OpenAPI spec at /api/v1/spec.json with shared schema components

Extract spec generation into `openapi-spec.ts` with a `generateOpenApiSpec` function that registers common `$ref` schemas (Title, Person, Episode, Season, etc.) to deduplicate inline definitions across the spec. Add a `/api/v1/spec.json` interceptor that returns the fully normalized document, and point the Scalar docs UI at it instead of the plugin's raw generator output.

Also strip oRPC's impossible `{ not: {} }` void placeholders from request/response bodies via `normalizeOpenApiSpec`, and fix the handler registration so both `/api/v1` and `/api/v1/*` routes are matched.
This commit is contained in:
jake committed 2026-03-14 20:05:44 -04:00
1 parent aede4fc90a
commit 33fd871114
20 files changed
+5153 -2928

No files matched your search

+6 -3
View File
@@ -5,7 +5,7 @@ import { registerJobScheduleProvider } from "@sofa/core/system-health";
import { closeDatabase } from "@sofa/db/client"; import { closeDatabase } from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate"; import { runMigrations } from "@sofa/db/migrate";
import { createLogger } from "@sofa/logger"; import { createLogger } from "@sofa/logger";
import { Hono } from "hono"; import { type Context, Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { getJobSchedules, startJobs, stopJobs } from "./cron"; import { getJobSchedules, startJobs, stopJobs } from "./cron";
@@ -84,13 +84,16 @@ app.all("/rpc/*", async (c) => {
}); });
// oRPC OpenAPI handler + Scalar docs // oRPC OpenAPI handler + Scalar docs
app.all("/api/v1/*", async (c) => { const handleOpenApi = async (c: Context) => {
const { response } = await openApiHandler.handle(c.req.raw, { const { response } = await openApiHandler.handle(c.req.raw, {
prefix: "/api/v1", prefix: "/api/v1",
context: { headers: c.req.raw.headers }, context: { headers: c.req.raw.headers },
}); });
return response ?? c.text("Not found", 404); return response ?? c.text("Not found", 404);
}); };
app.all("/api/v1", handleOpenApi);
app.all("/api/v1/*", handleOpenApi);
// ─── SPA static serving (production) ───────────────────────── // ─── SPA static serving (production) ─────────────────────────
+75 -5
View File
@@ -2,9 +2,13 @@ import { SmartCoercionPlugin } from "@orpc/json-schema";
import { OpenAPIHandler } from "@orpc/openapi/fetch"; import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
import { onError } from "@orpc/server"; import { onError } from "@orpc/server";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { createLogger } from "@sofa/logger"; import { createLogger } from "@sofa/logger";
import { router } from "./router"; import {
generateOpenApiSpec,
openApiTags,
schemaConverters,
} from "./openapi-spec";
import { implementedRouter } from "./router";
const log = createLogger("openapi"); const log = createLogger("openapi");
@@ -13,10 +17,15 @@ const sessionCookieName = isSecure
? "__Secure-better-auth.session_token" ? "__Secure-better-auth.session_token"
: "better-auth.session_token"; : "better-auth.session_token";
// https://orpc.dev/docs/openapi/plugins/smart-coercion function escapeHtml(value: string): string {
const schemaConverters = [new ZodToJsonSchemaConverter()]; return value
.replaceAll("&", "&")
.replaceAll('"', """)
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
export const openApiHandler = new OpenAPIHandler(router, { export const openApiHandler = new OpenAPIHandler(implementedRouter, {
plugins: [ plugins: [
new SmartCoercionPlugin({ schemaConverters }), new SmartCoercionPlugin({ schemaConverters }),
new OpenAPIReferencePlugin({ new OpenAPIReferencePlugin({
@@ -27,6 +36,7 @@ export const openApiHandler = new OpenAPIHandler(router, {
version: process.env.APP_VERSION || "0.0.0", version: process.env.APP_VERSION || "0.0.0",
}, },
servers: [{ url: "/api/v1" }], servers: [{ url: "/api/v1" }],
tags: [...openApiTags],
components: { components: {
securitySchemes: { securitySchemes: {
session: { session: {
@@ -38,9 +48,69 @@ export const openApiHandler = new OpenAPIHandler(router, {
}, },
}, },
}, },
// Load the spec from /spec.json so the docs use the normalized document
// rather than the plugin's raw generator output.
renderDocsHtml: (specUrl, title, head, scriptUrl, config) => {
const scalarConfig = {
url: specUrl,
...(typeof config === "object" && config !== null ? config : {}),
};
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
${head}
</head>
<body>
<div id="app" data-config="${escapeHtml(JSON.stringify(scalarConfig))}"></div>
<script src="${escapeHtml(scriptUrl)}"></script>
<script>
Scalar.createApiReference('#app', JSON.parse(document.getElementById('app').dataset.config))
</script>
</body>
</html>
`;
},
}), }),
], ],
interceptors: [ interceptors: [
async (options) => {
const requestPathname =
options.request.url.pathname.replace(/\/$/, "") || "/";
const prefix = options.prefix?.replace(/\/$/, "") || "";
const specPath = `${prefix}/spec.json`.replace(/\/$/, "") || "/";
if (options.request.method !== "GET" || requestPathname !== specPath) {
return options.next();
}
const spec = await generateOpenApiSpec({
title: "Sofa API",
version: process.env.APP_VERSION || "0.0.0",
servers: [{ url: prefix || "/api/v1" }],
sessionCookieName,
tags: [...openApiTags],
});
return {
matched: true,
response: {
status: 200,
headers: {
"content-type": "application/json",
},
body: new File([JSON.stringify(spec)], "spec.json", {
type: "application/json",
}),
},
};
},
onError((error) => { onError((error) => {
log.error("OpenAPI error", error); log.error("OpenAPI error", error);
}), }),
+259
View File
@@ -0,0 +1,259 @@
import { OpenAPIGenerator } from "@orpc/openapi";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import {
BackupSchema,
CastMemberSchema,
EpisodeSchema,
IntegrationEventSchema,
IntegrationSchema,
JobSchema,
PersonCreditSchema,
PersonSchema,
RecommendationItemSchema,
ResolvedTitleSchema,
SeasonSchema,
SystemHealthSchema,
TmdbBrowseItem,
} from "@sofa/api/schemas";
import { implementedRouter } from "./router";
export const schemaConverters = [new ZodToJsonSchemaConverter()];
export const openApiTags = [
{ name: "Titles", description: "Movie and TV show management" },
{ name: "Episodes", description: "Episode watch tracking" },
{ name: "Seasons", description: "Season watch tracking" },
{ name: "People", description: "Cast and crew information" },
{ name: "Dashboard", description: "User dashboard data" },
{ name: "Explore", description: "Discover trending and popular content" },
{ name: "Search", description: "Search for movies and TV shows" },
{
name: "Discover",
description: "Advanced content discovery with filters",
},
{ name: "System", description: "Server status and configuration" },
{ name: "Integrations", description: "Media server integrations" },
{ name: "Admin", description: "Server administration" },
{ name: "Account", description: "User account management" },
] as const;
const generator = new OpenAPIGenerator({
schemaConverters,
});
const httpMethods = [
"get",
"put",
"post",
"delete",
"options",
"head",
"patch",
"trace",
] as const;
type OpenApiSpec = Awaited<ReturnType<OpenAPIGenerator["generate"]>>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isEmptyRecord(value: unknown): value is Record<string, never> {
return isRecord(value) && Object.keys(value).length === 0;
}
function isImpossibleSchema(schema: unknown): boolean {
return (
isRecord(schema) &&
Object.keys(schema).length === 1 &&
"not" in schema &&
isEmptyRecord(schema.not)
);
}
function normalizeSchema(schema: unknown): unknown {
if (!isRecord(schema)) {
return schema;
}
if (isImpossibleSchema(schema)) {
return undefined;
}
const normalized = { ...schema };
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
const branches = normalized[key];
if (!Array.isArray(branches)) continue;
const nextBranches = branches.flatMap((branch) => {
const nextBranch = normalizeSchema(branch);
return nextBranch === undefined ? [] : [nextBranch];
});
if (nextBranches.length === 0) {
return undefined;
}
normalized[key] = nextBranches;
}
if (isRecord(normalized.properties)) {
const nextProperties = Object.fromEntries(
Object.entries(normalized.properties).flatMap(
([name, propertySchema]) => {
const nextPropertySchema = normalizeSchema(propertySchema);
return nextPropertySchema === undefined
? []
: [[name, nextPropertySchema]];
},
),
);
if (Object.keys(nextProperties).length > 0) {
normalized.properties = nextProperties;
} else {
delete normalized.properties;
}
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),
);
if (nextRequired.length > 0) {
normalized.required = nextRequired;
} else {
delete normalized.required;
}
}
}
if ("items" in normalized) {
const nextItems = normalizeSchema(normalized.items);
if (nextItems === undefined) {
delete normalized.items;
} else {
normalized.items = nextItems;
}
}
if (isRecord(normalized.additionalProperties)) {
const nextAdditionalProperties = normalizeSchema(
normalized.additionalProperties,
);
if (nextAdditionalProperties === undefined) {
delete normalized.additionalProperties;
} else {
normalized.additionalProperties = nextAdditionalProperties;
}
}
return normalized;
}
function normalizeContent(
content: Record<string, { schema?: unknown }> | undefined,
): Record<string, { schema?: unknown }> | undefined {
if (!content) {
return undefined;
}
for (const [mediaType, mediaTypeObject] of Object.entries(content)) {
const nextSchema = normalizeSchema(mediaTypeObject.schema);
if (nextSchema === undefined) {
delete content[mediaType];
continue;
}
mediaTypeObject.schema = nextSchema;
}
return Object.keys(content).length > 0 ? content : undefined;
}
export function normalizeOpenApiSpec<T extends OpenApiSpec>(spec: T): T {
for (const pathItem of Object.values(spec.paths ?? {})) {
if (!pathItem) continue;
for (const method of httpMethods) {
const operation = pathItem[method];
if (!operation) continue;
if (operation.requestBody && "content" in operation.requestBody) {
const nextContent = normalizeContent(operation.requestBody.content);
if (nextContent) {
operation.requestBody.content =
nextContent as typeof operation.requestBody.content;
} else {
delete operation.requestBody;
}
}
for (const response of Object.values(operation.responses ?? {})) {
if (!response || !("content" in response)) continue;
const nextContent = normalizeContent(response.content);
if (nextContent) {
response.content = nextContent as typeof response.content;
} else {
delete response.content;
}
}
}
}
return spec;
}
export async function generateOpenApiSpec(options: {
title: string;
version: string;
servers: Array<{ url: string }>;
sessionCookieName: string;
tags?: Array<{ name: string; description?: string }>;
}): Promise<OpenApiSpec> {
const spec = await generator.generate(implementedRouter, {
info: {
title: options.title,
version: options.version,
},
servers: options.servers,
tags: options.tags,
commonSchemas: {
Title: { schema: ResolvedTitleSchema },
Person: { schema: PersonSchema },
PersonCredit: { schema: PersonCreditSchema },
Episode: { schema: EpisodeSchema },
Season: { schema: SeasonSchema },
CastMember: { schema: CastMemberSchema },
BrowseItem: { schema: TmdbBrowseItem },
Recommendation: { schema: RecommendationItemSchema },
Integration: { schema: IntegrationSchema },
IntegrationEvent: { schema: IntegrationEventSchema },
Backup: { schema: BackupSchema },
Job: { schema: JobSchema },
SystemHealth: { schema: SystemHealthSchema },
},
components: {
securitySchemes: {
session: {
type: "apiKey",
name: options.sessionCookieName,
in: "cookie",
description: "Better Auth session cookie",
},
},
},
});
// oRPC represents void/undefined with an impossible schema placeholder.
// OpenAPI has no "undefined" payload, so drop impossible request/response
// bodies entirely while preserving real `null` schemas.
return normalizeOpenApiSpec(spec);
}
+4 -2
View File
@@ -13,7 +13,7 @@ import * as status from "./procedures/status";
import * as system from "./procedures/system"; import * as system from "./procedures/system";
import * as titles from "./procedures/titles"; import * as titles from "./procedures/titles";
export const router = os.router({ export const implementedRouter = {
titles: { titles: {
detail: titles.detail, detail: titles.detail,
resolve: titles.resolve, resolve: titles.resolve,
@@ -87,6 +87,8 @@ export const router = os.router({
uploadAvatar: account.uploadAvatar, uploadAvatar: account.uploadAvatar,
removeAvatar: account.removeAvatar, removeAvatar: account.removeAvatar,
}, },
}); };
export const router = os.router(implementedRouter);
export type Router = typeof router; export type Router = typeof router;
+17 -9
View File
@@ -5,23 +5,31 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: Update Name title: Update display name
url: '#update-name' url: '#update-display-name'
- depth: 2 - depth: 2
title: Upload Avatar title: Upload avatar
url: '#upload-avatar' url: '#upload-avatar'
- depth: 2 - depth: 2
title: Remove Avatar title: Remove avatar
url: '#remove-avatar' url: '#remove-avatar'
structuredData: structuredData:
headings: headings:
- content: Update Name - content: Update display name
id: update-name id: update-display-name
- content: Upload Avatar - content: Upload avatar
id: upload-avatar id: upload-avatar
- content: Remove Avatar - content: Remove avatar
id: remove-avatar id: remove-avatar
contents: [] contents:
- content: Change the current user's display name.
heading: update-display-name
- content: >-
Upload a new profile avatar image. Accepts JPEG, PNG, WebP, or GIF up
to 2 MB.
heading: upload-avatar
- content: Delete the current user's profile avatar, reverting to the default.
heading: remove-avatar
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+87 -49
View File
@@ -5,73 +5,111 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: List title: List backups
url: '#list' url: '#list-backups'
- depth: 2 - depth: 2
title: Create title: Create backup
url: '#create' url: '#create-backup'
- depth: 2 - depth: 2
title: Delete title: Delete backup
url: '#delete' url: '#delete-backup'
- depth: 2 - depth: 2
title: Restore title: Restore from backup
url: '#restore' url: '#restore-from-backup'
- depth: 2 - depth: 2
title: Schedule title: Get backup schedule
url: '#schedule' url: '#get-backup-schedule'
- depth: 2 - depth: 2
title: Update Schedule title: Update backup schedule
url: '#update-schedule' url: '#update-backup-schedule'
- depth: 2 - depth: 2
title: Registration title: Get registration status
url: '#registration' url: '#get-registration-status'
- depth: 2 - depth: 2
title: Toggle Registration title: Toggle registration
url: '#toggle-registration' url: '#toggle-registration'
- depth: 2 - depth: 2
title: Update Check title: Get update check status
url: '#update-check' url: '#get-update-check-status'
- depth: 2 - depth: 2
title: Toggle Update Check title: Toggle update checks
url: '#toggle-update-check' url: '#toggle-update-checks'
- depth: 2 - depth: 2
title: Telemetry title: Get telemetry status
url: '#telemetry' url: '#get-telemetry-status'
- depth: 2 - depth: 2
title: Toggle Telemetry title: Toggle telemetry
url: '#toggle-telemetry' url: '#toggle-telemetry'
- depth: 2 - depth: 2
title: Trigger Job title: Trigger cron job
url: '#trigger-job' url: '#trigger-cron-job'
structuredData: structuredData:
headings: headings:
- content: List - content: List backups
id: list id: list-backups
- content: Create - content: Create backup
id: create id: create-backup
- content: Delete - content: Delete backup
id: delete id: delete-backup
- content: Restore - content: Restore from backup
id: restore id: restore-from-backup
- content: Schedule - content: Get backup schedule
id: schedule id: get-backup-schedule
- content: Update Schedule - content: Update backup schedule
id: update-schedule id: update-backup-schedule
- content: Registration - content: Get registration status
id: registration id: get-registration-status
- content: Toggle Registration - content: Toggle registration
id: toggle-registration id: toggle-registration
- content: Update Check - content: Get update check status
id: update-check id: get-update-check-status
- content: Toggle Update Check - content: Toggle update checks
id: toggle-update-check id: toggle-update-checks
- content: Telemetry - content: Get telemetry status
id: telemetry id: get-telemetry-status
- content: Toggle Telemetry - content: Toggle telemetry
id: toggle-telemetry id: toggle-telemetry
- content: Trigger Job - content: Trigger cron job
id: trigger-job id: trigger-cron-job
contents: [] contents:
- content: Fetch all database backups with their sizes and creation times.
heading: list-backups
- content: >-
Create a new manual database backup. The backup is tagged as a manual
backup.
heading: create-backup
- content: Permanently delete a database backup file by filename.
heading: delete-backup
- content: >-
Upload a database backup file and restore it. A pre-restore backup is
automatically created before overwriting the current database.
heading: restore-from-backup
- content: Fetch the current automated backup schedule configuration.
heading: get-backup-schedule
- content: >-
Update the automated backup schedule. Only provided fields are
changed; omitted fields keep their current values.
heading: update-backup-schedule
- content: Check whether new user registration is currently open or closed.
heading: get-registration-status
- content: Open or close new user registration.
heading: toggle-registration
- content: >-
Fetch whether automatic update checks are enabled, and the latest
cached check result if available.
heading: get-update-check-status
- content: Enable or disable automatic update checks against the public API.
heading: toggle-update-checks
- content: >-
Fetch whether anonymous telemetry is enabled and when the last report
was sent.
heading: get-telemetry-status
- content: Enable or disable anonymous telemetry reporting.
heading: toggle-telemetry
- content: >-
Manually trigger a background cron job by name. The job runs
asynchronously.
heading: trigger-cron-job
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+39 -21
View File
@@ -5,33 +5,51 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: Stats title: Get dashboard statistics
url: '#stats' url: '#get-dashboard-statistics'
- depth: 2 - depth: 2
title: Continue Watching title: Get continue watching list
url: '#continue-watching' url: '#get-continue-watching-list'
- depth: 2 - depth: 2
title: Library title: Get user library
url: '#library' url: '#get-user-library'
- depth: 2 - depth: 2
title: Recommendations title: Get personalized recommendations
url: '#recommendations' url: '#get-personalized-recommendations'
- depth: 2 - depth: 2
title: Watch History title: Get watch history
url: '#watch-history' url: '#get-watch-history'
structuredData: structuredData:
headings: headings:
- content: Stats - content: Get dashboard statistics
id: stats id: get-dashboard-statistics
- content: Continue Watching - content: Get continue watching list
id: continue-watching id: get-continue-watching-list
- content: Library - content: Get user library
id: library id: get-user-library
- content: Recommendations - content: Get personalized recommendations
id: recommendations id: get-personalized-recommendations
- content: Watch History - content: Get watch history
id: watch-history id: get-watch-history
contents: [] contents:
- content: >-
Fetch summary counts for the current user: movies watched this month,
episodes this week, library size, and completed titles.
heading: get-dashboard-statistics
- content: >-
Fetch TV shows the user is currently watching, with the next unwatched
episode and progress for each.
heading: get-continue-watching-list
- content: Fetch all titles in the user's library with their tracking statuses.
heading: get-user-library
- content: >-
Fetch personalized title recommendations based on the user's library
and watch history.
heading: get-personalized-recommendations
- content: >-
Fetch the user's watch counts grouped by time period. Useful for
rendering activity charts.
heading: get-watch-history
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+9 -5
View File
@@ -6,13 +6,17 @@ _openapi:
method: GET method: GET
toc: toc:
- depth: 2 - depth: 2
title: Discover title: Discover titles by genre
url: '#discover' url: '#discover-titles-by-genre'
structuredData: structuredData:
headings: headings:
- content: Discover - content: Discover titles by genre
id: discover id: discover-titles-by-genre
contents: [] contents:
- content: >-
Browse movies or TV shows filtered by genre, sorted by popularity.
Returns user statuses and episode progress.
heading: discover-titles-by-genre
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+21 -13
View File
@@ -5,23 +5,31 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: Watch title: Mark episode watched
url: '#watch' url: '#mark-episode-watched'
- depth: 2 - depth: 2
title: Unwatch title: Mark episode unwatched
url: '#unwatch' url: '#mark-episode-unwatched'
- depth: 2 - depth: 2
title: Batch Watch title: Batch mark episodes watched
url: '#batch-watch' url: '#batch-mark-episodes-watched'
structuredData: structuredData:
headings: headings:
- content: Watch - content: Mark episode watched
id: watch id: mark-episode-watched
- content: Unwatch - content: Mark episode unwatched
id: unwatch id: mark-episode-unwatched
- content: Batch Watch - content: Batch mark episodes watched
id: batch-watch id: batch-mark-episodes-watched
contents: [] contents:
- content: Record a watch event for a single TV episode.
heading: mark-episode-watched
- content: Remove the watch record for a single TV episode.
heading: mark-episode-unwatched
- content: >-
Record watch events for multiple episodes at once. Useful for marking
a range of episodes as watched.
heading: batch-mark-episodes-watched
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+25 -13
View File
@@ -5,23 +5,35 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: Trending title: Get trending titles
url: '#trending' url: '#get-trending-titles'
- depth: 2 - depth: 2
title: Popular title: Get popular titles
url: '#popular' url: '#get-popular-titles'
- depth: 2 - depth: 2
title: Genres title: List genres
url: '#genres' url: '#list-genres'
structuredData: structuredData:
headings: headings:
- content: Trending - content: Get trending titles
id: trending id: get-trending-titles
- content: Popular - content: Get popular titles
id: popular id: get-popular-titles
- content: Genres - content: List genres
id: genres id: list-genres
contents: [] contents:
- content: >-
Fetch today's trending movies and/or TV shows from TMDB, including a
featured hero title and the user's statuses.
heading: get-trending-titles
- content: >-
Fetch currently popular movies or TV shows from TMDB with the user's
tracking statuses.
heading: get-popular-titles
- content: >-
Fetch the list of TMDB genres for movies or TV shows. Used to populate
genre filters for discovery.
heading: list-genres
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+31 -17
View File
@@ -5,28 +5,42 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: List title: List integrations
url: '#list' url: '#list-integrations'
- depth: 2 - depth: 2
title: Create title: Create or update integration
url: '#create' url: '#create-or-update-integration'
- depth: 2 - depth: 2
title: Delete title: Delete integration
url: '#delete' url: '#delete-integration'
- depth: 2 - depth: 2
title: Regenerate Token title: Regenerate webhook token
url: '#regenerate-token' url: '#regenerate-webhook-token'
structuredData: structuredData:
headings: headings:
- content: List - content: List integrations
id: list id: list-integrations
- content: Create - content: Create or update integration
id: create id: create-or-update-integration
- content: Delete - content: Delete integration
id: delete id: delete-integration
- content: Regenerate Token - content: Regenerate webhook token
id: regenerate-token id: regenerate-webhook-token
contents: [] contents:
- content: >-
Fetch all configured media server integrations for the current user,
including recent webhook/sync events for each.
heading: list-integrations
- content: >-
Create a new media server integration or update an existing one.
Generates a unique webhook token for the provider.
heading: create-or-update-integration
- content: Remove a media server integration and all its event history.
heading: delete-integration
- content: >-
Generate a new webhook token for an integration. The old token is
immediately invalidated.
heading: regenerate-webhook-token
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+17 -9
View File
@@ -5,18 +5,26 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: Detail title: Get person details
url: '#detail' url: '#get-person-details'
- depth: 2 - depth: 2
title: Resolve title: Resolve TMDB ID to local person
url: '#resolve' url: '#resolve-tmdb-id-to-local-person'
structuredData: structuredData:
headings: headings:
- content: Detail - content: Get person details
id: detail id: get-person-details
- content: Resolve - content: Resolve TMDB ID to local person
id: resolve id: resolve-tmdb-id-to-local-person
contents: [] contents:
- content: >-
Fetch a person's profile and filmography. Imports from TMDB on first
access if not yet cached locally.
heading: get-person-details
- content: >-
Look up or import a person by their TMDB ID. Returns the internal ID
for use with other endpoints.
heading: resolve-tmdb-id-to-local-person
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+9 -5
View File
@@ -6,13 +6,17 @@ _openapi:
method: GET method: GET
toc: toc:
- depth: 2 - depth: 2
title: Search title: Search movies, TV shows, and people
url: '#search' url: '#search-movies-tv-shows-and-people'
structuredData: structuredData:
headings: headings:
- content: Search - content: Search movies, TV shows, and people
id: search id: search-movies-tv-shows-and-people
contents: [] contents:
- content: >-
Full-text search across movies, TV shows, and people via TMDB.
Optionally filter by media type.
heading: search-movies-tv-shows-and-people
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+15 -9
View File
@@ -5,18 +5,24 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: Watch title: Mark season watched
url: '#watch' url: '#mark-season-watched'
- depth: 2 - depth: 2
title: Unwatch title: Mark season unwatched
url: '#unwatch' url: '#mark-season-unwatched'
structuredData: structuredData:
headings: headings:
- content: Watch - content: Mark season watched
id: watch id: mark-season-watched
- content: Unwatch - content: Mark season unwatched
id: unwatch id: mark-season-unwatched
contents: [] contents:
- content: Mark all episodes in a season as watched in a single operation.
heading: mark-season-watched
- content: >-
Remove all watch records for episodes in a season in a single
operation.
heading: mark-season-unwatched
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+35 -17
View File
@@ -5,28 +5,46 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: Public Info title: Get public instance info
url: '#public-info' url: '#get-public-instance-info'
- depth: 2 - depth: 2
title: Auth Config title: Get authentication config
url: '#auth-config' url: '#get-authentication-config'
- depth: 2 - depth: 2
title: Status title: Get system status
url: '#status' url: '#get-system-status'
- depth: 2 - depth: 2
title: Health title: Get system health report
url: '#health' url: '#get-system-health-report'
structuredData: structuredData:
headings: headings:
- content: Public Info - content: Get public instance info
id: public-info id: get-public-instance-info
- content: Auth Config - content: Get authentication config
id: auth-config id: get-authentication-config
- content: Status - content: Get system status
id: status id: get-system-status
- content: Health - content: Get system health report
id: health id: get-system-health-report
contents: [] contents:
- content: >-
Fetch public information about this Sofa instance. Does not require
authentication. Used by the login screen to display instance details.
heading: get-public-instance-info
- content: >-
Fetch the authentication configuration including OIDC availability,
password login status, and registration state. Does not require
authentication.
heading: get-authentication-config
- content: >-
Quick check of whether TMDB is configured. Does not require
authentication.
heading: get-system-status
- content: >-
Comprehensive health check covering database, TMDB connectivity, cron
jobs, image cache, backups, and environment. Does not require
authentication.
heading: get-system-health-report
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+79 -41
View File
@@ -5,58 +5,96 @@ full: true
_openapi: _openapi:
toc: toc:
- depth: 2 - depth: 2
title: Detail title: Get title details
url: '#detail' url: '#get-title-details'
- depth: 2 - depth: 2
title: Resolve title: Resolve TMDB ID to local title
url: '#resolve' url: '#resolve-tmdb-id-to-local-title'
- depth: 2 - depth: 2
title: Update Status title: Update tracking status
url: '#update-status' url: '#update-tracking-status'
- depth: 2 - depth: 2
title: Update Rating title: Rate a title
url: '#update-rating' url: '#rate-a-title'
- depth: 2 - depth: 2
title: Watch Movie title: Mark movie as watched
url: '#watch-movie' url: '#mark-movie-as-watched'
- depth: 2 - depth: 2
title: Watch All title: Mark all episodes watched
url: '#watch-all' url: '#mark-all-episodes-watched'
- depth: 2 - depth: 2
title: User Info title: Get user's title info
url: '#user-info' url: '#get-users-title-info'
- depth: 2 - depth: 2
title: Recommendations title: Get title recommendations
url: '#recommendations' url: '#get-title-recommendations'
- depth: 2 - depth: 2
title: Hydrate Seasons title: Hydrate TV seasons
url: '#hydrate-seasons' url: '#hydrate-tv-seasons'
- depth: 2 - depth: 2
title: Quick Add title: Quick add title to library
url: '#quick-add' url: '#quick-add-title-to-library'
structuredData: structuredData:
headings: headings:
- content: Detail - content: Get title details
id: detail id: get-title-details
- content: Resolve - content: Resolve TMDB ID to local title
id: resolve id: resolve-tmdb-id-to-local-title
- content: Update Status - content: Update tracking status
id: update-status id: update-tracking-status
- content: Update Rating - content: Rate a title
id: update-rating id: rate-a-title
- content: Watch Movie - content: Mark movie as watched
id: watch-movie id: mark-movie-as-watched
- content: Watch All - content: Mark all episodes watched
id: watch-all id: mark-all-episodes-watched
- content: User Info - content: Get user's title info
id: user-info id: get-users-title-info
- content: Recommendations - content: Get title recommendations
id: recommendations id: get-title-recommendations
- content: Hydrate Seasons - content: Hydrate TV seasons
id: hydrate-seasons id: hydrate-tv-seasons
- content: Quick Add - content: Quick add title to library
id: quick-add id: quick-add-title-to-library
contents: [] contents:
- content: >-
Fetch full title metadata including seasons, cast, and streaming
availability. Imports from TMDB on first access if not yet cached
locally.
heading: get-title-details
- content: >-
Look up or import a title by its TMDB ID and media type. Returns the
internal ID for use with other endpoints.
heading: resolve-tmdb-id-to-local-title
- content: >-
Set the user's tracking status for a title. Use null to remove the
title from the library entirely.
heading: update-tracking-status
- content: Set a 0-5 star rating for a title. Use 0 to clear the rating.
heading: rate-a-title
- content: Log a watch event for a movie. Automatically sets status to completed.
heading: mark-movie-as-watched
- content: >-
Mark every episode of a TV show as watched. Requires seasons to be
hydrated first.
heading: mark-all-episodes-watched
- content: >-
Fetch the current user's tracking status, rating, and watched episode
IDs for a title.
heading: get-users-title-info
- content: >-
Fetch similar titles based on locally cached recommendation data,
along with the user's statuses for each.
heading: get-title-recommendations
- content: >-
Fetch full season and episode data from TMDB for a TV show. Required
before tracking individual episodes.
heading: hydrate-tv-seasons
- content: >-
Import a title by TMDB ID and add it to the user's watchlist in one
step. If the title already exists in the user's library, returns
alreadyAdded: true.
heading: quick-add-title-to-library
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
+3163 -2182
View File
File diff suppressed because it is too large. Load diff
+331 -32
View File
@@ -61,18 +61,44 @@ import {
export const contract = { export const contract = {
titles: { titles: {
detail: oc detail: oc
.route({ method: "GET", path: "/titles/{id}", tags: ["Titles"] }) .route({
method: "GET",
path: "/titles/{id}",
tags: ["Titles"],
summary: "Get title details",
description:
"Fetch full title metadata including seasons, cast, and streaming availability. Imports from TMDB on first access if not yet cached locally.",
successDescription:
"Full title with seasons, cast, and availability offers",
})
.input(IdParam) .input(IdParam)
.output(TitleDetailOutput), .output(TitleDetailOutput)
.errors({
NOT_FOUND: { message: "Title not found" },
}),
resolve: oc resolve: oc
.route({ method: "POST", path: "/titles/resolve", tags: ["Titles"] }) .route({
method: "POST",
path: "/titles/resolve",
tags: ["Titles"],
summary: "Resolve TMDB ID to local title",
description:
"Look up or import a title by its TMDB ID and media type. Returns the internal ID for use with other endpoints.",
successDescription: "Internal title ID",
})
.input(TmdbIdTypeParam) .input(TmdbIdTypeParam)
.output(TitleResolveOutput), .output(TitleResolveOutput)
.errors({
NOT_FOUND: { message: "Title not found" },
}),
updateStatus: oc updateStatus: oc
.route({ .route({
method: "PUT", method: "PUT",
path: "/titles/{id}/status", path: "/titles/{id}/status",
tags: ["Titles"], tags: ["Titles"],
summary: "Update tracking status",
description:
"Set the user's tracking status for a title. Use null to remove the title from the library entirely.",
}) })
.input(UpdateStatusInput) .input(UpdateStatusInput)
.output(z.void()), .output(z.void()),
@@ -81,6 +107,9 @@ export const contract = {
method: "PUT", method: "PUT",
path: "/titles/{id}/rating", path: "/titles/{id}/rating",
tags: ["Titles"], tags: ["Titles"],
summary: "Rate a title",
description:
"Set a 0-5 star rating for a title. Use 0 to clear the rating.",
}) })
.input(UpdateRatingInput) .input(UpdateRatingInput)
.output(z.void()), .output(z.void()),
@@ -89,6 +118,9 @@ export const contract = {
method: "POST", method: "POST",
path: "/titles/{id}/watch", path: "/titles/{id}/watch",
tags: ["Titles"], tags: ["Titles"],
summary: "Mark movie as watched",
description:
"Log a watch event for a movie. Automatically sets status to completed.",
}) })
.input(IdParam) .input(IdParam)
.output(z.void()), .output(z.void()),
@@ -97,6 +129,9 @@ export const contract = {
method: "POST", method: "POST",
path: "/titles/{id}/watch-all", path: "/titles/{id}/watch-all",
tags: ["Titles"], tags: ["Titles"],
summary: "Mark all episodes watched",
description:
"Mark every episode of a TV show as watched. Requires seasons to be hydrated first.",
}) })
.input(IdParam) .input(IdParam)
.output(z.void()), .output(z.void()),
@@ -105,6 +140,11 @@ export const contract = {
method: "GET", method: "GET",
path: "/titles/{id}/user-info", path: "/titles/{id}/user-info",
tags: ["Titles"], tags: ["Titles"],
summary: "Get user's title info",
description:
"Fetch the current user's tracking status, rating, and watched episode IDs for a title.",
successDescription:
"User's status, rating, and list of watched episode IDs",
}) })
.input(IdParam) .input(IdParam)
.output(UserInfoOutput), .output(UserInfoOutput),
@@ -113,6 +153,10 @@ export const contract = {
method: "GET", method: "GET",
path: "/titles/{id}/recommendations", path: "/titles/{id}/recommendations",
tags: ["Titles"], tags: ["Titles"],
summary: "Get title recommendations",
description:
"Fetch similar titles based on locally cached recommendation data, along with the user's statuses for each.",
successDescription: "Recommended titles with user statuses",
}) })
.input(IdParam) .input(IdParam)
.output(TitleRecommendationsOutput), .output(TitleRecommendationsOutput),
@@ -121,6 +165,10 @@ export const contract = {
method: "POST", method: "POST",
path: "/titles/{id}/hydrate-seasons", path: "/titles/{id}/hydrate-seasons",
tags: ["Titles"], tags: ["Titles"],
summary: "Hydrate TV seasons",
description:
"Fetch full season and episode data from TMDB for a TV show. Required before tracking individual episodes.",
successDescription: "Hydrated seasons with episodes",
}) })
.input(HydrateSeasonsInput) .input(HydrateSeasonsInput)
.output(HydrateSeasonsOutput), .output(HydrateSeasonsOutput),
@@ -129,9 +177,17 @@ export const contract = {
method: "POST", method: "POST",
path: "/titles/quick-add", path: "/titles/quick-add",
tags: ["Titles"], tags: ["Titles"],
summary: "Quick add title to library",
description:
"Import a title by TMDB ID and add it to the user's watchlist in one step. If the title already exists in the user's library, returns alreadyAdded: true.",
successDescription:
"Title ID and whether it was already in the library",
}) })
.input(TmdbIdTypeParam) .input(TmdbIdTypeParam)
.output(QuickAddOutput), .output(QuickAddOutput)
.errors({
INTERNAL_SERVER_ERROR: { message: "Failed to import title from TMDB" },
}),
}, },
episodes: { episodes: {
watch: oc watch: oc
@@ -139,6 +195,8 @@ export const contract = {
method: "POST", method: "POST",
path: "/episodes/{id}/watch", path: "/episodes/{id}/watch",
tags: ["Episodes"], tags: ["Episodes"],
summary: "Mark episode watched",
description: "Record a watch event for a single TV episode.",
}) })
.input(IdParam) .input(IdParam)
.output(z.void()), .output(z.void()),
@@ -147,6 +205,8 @@ export const contract = {
method: "POST", method: "POST",
path: "/episodes/{id}/unwatch", path: "/episodes/{id}/unwatch",
tags: ["Episodes"], tags: ["Episodes"],
summary: "Mark episode unwatched",
description: "Remove the watch record for a single TV episode.",
}) })
.input(IdParam) .input(IdParam)
.output(z.void()), .output(z.void()),
@@ -155,6 +215,9 @@ export const contract = {
method: "POST", method: "POST",
path: "/episodes/batch-watch", path: "/episodes/batch-watch",
tags: ["Episodes"], tags: ["Episodes"],
summary: "Batch mark episodes watched",
description:
"Record watch events for multiple episodes at once. Useful for marking a range of episodes as watched.",
}) })
.input(BatchWatchInput) .input(BatchWatchInput)
.output(z.void()), .output(z.void()),
@@ -165,6 +228,9 @@ export const contract = {
method: "POST", method: "POST",
path: "/seasons/{id}/watch", path: "/seasons/{id}/watch",
tags: ["Seasons"], tags: ["Seasons"],
summary: "Mark season watched",
description:
"Mark all episodes in a season as watched in a single operation.",
}) })
.input(IdParam) .input(IdParam)
.output(z.void()), .output(z.void()),
@@ -173,19 +239,45 @@ export const contract = {
method: "POST", method: "POST",
path: "/seasons/{id}/unwatch", path: "/seasons/{id}/unwatch",
tags: ["Seasons"], tags: ["Seasons"],
summary: "Mark season unwatched",
description:
"Remove all watch records for episodes in a season in a single operation.",
}) })
.input(IdParam) .input(IdParam)
.output(z.void()), .output(z.void()),
}, },
people: { people: {
detail: oc detail: oc
.route({ method: "GET", path: "/people/{id}", tags: ["People"] }) .route({
method: "GET",
path: "/people/{id}",
tags: ["People"],
summary: "Get person details",
description:
"Fetch a person's profile and filmography. Imports from TMDB on first access if not yet cached locally.",
successDescription:
"Person profile, filmography credits, and user statuses for their titles",
})
.input(IdParam) .input(IdParam)
.output(PersonDetailOutput), .output(PersonDetailOutput)
.errors({
NOT_FOUND: { message: "Person not found" },
}),
resolve: oc resolve: oc
.route({ method: "POST", path: "/people/resolve", tags: ["People"] }) .route({
method: "POST",
path: "/people/resolve",
tags: ["People"],
summary: "Resolve TMDB ID to local person",
description:
"Look up or import a person by their TMDB ID. Returns the internal ID for use with other endpoints.",
successDescription: "Internal person ID",
})
.input(TmdbIdParam) .input(TmdbIdParam)
.output(PersonResolveOutput), .output(PersonResolveOutput)
.errors({
NOT_FOUND: { message: "Person not found" },
}),
}, },
dashboard: { dashboard: {
stats: oc stats: oc
@@ -193,6 +285,10 @@ export const contract = {
method: "GET", method: "GET",
path: "/dashboard/stats", path: "/dashboard/stats",
tags: ["Dashboard"], tags: ["Dashboard"],
summary: "Get dashboard statistics",
description:
"Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.",
successDescription: "Aggregate watch statistics",
}) })
.output(DashboardStatsOutput), .output(DashboardStatsOutput),
continueWatching: oc continueWatching: oc
@@ -200,6 +296,11 @@ export const contract = {
method: "GET", method: "GET",
path: "/dashboard/continue-watching", path: "/dashboard/continue-watching",
tags: ["Dashboard"], tags: ["Dashboard"],
summary: "Get continue watching list",
description:
"Fetch TV shows the user is currently watching, with the next unwatched episode and progress for each.",
successDescription:
"In-progress shows with next episode and watch progress",
}) })
.output(ContinueWatchingOutput), .output(ContinueWatchingOutput),
library: oc library: oc
@@ -207,6 +308,10 @@ export const contract = {
method: "GET", method: "GET",
path: "/dashboard/library", path: "/dashboard/library",
tags: ["Dashboard"], tags: ["Dashboard"],
summary: "Get user library",
description:
"Fetch all titles in the user's library with their tracking statuses.",
successDescription: "Library items with user statuses",
}) })
.output(LibraryOutput), .output(LibraryOutput),
recommendations: oc recommendations: oc
@@ -214,6 +319,10 @@ export const contract = {
method: "GET", method: "GET",
path: "/dashboard/recommendations", path: "/dashboard/recommendations",
tags: ["Dashboard"], tags: ["Dashboard"],
summary: "Get personalized recommendations",
description:
"Fetch personalized title recommendations based on the user's library and watch history.",
successDescription: "Recommended titles",
}) })
.output(DashboardRecommendationsOutput), .output(DashboardRecommendationsOutput),
watchHistory: oc watchHistory: oc
@@ -221,6 +330,10 @@ export const contract = {
method: "GET", method: "GET",
path: "/dashboard/watch-history", path: "/dashboard/watch-history",
tags: ["Dashboard"], tags: ["Dashboard"],
summary: "Get watch history",
description:
"Fetch the user's watch counts grouped by time period. Useful for rendering activity charts.",
successDescription: "Watch counts bucketed by time period",
}) })
.input(WatchHistoryInput) .input(WatchHistoryInput)
.output(WatchHistoryOutput), .output(WatchHistoryOutput),
@@ -231,40 +344,87 @@ export const contract = {
method: "GET", method: "GET",
path: "/explore/trending", path: "/explore/trending",
tags: ["Explore"], tags: ["Explore"],
summary: "Get trending titles",
description:
"Fetch today's trending movies and/or TV shows from TMDB, including a featured hero title and the user's statuses.",
successDescription: "Trending items, hero spotlight, and user statuses",
}) })
.input(TrendingTypeParam) .input(TrendingTypeParam)
.output(TrendingOutput), .output(TrendingOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
}),
popular: oc popular: oc
.route({ .route({
method: "GET", method: "GET",
path: "/explore/popular", path: "/explore/popular",
tags: ["Explore"], tags: ["Explore"],
summary: "Get popular titles",
description:
"Fetch currently popular movies or TV shows from TMDB with the user's tracking statuses.",
successDescription: "Popular items with user statuses",
}) })
.input(MediaTypeParam) .input(MediaTypeParam)
.output(PopularOutput), .output(PopularOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
}),
genres: oc genres: oc
.route({ .route({
method: "GET", method: "GET",
path: "/explore/genres", path: "/explore/genres",
tags: ["Explore"], tags: ["Explore"],
summary: "List genres",
description:
"Fetch the list of TMDB genres for movies or TV shows. Used to populate genre filters for discovery.",
successDescription: "Genre ID/name pairs",
}) })
.input(MediaTypeParam) .input(MediaTypeParam)
.output(GenresOutput), .output(GenresOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
}),
}, },
search: oc search: oc
.route({ method: "GET", path: "/search", tags: ["Search"] }) .route({
method: "GET",
path: "/search",
tags: ["Search"],
summary: "Search movies, TV shows, and people",
description:
"Full-text search across movies, TV shows, and people via TMDB. Optionally filter by media type.",
successDescription: "Search results with metadata",
})
.input(SearchInput) .input(SearchInput)
.output(SearchOutput), .output(SearchOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
}),
discover: oc discover: oc
.route({ method: "GET", path: "/discover", tags: ["Discover"] }) .route({
method: "GET",
path: "/discover",
tags: ["Discover"],
summary: "Discover titles by genre",
description:
"Browse movies or TV shows filtered by genre, sorted by popularity. Returns user statuses and episode progress.",
successDescription: "Filtered titles with user statuses",
})
.input(DiscoverInput) .input(DiscoverInput)
.output(DiscoverOutput), .output(DiscoverOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
}),
system: { system: {
publicInfo: oc publicInfo: oc
.route({ .route({
method: "GET", method: "GET",
path: "/system/public-info", path: "/system/public-info",
tags: ["System"], tags: ["System"],
summary: "Get public instance info",
description:
"Fetch public information about this Sofa instance. Does not require authentication. Used by the login screen to display instance details.",
successDescription: "Instance configuration and status",
}) })
.output(PublicInfoOutput), .output(PublicInfoOutput),
authConfig: oc authConfig: oc
@@ -272,13 +432,33 @@ export const contract = {
method: "GET", method: "GET",
path: "/system/auth-config", path: "/system/auth-config",
tags: ["System"], tags: ["System"],
summary: "Get authentication config",
description:
"Fetch the authentication configuration including OIDC availability, password login status, and registration state. Does not require authentication.",
successDescription: "Authentication provider configuration",
}) })
.output(AuthConfigOutput), .output(AuthConfigOutput),
status: oc status: oc
.route({ method: "GET", path: "/system/status", tags: ["System"] }) .route({
method: "GET",
path: "/system/status",
tags: ["System"],
summary: "Get system status",
description:
"Quick check of whether TMDB is configured. Does not require authentication.",
successDescription: "TMDB configuration status",
})
.output(SystemStatusOutput), .output(SystemStatusOutput),
health: oc health: oc
.route({ method: "GET", path: "/system/health", tags: ["System"] }) .route({
method: "GET",
path: "/system/health",
tags: ["System"],
summary: "Get system health report",
description:
"Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Does not require authentication.",
successDescription: "Full system health report",
})
.output(SystemHealthOutput), .output(SystemHealthOutput),
}, },
integrations: { integrations: {
@@ -287,6 +467,10 @@ export const contract = {
method: "GET", method: "GET",
path: "/integrations", path: "/integrations",
tags: ["Integrations"], tags: ["Integrations"],
summary: "List integrations",
description:
"Fetch all configured media server integrations for the current user, including recent webhook/sync events for each.",
successDescription: "Integrations with their recent events",
}) })
.output(IntegrationsListOutput), .output(IntegrationsListOutput),
create: oc create: oc
@@ -294,6 +478,10 @@ export const contract = {
method: "POST", method: "POST",
path: "/integrations", path: "/integrations",
tags: ["Integrations"], tags: ["Integrations"],
summary: "Create or update integration",
description:
"Create a new media server integration or update an existing one. Generates a unique webhook token for the provider.",
successDescription: "Created or updated integration with token",
}) })
.input(CreateIntegrationInput) .input(CreateIntegrationInput)
.output(IntegrationOutput), .output(IntegrationOutput),
@@ -302,6 +490,9 @@ export const contract = {
method: "DELETE", method: "DELETE",
path: "/integrations/{provider}", path: "/integrations/{provider}",
tags: ["Integrations"], tags: ["Integrations"],
summary: "Delete integration",
description:
"Remove a media server integration and all its event history.",
}) })
.input(ProviderParam) .input(ProviderParam)
.output(z.void()), .output(z.void()),
@@ -310,17 +501,40 @@ export const contract = {
method: "POST", method: "POST",
path: "/integrations/{provider}/regenerate-token", path: "/integrations/{provider}/regenerate-token",
tags: ["Integrations"], tags: ["Integrations"],
summary: "Regenerate webhook token",
description:
"Generate a new webhook token for an integration. The old token is immediately invalidated.",
successDescription: "Integration with new token",
}) })
.input(ProviderParam) .input(ProviderParam)
.output(IntegrationOutput), .output(IntegrationOutput)
.errors({
NOT_FOUND: { message: "Integration not found" },
}),
}, },
admin: { admin: {
backups: { backups: {
list: oc list: oc
.route({ method: "GET", path: "/admin/backups", tags: ["Admin"] }) .route({
method: "GET",
path: "/admin/backups",
tags: ["Admin"],
summary: "List backups",
description:
"Fetch all database backups with their sizes and creation times.",
successDescription: "Available backups",
})
.output(BackupsListOutput), .output(BackupsListOutput),
create: oc create: oc
.route({ method: "POST", path: "/admin/backups", tags: ["Admin"] }) .route({
method: "POST",
path: "/admin/backups",
tags: ["Admin"],
summary: "Create backup",
description:
"Create a new manual database backup. The backup is tagged as a manual backup.",
successDescription: "Created backup metadata",
})
.input(z.void()) .input(z.void())
.output(BackupCreateOutput), .output(BackupCreateOutput),
delete: oc delete: oc
@@ -328,22 +542,38 @@ export const contract = {
method: "DELETE", method: "DELETE",
path: "/admin/backups/{filename}", path: "/admin/backups/{filename}",
tags: ["Admin"], tags: ["Admin"],
summary: "Delete backup",
description: "Permanently delete a database backup file by filename.",
}) })
.input(FilenameParam) .input(FilenameParam)
.output(z.void()), .output(z.void())
.errors({
NOT_FOUND: { message: "Backup not found" },
BAD_REQUEST: { message: "Failed to delete backup" },
}),
restore: oc restore: oc
.route({ .route({
method: "POST", method: "POST",
path: "/admin/backups/restore", path: "/admin/backups/restore",
tags: ["Admin"], tags: ["Admin"],
summary: "Restore from backup",
description:
"Upload a database backup file and restore it. A pre-restore backup is automatically created before overwriting the current database.",
}) })
.input(RestoreBackupInput) .input(RestoreBackupInput)
.output(z.void()), .output(z.void())
.errors({
BAD_REQUEST: { message: "Backup restoration failed" },
}),
schedule: oc schedule: oc
.route({ .route({
method: "GET", method: "GET",
path: "/admin/backups/schedule", path: "/admin/backups/schedule",
tags: ["Admin"], tags: ["Admin"],
summary: "Get backup schedule",
description:
"Fetch the current automated backup schedule configuration.",
successDescription: "Backup schedule settings",
}) })
.output(BackupScheduleOutput), .output(BackupScheduleOutput),
updateSchedule: oc updateSchedule: oc
@@ -351,29 +581,75 @@ export const contract = {
method: "PUT", method: "PUT",
path: "/admin/backups/schedule", path: "/admin/backups/schedule",
tags: ["Admin"], tags: ["Admin"],
summary: "Update backup schedule",
description:
"Update the automated backup schedule. Only provided fields are changed; omitted fields keep their current values.",
}) })
.input(UpdateScheduleInput) .input(UpdateScheduleInput)
.output(z.void()), .output(z.void()),
}, },
registration: oc registration: oc
.route({ method: "GET", path: "/admin/registration", tags: ["Admin"] }) .route({
method: "GET",
path: "/admin/registration",
tags: ["Admin"],
summary: "Get registration status",
description:
"Check whether new user registration is currently open or closed.",
successDescription: "Registration open/closed state",
})
.output(RegistrationOutput), .output(RegistrationOutput),
toggleRegistration: oc toggleRegistration: oc
.route({ method: "PUT", path: "/admin/registration", tags: ["Admin"] }) .route({
method: "PUT",
path: "/admin/registration",
tags: ["Admin"],
summary: "Toggle registration",
description: "Open or close new user registration.",
})
.input(ToggleRegistrationInput) .input(ToggleRegistrationInput)
.output(z.void()), .output(z.void()),
updateCheck: oc updateCheck: oc
.route({ method: "GET", path: "/admin/update-check", tags: ["Admin"] }) .route({
method: "GET",
path: "/admin/update-check",
tags: ["Admin"],
summary: "Get update check status",
description:
"Fetch whether automatic update checks are enabled, and the latest cached check result if available.",
successDescription: "Update check configuration and latest result",
})
.output(UpdateCheckOutput), .output(UpdateCheckOutput),
toggleUpdateCheck: oc toggleUpdateCheck: oc
.route({ method: "PUT", path: "/admin/update-check", tags: ["Admin"] }) .route({
method: "PUT",
path: "/admin/update-check",
tags: ["Admin"],
summary: "Toggle update checks",
description:
"Enable or disable automatic update checks against the public API.",
})
.input(ToggleUpdateCheckInput) .input(ToggleUpdateCheckInput)
.output(z.void()), .output(z.void()),
telemetry: oc telemetry: oc
.route({ method: "GET", path: "/admin/telemetry", tags: ["Admin"] }) .route({
method: "GET",
path: "/admin/telemetry",
tags: ["Admin"],
summary: "Get telemetry status",
description:
"Fetch whether anonymous telemetry is enabled and when the last report was sent.",
successDescription: "Telemetry configuration and last report time",
})
.output(TelemetryOutput), .output(TelemetryOutput),
toggleTelemetry: oc toggleTelemetry: oc
.route({ method: "PUT", path: "/admin/telemetry", tags: ["Admin"] }) .route({
method: "PUT",
path: "/admin/telemetry",
tags: ["Admin"],
summary: "Toggle telemetry",
description: "Enable or disable anonymous telemetry reporting.",
})
.input(ToggleTelemetryInput) .input(ToggleTelemetryInput)
.output(z.void()), .output(z.void()),
triggerJob: oc triggerJob: oc
@@ -381,17 +657,37 @@ export const contract = {
method: "POST", method: "POST",
path: "/admin/jobs/trigger", path: "/admin/jobs/trigger",
tags: ["Admin"], tags: ["Admin"],
summary: "Trigger cron job",
description:
"Manually trigger a background cron job by name. The job runs asynchronously.",
}) })
.input(TriggerJobInput) .input(TriggerJobInput)
.output(TriggerJobOutput), .output(TriggerJobOutput)
.errors({
NOT_FOUND: { message: "Job not found" },
}),
}, },
account: { account: {
updateName: oc updateName: oc
.route({ method: "PUT", path: "/account/name", tags: ["Account"] }) .route({
method: "PUT",
path: "/account/name",
tags: ["Account"],
summary: "Update display name",
description: "Change the current user's display name.",
})
.input(UpdateNameInput) .input(UpdateNameInput)
.output(z.void()), .output(z.void()),
uploadAvatar: oc uploadAvatar: oc
.route({ method: "POST", path: "/account/avatar", tags: ["Account"] }) .route({
method: "POST",
path: "/account/avatar",
tags: ["Account"],
summary: "Upload avatar",
description:
"Upload a new profile avatar image. Accepts JPEG, PNG, WebP, or GIF up to 2 MB.",
successDescription: "URL of the uploaded avatar image",
})
.input(UploadAvatarInput) .input(UploadAvatarInput)
.output(UploadAvatarOutput), .output(UploadAvatarOutput),
removeAvatar: oc removeAvatar: oc
@@ -399,6 +695,9 @@ export const contract = {
method: "DELETE", method: "DELETE",
path: "/account/avatar", path: "/account/avatar",
tags: ["Account"], tags: ["Account"],
summary: "Remove avatar",
description:
"Delete the current user's profile avatar, reverting to the default.",
}) })
.input(z.void()) .input(z.void())
.output(z.void()), .output(z.void()),
File diff suppressed because it is too large. Load diff
+9 -37
View File
@@ -1,43 +1,15 @@
import { OpenAPIGenerator } from "@orpc/openapi";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { contract } from "@sofa/api/contract";
import packageJson from "../apps/server/package.json" with { type: "json" }; import packageJson from "../apps/server/package.json" with { type: "json" };
import {
generateOpenApiSpec,
openApiTags,
} from "../apps/server/src/orpc/openapi-spec";
const generator = new OpenAPIGenerator({ const spec = await generateOpenApiSpec({
schemaConverters: [new ZodToJsonSchemaConverter()], title: "Sofa API",
}); version: packageJson.version || "1.0.0",
const spec = await generator.generate(contract, {
info: { title: "Sofa API", version: packageJson.version || "1.0.0" },
servers: [{ url: "https://sofa.example.com/api/v1" }], servers: [{ url: "https://sofa.example.com/api/v1" }],
tags: [ sessionCookieName: "better-auth.session_token",
{ name: "Titles", description: "Movie and TV show management" }, tags: [...openApiTags],
{ name: "Episodes", description: "Episode watch tracking" },
{ name: "Seasons", description: "Season watch tracking" },
{ name: "People", description: "Cast and crew information" },
{ name: "Dashboard", description: "User dashboard data" },
{ name: "Explore", description: "Discover trending and popular content" },
{ name: "Search", description: "Search for movies and TV shows" },
{
name: "Discover",
description: "Advanced content discovery with filters",
},
{ name: "System", description: "Server status and configuration" },
{ name: "Integrations", description: "Media server integrations" },
{ name: "Admin", description: "Server administration" },
{ name: "Account", description: "User account management" },
],
components: {
securitySchemes: {
session: {
type: "apiKey",
name: "better-auth.session_token",
in: "cookie",
description: "Better Auth session cookie",
},
},
},
}); });
// Normalize file upload media types that fumadocs-openapi doesn't support // Normalize file upload media types that fumadocs-openapi doesn't support