mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
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:
20 files changed
+5153
-2928
No files matched your search
@@ -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) ─────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+331
-32
@@ -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()),
|
||||||
|
|||||||
+922
-459
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user