import { z } from "zod"; import * as agent from "./agent.ts"; import * as assets from "./assets.ts"; import * as auth from "./auth.ts"; import * as browser from "./browser.ts"; import * as cache from "./cache.ts"; import { MetadataEnrichOutput, WebSearchTestInput, WebSearchTestResult } from "./capabilities-io.ts"; import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts"; import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts"; import * as content from "./content.ts"; import * as board from "./download-board.ts"; import * as events from "./events.ts"; import * as execution from "./execution.ts"; import * as files from "./files.ts"; import * as identity from "./identity.ts"; import * as instances from "./instances.ts"; import * as libraryVersions from "./library-versions.ts"; import * as mcp from "./mcp.ts"; import * as ops from "./ops.ts"; import * as organization from "./organization.ts"; import * as pi from "./pi.ts"; import * as policy from "./policy.ts"; import * as schedules from "./schedules.ts"; import * as search from "./search.ts"; import * as settingsNs from "./settings.ts"; import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts"; import * as sources from "./sources.ts"; export * from "./search.ts"; // 所有 REST 端点的路径常量与请求/响应 schema 汇总。 // path 相对 API_PREFIX,参数用 OpenAPI 风格 `{id}`(server 的 createRoute 直接使用); // web 用 buildPath() 填参数。响应体统一套 apiResponse() 信封,这里的 response 是 data 部分。 export const API_PREFIX = "/api/v1"; export type HttpMethod = "get" | "post" | "patch" | "put" | "delete"; /** * 鉴权方式(不写即按 scope 校验,cookie 会话与 API Key 同样适用): * - public:免鉴权(必须与服务端 http/auth.ts 的 PUBLIC_AUTH_PATHS 一致,测试断言); * - session:只接受控制台管理员 cookie 会话,API Key(含 admin scope)一律 403。 */ export type EndpointAuth = "public" | "session"; export interface EndpointDef { method: HttpMethod; path: string; scope: ApiScope; auth?: EndpointAuth; summary: string; params?: z.ZodObject; query?: z.ZodType; body?: z.ZodType; response: z.ZodType; } const ep = (e: E): E => e; /** 列表查询:游标分页 + 资源自有过滤 */ const listQuery = (filters: S) => CursorQuery.extend(filters); /** 删除/无内容动作的响应 */ export const OkResult = z.object({ ok: z.literal(true) }); interface CrudSchemas { row: z.ZodType; create?: z.ZodType; update?: z.ZodType; filters?: z.ZodRawShape; } /** 标准资源端点:list / get / create / update / remove(按给出的 schema 决定是否生成) */ function crud( base: B, name: string, s: CrudSchemas, scopes: { read: ApiScope; write: ApiScope }, ) { const item = `${base}/{id}` as const; return { list: ep({ method: "get", path: base, scope: scopes.read, summary: `${name}列表`, query: listQuery(s.filters ?? {}), response: cursorPage(s.row), }), get: ep({ method: "get", path: item, scope: scopes.read, summary: `${name}详情`, params: IdParam, response: s.row, }), ...(s.create ? { create: ep({ method: "post", path: base, scope: scopes.write, summary: `新建${name}`, body: s.create, response: s.row, }), } : {}), ...(s.update ? { update: ep({ method: "patch", path: item, scope: scopes.write, summary: `修改${name}`, params: IdParam, body: s.update, response: s.row, }), } : {}), }; } const R = { read: "read", write: "admin" } as const; const del = (path: string, summary: string) => ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult }); /** 实例连通性测试 */ const instanceTest = (path: string, summary: string) => ep({ method: "post", path, scope: "admin", summary, params: IdParam, response: instances.InstanceTestResult }); /** 入队后台任务的受理结果(taskId = 引擎 run id) */ export const TaskAccepted = z.object({ taskId: Id }); export type TaskAccepted = z.infer; /** 人工指定来源资源身份的结果(待识别池 assign / source-releases identity) */ export const ReleaseIdentityResult = z.object({ mediaEntityId: Id, contentUnitIds: z.array(Id) }); export type ReleaseIdentityResult = z.infer; /** 身份修订历史 */ export const IdentityRevisionList = z.object({ items: z.array(identity.IdentityRevision) }); export type IdentityRevisionList = z.infer; /** 字段覆盖/取消覆盖的结果 */ const FieldOverrideResult = z.object({ mediaEntityId: Id, changedFields: z.array(z.string()), lockedFields: z.array(z.string()), }); const FieldParam = z.object({ id: Id, field: content.EntityMetadataField }); const UnitFieldParam = z.object({ id: Id, field: content.UnitMetadataField }); const UnitFieldOverrideResult = z.object({ contentUnitId: Id, changedFields: z.array(z.string()), lockedFields: z.array(z.string()), }); /** 引擎 run id 不是 uuid(openworkflow 自有 id) */ const EngineRunIdParam = z.object({ id: z.string().min(1).max(200) }); /** 待处理事项聚合分组 */ export const DecisionSummaryGroup = z.object({ kind: agent.DecisionKind, mediaEntityId: Id.nullable(), mediaEntityTitle: z.string().nullable(), count: z.number().int(), maxSeverity: z.enum(["info", "warning", "critical"]), latestAt: z.string(), }); export type DecisionSummaryGroup = z.infer; export const DecisionSummary = z.object({ total: z.number().int(), groups: z.array(DecisionSummaryGroup) }); export type DecisionSummary = z.infer; export const Api = { // ── 内容 / 身份 ── mediaEntities: { ...crud( "/media-entities", "作品", { row: content.MediaEntity, create: content.MediaEntityCreate, update: content.MediaEntityUpdate, filters: { kind: content.MediaKind.optional(), status: content.MediaEntityStatus.optional(), q: z.string().max(200).optional(), }, }, R, ), merge: ep({ method: "post", path: "/media-entities/{id}/merge", scope: "admin", summary: "合并重复作品(保留旧 ID 跳转)", params: IdParam, body: z.object({ intoId: Id, reason: z.string().max(2000) }), response: content.MediaEntity, }), detail: ep({ method: "get", path: "/media-entities/{id}/detail", scope: "read", summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)", params: IdParam, response: content.MediaEntityDetail, }), setIdentity: ep({ method: "post", path: "/media-entities/{id}/identities", scope: "organize.execute", summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁", params: IdParam, body: identity.EntityIdentitySet, response: z.object({ mediaEntityId: Id, changed: z.boolean(), identityId: Id.optional(), retired: z.array(Id).optional(), }), }), refresh: ep({ method: "post", path: "/media-entities/{id}/refresh", scope: "organize.execute", summary: "刷新元数据(字段级;用户锁优先,不动路径)。标题 / 简介按设置 metadata.languages 回退;full=1 拉取全部季的每集信息", params: IdParam, query: z.object({ full: z.enum(["0", "1", "true", "false"]).optional() }), response: TaskAccepted, }), setField: ep({ method: "put", path: "/media-entities/{id}/fields/{field}", scope: "organize.execute", summary: "覆盖作品字段并锁定(自动刷新不再改写)", params: FieldParam, body: content.FieldOverrideInput, response: FieldOverrideResult, }), clearField: ep({ method: "delete", path: "/media-entities/{id}/fields/{field}", scope: "organize.execute", summary: "取消字段覆盖与锁定", params: FieldParam, response: FieldOverrideResult, }), }, contentUnits: { ...crud( "/content-units", "内容单元", { row: content.ContentUnit, update: content.ContentUnitUpdate, filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() }, }, R, ), setField: ep({ method: "put", path: "/content-units/{id}/fields/{field}", scope: "organize.execute", summary: "覆盖季 / 集字段并锁定(元数据刷新不再改写)", params: UnitFieldParam, body: content.FieldOverrideInput, response: UnitFieldOverrideResult, }), clearField: ep({ method: "delete", path: "/content-units/{id}/fields/{field}", scope: "organize.execute", summary: "取消季 / 集字段覆盖与锁定", params: UnitFieldParam, response: UnitFieldOverrideResult, }), }, titleAliases: { ...crud( "/title-aliases", "别名", { row: content.TitleAlias, create: content.TitleAliasCreate, filters: { mediaEntityId: Id.optional(), q: z.string().max(200).optional() }, }, R, ), remove: del("/title-aliases/{id}", "删除别名"), }, externalIdentities: { ...crud( "/external-identities", "外部身份", { row: identity.ExternalIdentity, create: identity.ExternalIdentityCreate, update: identity.ExternalIdentityUpdate, filters: { mediaEntityId: Id.optional(), provider: z.string().optional(), status: identity.IdentityStatus.optional(), }, }, R, ), revisions: ep({ method: "get", path: "/external-identities/{id}/revisions", scope: "read", summary: "身份修订历史", params: IdParam, response: IdentityRevisionList, }), }, // ── 文件(只读;写入只经整理/对账流程,不开放直接改) ── mediaAssets: crud( "/media-assets", "资产", { row: assets.MediaAsset, filters: { mediaEntityId: Id.optional(), status: assets.AssetStatus.optional() }, }, R, ), assetUnitCoverage: crud( "/asset-unit-coverage", "资产覆盖", { row: assets.AssetUnitCoverage, filters: { mediaAssetId: Id.optional(), contentUnitId: Id.optional() }, }, R, ), assetLocations: crud( "/asset-locations", "资产位置", { row: assets.AssetLocation, filters: { mediaAssetId: Id.optional(), status: assets.LocationStatus.optional() }, }, R, ), pathBindings: crud( "/path-bindings", "路径绑定", { row: assets.PathBinding, filters: { mediaEntityId: Id.optional(), status: assets.BindingStatus.optional() }, }, R, ), // ── 实例 ── libraryRoots: { ...crud( "/library-roots", "库根", { row: instances.LibraryRoot, create: instances.LibraryRootCreate, update: instances.LibraryRootUpdate, }, R, ), test: instanceTest("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)"), remove: del("/library-roots/{id}", "删除库根"), }, downloaderInstances: { ...crud( "/downloader-instances", "下载器实例", { row: instances.DownloaderInstance, create: instances.DownloaderInstanceCreate, update: instances.DownloaderInstanceUpdate, }, R, ), test: ep({ method: "post", path: "/downloader-instances/{id}/test", scope: "admin", summary: "测试下载器连接", params: IdParam, response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }), }), remove: del("/downloader-instances/{id}", "删除下载器实例"), }, mediaServerInstances: { ...crud( "/media-server-instances", "媒体服务器实例", { row: instances.MediaServerInstance, create: instances.MediaServerInstanceCreate, update: instances.MediaServerInstanceUpdate, }, R, ), test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"), remove: del("/media-server-instances/{id}", "删除媒体服务器实例"), }, pathMappings: { ...crud( "/path-mappings", "路径映射", { row: instances.PathMapping, create: instances.PathMappingCreate, update: instances.PathMappingUpdate, }, R, ), verify: ep({ method: "post", path: "/path-mappings/{id}/verify", scope: "admin", summary: "校验路径映射可读写与挂载健康", params: IdParam, response: instances.PathMapping, }), remove: del("/path-mappings/{id}", "删除路径映射"), }, // ── 来源 ── sites: { ...crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R), list: ep({ method: "get", path: "/sites", scope: "read", summary: "站点列表(include=accounts 附带账号与整体账号状态)", query: sources.SiteListQuery, response: cursorPage(sources.SiteListItem), }), remove: del("/sites/{id}", "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)"), healthCheck: ep({ method: "post", path: "/sites/{id}/health-check", scope: "admin", summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列", params: IdParam, response: sources.SiteHealthResult, }), refreshLogin: ep({ method: "post", path: "/sites/{id}/refresh-login", scope: "admin", summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据", params: IdParam, body: sources.SiteRefreshLoginInput, response: sources.SiteRefreshLoginAccepted, }), userData: ep({ method: "get", path: "/sites/{id}/user-data", scope: "read", summary: "站点账号用户数据按天历史", params: IdParam, query: sources.SiteUserDataQuery, response: z.object({ items: z.array(sources.SiteUserDataPoint) }), }), enable: ep({ method: "post", path: "/sites/{id}/enable", scope: "admin", summary: "开启站点", params: IdParam, response: sources.Site, }), disable: ep({ method: "post", path: "/sites/{id}/disable", scope: "admin", summary: "关闭站点(停止网络访问与新候选,历史资源仍可查)", params: IdParam, response: sources.Site, }), snapshot: ep({ method: "post", path: "/sites/{id}/snapshot", scope: "admin", summary: "立即同步站点快照(受站点限流)", params: IdParam, body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }), response: TaskAccepted, }), snapshots: ep({ method: "get", path: "/sites/{id}/snapshots", scope: "read", summary: "站点快照/补查记录(覆盖范围与新鲜度)", params: IdParam, query: CursorQuery, response: cursorPage(sources.SiteSnapshot), }), requestStats: ep({ method: "get", path: "/sites/{id}/request-stats", scope: "read", summary: "站点请求统计:最近 24 小时对该站发出的请求次数(按小时)与生效的访问默认", params: IdParam, response: sources.SiteRequestStats, }), credentials: ep({ method: "put", path: "/sites/{id}/credentials", scope: "admin", summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)", params: IdParam, body: instances.SiteCredentialsPut, response: instances.SiteCredentialsResult, }), }, siteDefinitions: { list: ep({ method: "get", path: "/site-definitions", scope: "read", summary: "随产品发布的站点适配器定义(含是否已登记)", response: z.object({ items: z.array(sources.SiteDefinitionView) }), }), }, /** 站点工作台(整理视图):库存叠加走 inventory.overlay */ siteWorkbench: { releases: ep({ method: "get", path: "/sites/{id}/releases", scope: "read", summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)", params: IdParam, query: sources.WorkbenchReleaseQuery, response: sources.WorkbenchReleasePage, }), release: ep({ method: "get", path: "/sites/{id}/releases/{rid}", scope: "read", summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)", params: z.object({ id: Id, rid: Id }), query: z.object({ refresh: z.enum(["true", "false"]).optional() }), response: sources.WorkbenchReleaseDetail, }), }, siteAccounts: { ...crud( "/site-accounts", "站点账号", { row: sources.SiteAccount, create: sources.SiteAccountCreate, update: sources.SiteAccountUpdate, filters: { siteId: Id.optional() }, }, R, ), remove: del("/site-accounts/{id}", "删除站点账号与其凭证"), }, feedSources: { ...crud( "/feed-sources", "Feed", { row: sources.FeedSource, create: sources.FeedSourceCreate, update: sources.FeedSourceUpdate, filters: { siteId: Id.optional(), health: sources.FeedHealth.optional() }, }, R, ), fetchNow: ep({ method: "post", path: "/feed-sources/{id}/fetch", scope: "admin", summary: "立即抓取(仍受站点限流)", params: IdParam, response: z.object({ taskId: Id }), }), enable: ep({ method: "post", path: "/feed-sources/{id}/enable", scope: "admin", summary: "启用 Feed", params: IdParam, response: sources.FeedSource, }), disable: ep({ method: "post", path: "/feed-sources/{id}/disable", scope: "admin", summary: "停用 Feed", params: IdParam, response: sources.FeedSource, }), reset: ep({ method: "post", path: "/feed-sources/{id}/reset", scope: "admin", summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)", params: IdParam, response: sources.FeedSource, }), }, sourceReleases: { ...crud( "/source-releases", "资源", { row: sources.SourceRelease, filters: { siteId: Id.optional(), mediaEntityId: Id.optional(), resolutionStatus: sources.ResolutionStatus.optional(), q: z.string().max(200).optional(), }, }, R, ), assignIdentity: ep({ method: "post", path: "/source-releases/{id}/identity", scope: "organize.execute", summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)", params: IdParam, body: identity.ReleaseIdentityAssign, response: ReleaseIdentityResult, }), unlock: ep({ method: "post", path: "/source-releases/{id}/unlock", scope: "organize.execute", summary: "解除资源的人工锁并重新识别", params: IdParam, response: TaskAccepted, }), }, observations: crud( "/observations", "观察", { row: sources.Observation, filters: { feedId: Id.optional(), status: sources.ObservationStatus.optional() }, }, R, ), // ── 规则 ── subscriptions: { ...crud( "/subscriptions", "订阅", { row: policy.Subscription, create: policy.SubscriptionCreate, update: policy.SubscriptionUpdate, filters: { status: policy.SubscriptionStatus.optional(), mediaEntityId: Id.optional(), mediaKind: policy.SubscriptionMediaKind.optional(), /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ q: z.string().max(200).optional(), }, }, R, ), /** 日历(静态路径,须先于 /subscriptions/{id} 注册) */ calendar: ep({ method: "get", path: "/subscriptions/calendar", scope: "read", summary: "订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与入库状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天", query: z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), mediaKind: policy.SubscriptionMediaKind.optional(), }), response: policy.SubscriptionCalendar, }), reset: ep({ method: "post", path: "/subscriptions/{id}/reset", scope: "admin", summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已入库资产保留", params: IdParam, response: policy.SubscriptionResetResult, }), /** 总览:在 crud 列表上加分组/排序/筛选,条目带卡片派生字段,另给各分组计数(docs/design/subscriptions.md §1) */ list: ep({ method: "get", path: "/subscriptions", scope: "read", summary: "订阅总览(分组、进度、各版本摘要、上次更新、下次搜索、站点、规则组;cancelled 只在显式 status=cancelled 时返回)", query: listQuery({ status: policy.SubscriptionStatus.optional(), mediaEntityId: Id.optional(), mediaKind: policy.SubscriptionMediaKind.optional(), /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ q: z.string().max(200).optional(), group: policy.SubscriptionGroup.optional(), sort: policy.SubscriptionSort.optional(), order: z.enum(["asc", "desc"]).optional(), siteId: Id.optional(), ruleGroupId: Id.optional(), origin: policy.SubscriptionOrigin.optional(), upgrading: z.enum(["true", "false"]).optional(), }), response: policy.SubscriptionOverviewPage, }), coverage: ep({ method: "get", path: "/subscriptions/{id}/coverage", scope: "read", summary: "覆盖矩阵:季 → 集 × 收录版本(电影:内容版本 × 收录版本),每格 have/inflight/missing/skipped;缓存 30s,refresh=true 强制重算", params: IdParam, query: z.object({ refresh: z.enum(["true", "false"]).optional() }), response: policy.SubscriptionCoverage, }), pause: ep({ method: "post", path: "/subscriptions/{id}/pause", scope: "admin", summary: "暂停订阅(不评估、不搜索)", params: IdParam, response: policy.Subscription, }), resume: ep({ method: "post", path: "/subscriptions/{id}/resume", scope: "admin", summary: "恢复已暂停的订阅(TMDB 未就绪回到 pending),立即重评并排上搜索", params: IdParam, response: policy.Subscription, }), resubscribe: ep({ method: "post", path: "/subscriptions/{id}/resubscribe", scope: "admin", summary: "重新订阅已完成/已取消的订阅(历史保留),立即重评并搜索", params: IdParam, response: policy.Subscription, }), batch: ep({ method: "post", path: "/subscriptions/batch", scope: "admin", summary: "批量:立即搜索 / 暂停 / 恢复 / 删除(删除 = 取消,写审计)", body: policy.SubscriptionBatch, response: policy.SubscriptionBatchResult, }), fillSlot: ep({ method: "post", path: "/subscriptions/{id}/slots/{slotId}/fill", scope: "admin", summary: "手动补全某收录版本(不受自动补全开关与补全预算限制)", params: z.object({ id: Id, slotId: Id }), body: policy.SlotFillRequest, response: z.object({ runId: z.string(), deduplicated: z.boolean() }), }), slotOrder: ep({ method: "put", path: "/subscriptions/{id}/slot-order", scope: "admin", summary: "收录版本排序(第一个为主版本)", params: IdParam, body: policy.SlotOrderSet, response: z.array(policy.AcquisitionSlot), }), search: ep({ method: "post", path: "/subscriptions/{id}/search", scope: "admin", summary: "手动主动搜索(按标题/别名在已开启站点搜索,结果走同一识别评估链)", params: IdParam, response: z.object({ runId: z.string(), deduplicated: z.boolean() }), }), progress: ep({ method: "get", path: "/subscriptions/{id}/progress", scope: "read", summary: "订阅进度:季总集数账本、每集状态(已覆盖/在途/缺失/疑似缺集)、收录版本在途与延后", params: IdParam, response: policy.SubscriptionProgress, }), setEpisodeTotal: ep({ method: "post", path: "/subscriptions/{id}/episode-total", scope: "admin", summary: "手动设置季总集数(只升;locked 锁定后刷新不再改写)", params: IdParam, body: policy.EpisodeTotalSet, response: policy.SubscriptionProgress, }), }, subscriptionHistory: crud( "/subscription-history", "订阅完成历史", { row: policy.SubscriptionHistory, filters: { mediaEntityId: Id.optional(), subscriptionId: Id.optional() }, }, R, ), policyRevisions: { ...crud( "/policy-revisions", "策略版本", { row: policy.PolicyRevision, create: policy.PolicyRevisionCreate, update: policy.PolicyRevisionUpdate, filters: { policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }, }, R, ), preview: ep({ method: "post", path: "/policy-revisions/{id}/preview", scope: "read", summary: "历史回放预览", params: IdParam, response: policy.PolicyRevision, }), publish: ep({ method: "post", path: "/policy-revisions/{id}/publish", scope: "admin", summary: "发布策略版本", params: IdParam, body: z.object({ expectedRevision: z.number().int().min(1) }), response: policy.PolicyRevision, }), rollback: ep({ method: "post", path: "/policy-revisions/{id}/rollback", scope: "admin", summary: "回滚到该版本", params: IdParam, response: policy.PolicyRevision, }), }, acquisitionSlots: crud( "/acquisition-slots", "收录版本", { row: policy.AcquisitionSlot, create: policy.AcquisitionSlotCreate, update: policy.AcquisitionSlotUpdate, filters: { mediaEntityId: Id.optional(), status: policy.SlotStatus.optional(), subscriptionId: Id.optional(), }, }, R, ), preferenceTags: { ...crud( "/preference-tags", "偏好标签", { row: policy.PreferenceTag, create: policy.PreferenceTagCreate, update: policy.PreferenceTagUpdate, filters: { status: policy.PreferenceStatus.optional(), kind: policy.PreferenceKind.optional() }, }, R, ), /** 删除 = 标记 deleted + 写抑制记录 */ remove: del("/preference-tags/{id}", "删除偏好标签(留抑制记录)"), revisions: ep({ method: "get", path: "/preference-tags/{id}/revisions", scope: "read", summary: "偏好标签修订历史(新→旧;取自审计事件)", params: IdParam, response: policy.PreferenceTagRevisionList, }), }, preferenceSuppressions: crud( "/preference-suppressions", "偏好抑制", { row: policy.PreferenceSuppression, create: policy.PreferenceSuppressionCreate, update: policy.PreferenceSuppressionUpdate, filters: { active: z.enum(["true", "false"]).optional() }, }, R, ), // ── 规则(W9-D):规则组 = 收录过滤条件 + 偏好标签合一。静态路径先于 /rules/{id} 注册 ── rules: { keywords: ep({ method: "get", path: "/rules/keywords", scope: "read", summary: "关键字表:内置(对齐 MP,含判定依据与上游正则)+ 自定义条件", response: policy.RuleKeywordList, }), parse: ep({ method: "post", path: "/rules/parse", scope: "read", summary: "规则串 → 结构化层(`>` 分层,层内!& | 与括号);错误逐条给位置与相近名", body: policy.RuleParseRequest, response: policy.RuleParseResult, }), serialize: ep({ method: "post", path: "/rules/serialize", scope: "read", summary: "结构化层 → 规范化规则串", body: policy.RuleSerializeRequest, response: policy.RuleSerializeResult, }), defaults: ep({ method: "get", path: "/rules/defaults", scope: "read", summary: "三套默认规则组链:订阅 / 洗版 / 搜索", response: policy.RuleDefaults, }), setDefaults: ep({ method: "put", path: "/rules/defaults", scope: "admin", summary: "设置默认规则组链(按数组顺序串联,pri 取最后一个带层的组)", body: policy.RuleDefaults, response: policy.RuleDefaults, }), ...crud( "/rules", "规则组", { row: policy.RuleGroup, create: policy.RuleGroupCreate, update: policy.RuleGroupUpdate, filters: { kind: policy.RuleGroupKind.optional(), source: policy.RuleGroupSource.optional(), enabled: z.enum(["true", "false"]).optional(), includeDeleted: z.enum(["true", "false"]).optional(), q: z.string().max(80).optional(), }, }, R, ), /** 删除 = 标记 deleted_at + 写抑制记录 */ remove: del("/rules/{id}", "删除规则组(留抑制记录;被引用时 409)"), revisions: ep({ method: "get", path: "/rules/{id}/revisions", scope: "read", summary: "规则组修订历史(新→旧;取自审计事件)", params: IdParam, response: policy.RuleGroupRevisionList, }), preview: ep({ method: "post", path: "/rules/{id}/preview", scope: "read", summary: "试算:对最近 N 条已识别资源(或指定资源/标题)跑本组,给 pri、命中层与拒绝原因", params: IdParam, body: policy.RulePreviewRequest, response: policy.RulePreviewResult, }), }, ruleAtoms: { ...crud( "/rules/atoms", "自定义条件", { row: policy.RuleAtom, create: policy.RuleAtomCreate, update: policy.RuleAtomUpdate }, R, ), remove: del("/rules/atoms/{id}", "删除自定义条件(被规则组引用时 409)"), }, /** /policy-revisions 的新路径(旧路径保留为别名,见 docs/runbook/rules.md) */ rulePolicies: { list: ep({ method: "get", path: "/rules/policies", scope: "read", summary: "收录策略版本列表", query: listQuery({ policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }), response: cursorPage(policy.PolicyRevision), }), get: ep({ method: "get", path: "/rules/policies/{id}", scope: "read", summary: "收录策略版本", params: IdParam, response: policy.PolicyRevision, }), create: ep({ method: "post", path: "/rules/policies", scope: "admin", summary: "新建策略草案", body: policy.PolicyRevisionCreate, response: policy.PolicyRevision, }), update: ep({ method: "patch", path: "/rules/policies/{id}", scope: "admin", summary: "修改策略草案", params: IdParam, body: policy.PolicyRevisionUpdate, response: policy.PolicyRevision, }), preview: ep({ method: "post", path: "/rules/policies/{id}/preview", scope: "read", summary: "预览策略草案", params: IdParam, response: policy.PolicyRevision, }), publish: ep({ method: "post", path: "/rules/policies/{id}/publish", scope: "admin", summary: "发布策略版本", params: IdParam, body: z.object({ expectedRevision: z.number().int().min(1) }), response: policy.PolicyRevision, }), rollback: ep({ method: "post", path: "/rules/policies/{id}/rollback", scope: "admin", summary: "回滚到该策略版本", params: IdParam, response: policy.PolicyRevision, }), }, /** /preference-suppressions 的新路径(旧路径保留为别名) */ ruleSuppressions: crud( "/rules/suppressions", "规则抑制记录", { row: policy.PreferenceSuppression, create: policy.PreferenceSuppressionCreate, update: policy.PreferenceSuppressionUpdate, filters: { active: z.enum(["true", "false"]).optional() }, }, R, ), // ── 执行 ── tasks: { ...crud( "/tasks", "任务", { row: execution.Task, filters: { status: execution.TaskStatus.optional(), kind: execution.TaskKind.optional(), subjectId: Id.optional(), }, }, R, ), cancel: ep({ method: "post", path: "/tasks/{id}/cancel", scope: "organize.execute", summary: "取消任务(停止未来步骤)", params: IdParam, body: execution.TaskCancelRequest, response: execution.Task, }), retry: ep({ method: "post", path: "/tasks/{id}/retry", scope: "organize.execute", summary: "重试失败任务", params: IdParam, response: execution.Task, }), operations: ep({ method: "get", path: "/tasks/{id}/operations", scope: "read", summary: "任务的操作账本(业务时间线)", params: IdParam, response: z.object({ items: z.array(execution.AssetOperation) }), }), }, acquisitionPlans: { ...crud( "/acquisition-plans", "下载计划", { row: execution.AcquisitionPlan, filters: { slotId: Id.optional(), status: execution.PlanStatus.optional() }, }, R, ), create: ep({ method: "post", path: "/acquisition-plans", scope: "organize.execute", summary: "手动提交下载:与自动收录同一流程(download.plan → download.submit,含硬条件/预算/缓存阈值/授权检查);未生成计划时返回拒绝原因", body: execution.ManualAcquisitionRequest, response: execution.ManualAcquisitionResult, }), why: ep({ method: "get", path: "/acquisition-plans/{id}/why", scope: "read", summary: "下载的来由:来源(订阅 / 智能体会话 / 手动)、目的(补缺 / 升级 / 换源)、被替换的版本、选中理由要点与原始评估", params: IdParam, response: board.DownloadWhy, }), cancel: ep({ method: "post", path: "/acquisition-plans/{id}/cancel", scope: "organize.execute", summary: "取消还没提交到下载器的计划(待批准 / 已预留):释放收录版本与预算预留;已有下载任务的返回 409。待批准的计划请拒绝审批(拒绝后计划自动取消)", params: IdParam, response: execution.AcquisitionPlan, }), }, downloadTasks: { ...crud( "/download-tasks", "下载任务", { row: execution.DownloadTask, filters: { instanceId: Id.optional(), status: execution.DownloadStatus.optional(), managed: z.enum(["true", "false"]).optional(), }, }, R, ), board: ep({ method: "get", path: "/download-tasks/board", scope: "read", summary: "下载面板:按进行中 / 已完成分组列出下载任务(进行中含还没提交、在等批准或下载盘空间的计划,kind=plan),附每个种子的实时速度、剩余时间、连接数与下载器总速度(实时数据短时缓存)", query: board.DownloadBoardQuery, response: board.DownloadBoard, }), pause: ep({ method: "post", path: "/download-tasks/{id}/pause", scope: "organize.execute", summary: "暂停(记为用户暂停,阈值恢复不会自动恢复)", params: IdParam, response: execution.DownloadTask, }), resume: ep({ method: "post", path: "/download-tasks/{id}/resume", scope: "organize.execute", summary: "恢复下载", params: IdParam, response: execution.DownloadTask, }), }, // ── 人工 / AI ── decisions: { ...crud( "/decisions", "待处理事项", { row: agent.Decision, filters: { status: agent.DecisionStatus.optional(), kind: agent.DecisionKind.optional() }, }, R, ), resolve: ep({ method: "post", path: "/decisions/{id}/resolve", scope: "organize.execute", summary: "处理待处理事项", params: IdParam, body: agent.DecisionResolveInput, response: agent.Decision, }), summary: ep({ method: "get", path: "/decisions/summary", scope: "read", summary: "未关闭待处理事项按作品与问题类型聚合", query: z.object({ status: agent.DecisionStatus.optional() }), response: DecisionSummary, }), }, approvals: { ...crud( "/approvals", "审批", { row: agent.Approval, filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() }, }, R, ), // 审批的申请与决定只在控制台管理员会话里做:API Key(哪怕持 organize.execute)不能给自己的计划开审批(验收 S1) update: ep({ method: "patch", path: "/approvals/{id}", scope: "admin", auth: "session", summary: "批准/拒绝/撤销单次审批(只接受控制台管理员会话)", params: IdParam, body: agent.ApprovalUpdate, response: agent.Approval, }), create: ep({ method: "post", path: "/approvals", scope: "admin", auth: "session", summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期;只接受控制台管理员会话)", body: agent.ApprovalRequest, response: agent.Approval, }), }, automationGrants: crud( "/automation-grants", "持续授权", { row: agent.AutomationGrant, create: agent.AutomationGrantCreate, update: agent.AutomationGrantUpdate, filters: { status: agent.GrantStatus.optional() }, }, R, ), /** Agent 会话(W2-D):建 run 只入队持久任务 runAgent;增量经全局 SSE(agent.*),断线按 runSeq 用 afterSeq 补读 */ agentRuns: { list: ep({ method: "get", path: "/agent/runs", scope: "read", summary: "智能体运行列表(新→旧)", query: agent.AgentRunListQuery, response: cursorPage(agent.AgentRun), }), get: ep({ method: "get", path: "/agent/runs/{id}", scope: "read", summary: "智能体运行详情:事件按 seq 分页、追加消息、服务端核查的目标汇报", params: IdParam, query: agent.AgentRunDetailQuery, response: agent.AgentRunDetail, }), create: ep({ method: "post", path: "/agent/runs", scope: "organize.execute", summary: "委托目标:建 agent_run 并入队 runAgent(proposeFirst = 先看方案再执行;mode 已弃用)", body: agent.AgentRunStartInput, response: agent.AgentRun, }), message: ep({ method: "post", path: "/agent/runs/{id}/messages", scope: "organize.execute", summary: "追加用户消息(纠正/补充);运行已结束时唤醒续跑", params: IdParam, body: agent.AgentRunMessageInput, response: agent.AgentRun, }), // 回答 agent.propose_options(会话挂起等待中):选一个方案或都不要,会话随即继续 choose: ep({ method: "post", path: "/agent/runs/{id}/choice", scope: "organize.execute", summary: "回答方案选择:选一个方案或都不要(说明原因),挂起的会话随即继续", params: IdParam, body: agent.AgentRunChoiceInput, response: agent.AgentRun, }), abort: ep({ method: "post", path: "/agent/runs/{id}/abort", scope: "organize.execute", summary: "中止运行(停止未来步骤,不撤销已完成的外部动作)", params: IdParam, body: agent.AgentRunAbortInput, response: agent.AgentRun, }), }, artifacts: crud( "/artifacts", "产物", { row: agent.Artifact, filters: { agentRunId: Id.optional(), taskId: Id.optional(), kind: agent.ArtifactKind.optional() }, }, R, ), // ── 运维 ── reconcileRuns: crud( "/reconcile-runs", "校验运行", { row: ops.ReconcileRun, filters: { kind: ops.ReconcileKind.optional(), status: ops.ReconcileStatus.optional() }, }, R, ), serverMappings: crud( "/server-mappings", "库存映射", { row: ops.ServerMapping, filters: { mediaServerInstanceId: Id.optional(), mediaEntityId: Id.optional(), matchStatus: ops.ServerMatchStatus.optional(), }, }, R, ), apiKeys: { list: ep({ method: "get", path: "/api-keys", scope: "admin", summary: "API Key 列表", query: CursorQuery, response: cursorPage(ops.ApiKey), }), create: ep({ method: "post", path: "/api-keys", scope: "admin", summary: "新建 API Key(明文只返回一次)", body: ops.ApiKeyCreate, response: ops.ApiKeyCreated, }), update: ep({ method: "patch", path: "/api-keys/{id}", scope: "admin", summary: "修改/撤销 API Key", params: IdParam, body: ops.ApiKeyUpdate, response: ops.ApiKey, }), revoke: del("/api-keys/{id}", "撤销 API Key"), }, auditEvents: { list: ep({ method: "get", path: "/audit-events", scope: "admin", summary: "审计事件(按 actor/action/target 过滤,新→旧)", query: ops.AuditEventQuery, response: cursorPage(ops.AuditEvent), }), }, secrets: { list: ep({ method: "get", path: "/secrets", scope: "admin", summary: "凭证元数据列表", query: CursorQuery, response: cursorPage(ops.Secret), }), create: ep({ method: "post", path: "/secrets", scope: "admin", summary: "新建凭证(只写)", body: ops.SecretCreate, response: ops.Secret, }), update: ep({ method: "patch", path: "/secrets/{id}", scope: "admin", summary: "轮换凭证", params: IdParam, body: ops.SecretUpdate, response: ops.Secret, }), remove: del("/secrets/{id}", "删除凭证(被引用时 409)"), }, // ── 整理(公开合同) ── organization: { list: ep({ method: "get", path: "/organization-records", scope: "organize.read", summary: "整理记录列表", query: organization.OrganizationListQuery, response: cursorPage(organization.PublicOrganizationRecord), }), get: ep({ method: "get", path: "/organization-records/{id}", scope: "organize.read", summary: "整理记录详情(含逐文件结果)", params: IdParam, response: organization.PublicOrganizationRecord, }), changes: ep({ method: "get", path: "/organization-records/changes", scope: "organize.read", summary: "按 changeSeq 增量读取(含既有记录状态更新)", query: organization.OrganizationChangesQuery, response: organization.OrganizationChangesResponse, }), create: ep({ method: "post", path: "/organization-records", scope: "organize.execute", summary: "手动发起整理", body: organization.OrganizeRequest, response: organization.OrganizeAccepted, }), }, // ── 文件管理(docs/plan/06-file-manager-spec.md;只浏览已登记根,写操作两段式 dryRun → planId) ── files: { roots: ep({ method: "get", path: "/files/roots", scope: "organize.read", summary: "可浏览的根(库根 + 下载根),含实时健康与空间", response: files.FileRootList, }), list: ep({ method: "get", path: "/files", scope: "organize.read", summary: "列目录(分页/排序/过滤/截断,行内业务状态);根离线 503", query: files.FilesListQuery, response: files.FilesListResponse, }), stat: ep({ method: "get", path: "/files/stat", scope: "organize.read", summary: "单项详情:realpath/inode/nlink/sidecars/路径绑定", query: files.FileStatQuery, response: files.FileStat, }), mkdir: ep({ method: "post", path: "/files/mkdir", scope: "organize.execute", summary: "新建目录(非递归,已存在 409,仅 writable 根)", body: files.FilesMkdirRequest, response: files.FilesMkdirResult, }), rename: ep({ method: "post", path: "/files/rename", scope: "organize.execute", summary: "同目录改名(不覆盖;同步 asset_location 与 path_binding 修订;做种中 409)", body: files.FilesRenameRequest, response: files.FilesRenameResult, }), move: ep({ method: "post", path: "/files/move", scope: "organize.execute", summary: "移动/复制到目录:dryRun 返回 planId,执行回传 planId(源变更 409 plan_stale)", body: files.FilesMoveRequest, response: files.FilesTransferResult, }), copy: ep({ method: "post", path: "/files/copy", scope: "organize.execute", summary: "复制到目录(同 /files/move mode=copy)", body: files.FilesCopyRequest, response: files.FilesTransferResult, }), organize: ep({ method: "post", path: "/files/organize", scope: "organize.execute", summary: "按作品分组整理入库(每组走 planImport,与 /organization-records 同实现)", body: files.FilesOrganizeRequest, response: files.FilesOrganizeResult, }), delete: ep({ method: "post", path: "/files/delete", scope: "organize.execute", summary: "删除(默认进回收站;引用检查:做种/资产/绑定;permanent 仅 admin 会话)", body: files.FilesDeleteRequest, response: files.FilesDeleteResult, }), trash: ep({ method: "get", path: "/files/trash", scope: "organize.read", summary: "回收站列表", query: files.FilesTrashQuery, response: files.FilesTrashList, }), trashRestore: ep({ method: "post", path: "/files/trash/restore", scope: "organize.execute", summary: "从回收站还原(原位被占的项失败 conflict)", body: files.FilesTrashRestoreRequest, response: files.FilesTrashRestoreResult, }), trashPurge: ep({ method: "post", path: "/files/trash/purge", scope: "admin", auth: "session", summary: "清空回收站(缺省只清过期项;仅管理员控制台会话,API Key 与 DEV key 一律 403)", body: files.FilesTrashPurgeRequest, response: files.FilesTrashPurgeResult, }), identify: ep({ method: "post", path: "/files/identify", scope: "organize.read", summary: "识别(只读):解析 + 本地/TMDB 召回前 5 候选,不落库", body: files.FilesIdentifyRequest, response: files.FilesIdentifyResult, }), scrape: ep({ method: "post", path: "/files/scrape", scope: "organize.execute", summary: "刮削已登记作品(字段级刷新,遵守字段锁;NFO 投影 W5)", body: files.FilesScrapeRequest, response: files.FilesScrapeResult, }), probe: ep({ method: "post", path: "/files/probe", scope: "organize.execute", summary: "ffprobe 摘要(≤20 项;已登记资产写回 media_info,所以要 organize.execute)", body: files.FilesProbeRequest, response: files.FilesProbeResult, }), download: ep({ method: "get", path: "/files/download", scope: "admin", auth: "session", summary: "下载小文件(nfo/字幕/图片/文本/.torrent,≤20 MiB;流式;仅管理员控制台会话;审计)", query: files.FilesDownloadQuery, response: files.FilesDownloadResponse, }), preview: ep({ method: "get", path: "/files/preview", scope: "admin", auth: "session", summary: "结构化预览(文本/NFO 前 1 MiB 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;仅管理员控制台会话)", query: files.FilesPreviewQuery, response: files.FilePreview, }), records: ep({ method: "get", path: "/files/records", scope: "organize.read", summary: "按整理记录 id 或 planId 回查整理记录(整理页预填)", query: files.FilesRecordsQuery, response: files.FilesRecordsResult, }), // W9-H 文件管理 v2 后端缺口 entityFromCandidate: ep({ method: "post", path: "/files/entity-from-candidate", scope: "organize.execute", summary: "按 TMDB 候选就地建本地作品 + 外部映射(accepted/manual)+ 季集单元;幂等", body: files.FilesEntityFromCandidateRequest, response: files.FilesEntityFromCandidateResult, }), scrapeBatch: ep({ method: "post", path: "/files/scrape-batch", scope: "organize.execute", summary: "批量刷新元数据:按作品去重入队 refreshMetadata(锁定字段不变)", body: files.FilesScrapeBatchRequest, response: files.FilesScrapeBatchResult, }), tree: ep({ method: "get", path: "/files/tree", scope: "organize.read", summary: "目录摘要:上一级、子目录(是否有下级)、视频/已登记计数(树与上一级用)", query: files.FilesTreeQuery, response: files.FilesTreeResult, }), recordSources: ep({ method: "get", path: "/files/record-sources", scope: "organize.read", summary: "整理记录的源路径换算成登记目录 FileRef(重新整理用)", query: files.FilesRecordSourcesQuery, response: files.FilesRecordSourcesResult, }), suggestName: ep({ method: "post", path: "/files/suggest-name", scope: "organize.read", summary: "按命名模板(作品 > 库根 > 全局)生成推荐名;识别不可信时 name=null + 原因", body: files.FilesSuggestNameRequest, response: files.FilesSuggestNameResult, }), }, // ── 能力 ── capabilities: { list: ep({ method: "get", path: "/capabilities", scope: "read", summary: "能力注册中心描述", response: z.object({ items: z.array(CapabilityDescriptor) }), }), invoke: ep({ method: "post", path: "/capabilities/{capabilityId}/invoke", scope: "read", summary: "调用能力(实际所需 scope 见描述符;服务端按能力再次鉴权)", params: z.object({ capabilityId: KnownCapabilityId }), body: z.object({ input: z.unknown(), preview: z.boolean().default(false) }), response: z.object({ output: z.unknown(), taskId: Id.nullable(), planId: Id.nullable() }), }), stats: ep({ method: "get", path: "/capabilities/stats", scope: "read", summary: "能力调用统计:每能力调用数、失败数、平均耗时、最近调用、调用来源(内置智能体 / MCP / REST)", query: mcp.CapabilityStatsQuery, response: mcp.CapabilityStatsResponse, }), tools: ep({ method: "get", path: "/capabilities/tools", scope: "read", summary: "能力清单:描述符、工具名、输入输出 JSON Schema、当前授权状态", response: mcp.CapabilityToolsResponse, }), }, // ── 对外 MCP 服务端(Streamable HTTP 在 /mcp,不进 OpenAPI;这里是状态与配置)── mcp: { status: ep({ method: "get", path: "/mcp/status", scope: "admin", summary: "MCP 服务端状态:启用、允许的 API Key、当前会话、最近调用、限速", response: mcp.McpServerStatus, }), update: ep({ method: "patch", path: "/mcp/config", scope: "admin", summary: "修改 MCP 服务端配置(启用开关、允许的 API Key)", body: mcp.McpServerConfigUpdate, response: mcp.McpServerConfig, }), }, // ── 缓存(C2-A,docs/runbook/cache.md) ── cache: { tmdb: ep({ method: "get", path: "/cache/tmdb", scope: "admin", summary: "TMDB 缓存条目(请求响应与识别结果:键、过期时间、大小)与上限 / TTL", query: cache.TmdbCacheQuery, response: cache.TmdbCacheList, }), tmdbClear: ep({ method: "post", path: "/cache/tmdb/clear", scope: "admin", summary: "清空 TMDB 缓存(请求响应与识别结果)", response: cache.CacheClearResult, }), images: ep({ method: "get", path: "/cache/images", scope: "admin", summary: "图片代理缓存用量", response: cache.ImageCacheStatus, }), imagesClear: ep({ method: "post", path: "/cache/images/clear", scope: "admin", summary: "清空图片代理缓存", response: cache.CacheClearResult, }), }, // ── 网页搜索(WEB-1,docs/runbook/websearch.md) ── websearch: { test: ep({ method: "post", path: "/websearch/test", scope: "admin", summary: "用已保存的搜索服务 Key 实际查询一次(不走缓存,计入每分钟上限)", body: WebSearchTestInput, response: WebSearchTestResult, }), }, // ── 设置 ── settings: { get: ep({ method: "get", path: "/settings", scope: "read", summary: "全局设置", response: GlobalSettings }), update: ep({ method: "patch", path: "/settings", scope: "admin", summary: "修改全局设置", body: GlobalSettingsUpdate, response: GlobalSettings, }), }, // ── 整理命名模板(W9-C,docs/design/naming-template.md):保存前服务端编译校验 ── naming: { variables: ep({ method: "get", path: "/naming/variables", scope: "read", summary: "命名变量表(名称对齐 MP,含说明/示例值/我们特有标记)、过滤器、预览内置样本", response: settingsNs.NamingVariablesResponse, }), preview: ep({ method: "post", path: "/naming/preview", scope: "read", summary: "预览:模板 + 样本(文件名 / 资源 / 资产 / 内置)→ 相对路径、段角色、错误(行列号)、提示;已有路径绑定时目录用绑定", body: settingsNs.NamingPreviewInput, response: settingsNs.NamingPreviewResult, }), templates: ep({ method: "get", path: "/naming/templates", scope: "read", summary: "全局 / 库根 / 作品三级模板与按类型的生效结果(作品 > 库根 > 全局)", query: settingsNs.NamingTemplatesQuery, response: settingsNs.NamingTemplatesView, }), saveTemplates: ep({ method: "put", path: "/naming/templates", scope: "admin", summary: "保存全局命名模板(逐条编译校验,有错 422 不保存)", body: settingsNs.NamingSettingsPut, response: settingsNs.NamingSettings, }), libraryRoot: ep({ method: "put", path: "/library-roots/{id}/naming", scope: "admin", summary: "库根级命名覆盖(按类型;null 清除)", params: IdParam, body: settingsNs.LibraryRootNamingPut, response: settingsNs.LibraryRootNamingPut, }), entity: ep({ method: "put", path: "/media-entities/{id}/naming-override", scope: "admin", summary: "作品级命名覆盖(按类型;null 清除)。已有路径绑定的目录不受影响,只影响之后的文件名与新季目录", params: IdParam, body: settingsNs.EntityNamingOverridePut, response: settingsNs.EntityNamingOverridePut, }), }, // ── Pi 模型上游(W7-A):多上游共存 + 订阅登录;写操作与登录只接受控制台会话 ── pi: { providers: ep({ method: "get", path: "/pi/providers", scope: "admin", summary: "Pi provider 能力表(认证方式、订阅登录方式与条款提示、baseUrl 覆盖、env 字段、静态模型表)", query: pi.PiProvidersQuery, response: pi.PiProvidersResponse, }), list: ep({ method: "get", path: "/pi/upstreams", scope: "admin", summary: "模型服务列表(不含密钥)", response: pi.PiUpstreamList, }), get: ep({ method: "get", path: "/pi/upstreams/{id}", scope: "admin", summary: "模型服务详情", params: IdParam, response: pi.PiUpstream, }), create: ep({ method: "post", path: "/pi/upstreams", scope: "admin", auth: "session", summary: "新建模型服务(apiKey 只写,存 secret 表)", body: pi.PiUpstreamCreate, response: pi.PiUpstream, }), update: ep({ method: "patch", path: "/pi/upstreams/{id}", scope: "admin", auth: "session", summary: "修改模型服务(provider 不可改;makeDefault 设默认模型服务)", params: IdParam, body: pi.PiUpstreamUpdate, response: pi.PiUpstream, }), remove: ep({ method: "delete", path: "/pi/upstreams/{id}", scope: "admin", auth: "session", summary: "删除模型服务(连同凭据;是默认模型服务时清空默认)", params: IdParam, response: OkResult, }), test: ep({ method: "post", path: "/pi/upstreams/{id}/test", scope: "admin", auth: "session", summary: "测试模型服务:默认只解析凭据与校验模型(不产生费用);mode=completion 发一次最小补全", params: IdParam, body: pi.PiUpstreamTestRequest, response: pi.PiUpstreamTestResult, }), models: ep({ method: "get", path: "/pi/upstreams/{id}/models", scope: "admin", summary: "模型列表:已获取过远程列表时返回远程与静态目录的合并(远程优先),否则静态目录", params: IdParam, response: pi.PiUpstreamModels, }), modelsRefresh: ep({ method: "post", path: "/pi/upstreams/{id}/models/refresh", scope: "admin", auth: "session", summary: "获取模型列表:用该模型服务的凭证调 provider 的模型列表接口并存库;失败或不支持时回静态目录并给出原因", params: IdParam, response: pi.PiUpstreamModels, }), modelsProbe: ep({ method: "post", path: "/pi/models/probe", scope: "admin", auth: "session", summary: "新建模型服务前试拉模型列表(凭证只用于这一次请求,不落库)", body: pi.PiModelsProbeRequest, response: pi.PiUpstreamModels, }), oauthStart: ep({ method: "post", path: "/pi/upstreams/{id}/oauth/start", scope: "admin", auth: "session", summary: "开始订阅登录:返回授权链接(browser)或验证码(device)", params: IdParam, body: pi.PiOAuthStartRequest, response: pi.PiOAuthStartResult, }), oauthComplete: ep({ method: "post", path: "/pi/upstreams/{id}/oauth/complete", scope: "admin", auth: "session", summary: "完成订阅登录:browser 粘回地址栏 URL;device 服务端轮询(本次最多等 waitMs,未完成返回 pending)", params: IdParam, body: pi.PiOAuthCompleteRequest, response: pi.PiOAuthCompleteResult, }), oauthLogout: ep({ method: "post", path: "/pi/upstreams/{id}/oauth/logout", scope: "admin", auth: "session", summary: "退出订阅登录:删除本地 token(不会撤销提供方侧授权)", params: IdParam, response: pi.PiUpstream, }), }, // ── 登录门禁 ── /** /auth/setup、/auth/login、/auth/session 免鉴权;改密与 TOTP 只接受控制台 cookie 会话 */ auth: { setup: ep({ method: "post", path: "/auth/setup", scope: "read", auth: "public", summary: "首次初始化管理员", body: auth.AdminSetupRequest, response: auth.SessionInfo, }), login: ep({ method: "post", path: "/auth/login", scope: "read", auth: "public", summary: "管理员登录", body: auth.LoginRequest, response: auth.LoginResult, }), logout: ep({ method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult }), session: ep({ method: "get", path: "/auth/session", scope: "read", auth: "public", summary: "当前登录态(免鉴权)", response: auth.SessionInfo, }), password: ep({ method: "post", path: "/auth/password", scope: "admin", auth: "session", summary: "修改密码(吊销其它会话)", body: auth.PasswordChangeRequest, response: auth.PasswordChangeResult, }), totpStatus: ep({ method: "get", path: "/auth/totp", scope: "admin", auth: "session", summary: "TOTP 状态", response: auth.TotpStatus, }), totp: ep({ method: "post", path: "/auth/totp", scope: "admin", auth: "session", summary: "TOTP 绑定/启用/停用/重新生成恢复码", body: auth.TotpAction, response: auth.TotpActionResult, }), totpSetup: ep({ method: "get", path: "/auth/totp/setup", scope: "admin", auth: "session", summary: "待确认的 TOTP 密钥与 otpauth URL(前端渲染二维码)", response: auth.TotpSetup, }), passkeys: ep({ method: "get", path: "/auth/passkeys", scope: "admin", auth: "session", summary: "已登记的 Passkey", response: auth.PasskeyList, }), passkeyRegisterOptions: ep({ method: "post", path: "/auth/passkeys/register/options", scope: "admin", auth: "session", summary: "Passkey 注册挑战", response: auth.PasskeyChallenge, }), passkeyRegister: ep({ method: "post", path: "/auth/passkeys/register/verify", scope: "admin", auth: "session", summary: "校验并登记 Passkey", body: auth.PasskeyRegisterRequest, response: auth.PasskeyInfo, }), passkeyRename: ep({ method: "patch", path: "/auth/passkeys/{id}", scope: "admin", auth: "session", summary: "重命名 Passkey", params: IdParam, body: auth.PasskeyRenameRequest, response: auth.PasskeyInfo, }), passkeyDelete: ep({ method: "delete", path: "/auth/passkeys/{id}", scope: "admin", auth: "session", summary: "删除 Passkey", params: IdParam, response: OkResult, }), passkeyLoginOptions: ep({ method: "post", path: "/auth/passkeys/login/options", scope: "read", auth: "public", summary: "Passkey 登录挑战(免鉴权)", response: auth.PasskeyChallenge, }), passkeyLogin: ep({ method: "post", path: "/auth/passkeys/login/verify", scope: "read", auth: "public", summary: "Passkey 登录(免鉴权;成功即下发会话)", body: auth.PasskeyLoginRequest, response: auth.LoginResult, }), }, // ── 事件 ── events: { list: ep({ method: "get", path: "/events", scope: "read", summary: "事件流:控制台用 SSE(text/event-stream,cookie 会话);API Key 调用者带 limit 取 JSON 补读页(after=上次 nextAfter)", query: events.EventReplayQuery, response: events.EventReplayPage, }), }, // ── 系统 ── system: { health: ep({ method: "get", path: "/health", scope: "read", summary: "服务健康状态(免鉴权)", response: HealthStatus, }), healthDetail: ep({ method: "get", path: "/system/health", scope: "admin", summary: "进程与健康:进程心跳 / 事件循环延迟 / SSE 连接 / DB / 磁盘 / 挂载根 / 引擎 / 定时任务", response: ops.SystemHealth, }), healthHistory: ep({ method: "get", path: "/system/health/history", scope: "admin", summary: "心跳采样时间序列(事件循环延迟、运行中 run、SSE 连接;按桶聚合)", query: ops.SystemHealthHistoryQuery, response: ops.SystemHealthHistory, }), storage: ep({ method: "get", path: "/system/storage", scope: "admin", summary: "存储:表体积前 15、库总体积、各缓存用量与预算、备份占用、DATA_DIR 分目录用量、清理任务状态", response: ops.SystemStorage, }), storageCleanup: ep({ method: "post", path: "/system/storage/cleanup/{target}", scope: "admin", summary: "立即执行某个存储清理任务(排入对应定时任务,已在排队或运行中则合并)", params: ops.StorageCleanupParam, response: ops.StorageCleanupResult, }), }, // ── 引擎面板(仅 admin) ── engine: { runs: ep({ method: "get", path: "/engine/runs", scope: "admin", summary: "引擎 runs(状态/名称/时间筛选,新→旧)", query: ops.EngineRunQuery, response: cursorPage(ops.EngineRun), }), run: ep({ method: "get", path: "/engine/runs/{id}", scope: "admin", summary: "run 详情(step attempts + signals)", params: EngineRunIdParam, response: ops.EngineRunDetail, }), stats: ep({ method: "get", path: "/engine/stats", scope: "admin", summary: "按状态计数 / 最老待处理 / 活跃 worker 快照", response: ops.EngineStats, }), cancel: ep({ method: "post", path: "/engine/runs/{id}/cancel", scope: "admin", summary: "取消 run", params: EngineRunIdParam, body: ops.EngineCancelRequest, response: ops.EngineCancelResult, }), retry: ep({ method: "post", path: "/engine/runs/{id}/retry", scope: "admin", summary: "按原任务名与输入重新入队(新幂等键 retry::<序号>)", params: EngineRunIdParam, response: z.object({ runId: z.string(), retryOf: z.string(), attempt: z.number().int().positive() }), }), }, // ── 待识别池 ── identityPool: { list: ep({ method: "get", path: "/identity-pool", scope: "read", summary: "未识别资源(可按原始标题检索;候选不建正式作品)", query: identity.IdentityPoolQuery, response: cursorPage(identity.IdentityPoolItem), }), assign: ep({ method: "post", path: "/identity-pool/{id}/assign", scope: "organize.execute", summary: "为未识别资源手动指定候选 / 本地作品 / 外部身份", params: IdParam, body: identity.ReleaseIdentityAssign, response: ReleaseIdentityResult, }), dismiss: ep({ method: "post", path: "/identity-pool/{id}/dismiss", scope: "organize.execute", summary: "忽略未识别资源(不再自动复查)", params: IdParam, body: z.object({ note: z.string().max(2000).optional() }), response: identity.IdentityCandidate, }), retry: ep({ method: "post", path: "/identity-pool/{id}/retry", scope: "organize.execute", summary: "立即重新识别", params: IdParam, response: TaskAccepted, }), // META-1 元数据补全:先看建议(metadata.enrich,不改作品),再在控制台应用 enrich: ep({ method: "post", path: "/identity-pool/{id}/enrich", scope: "organize.execute", summary: "元数据补全建议(网页搜索 + 白名单网页 + 模型抽取;不改作品;同一标题 7 天内返回上次结果)", params: IdParam, body: identity.EnrichRequest, response: MetadataEnrichOutput, }), enrichApply: ep({ method: "post", path: "/identity-pool/{id}/enrich/apply", scope: "admin", auth: "session", summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联未识别资源(只接受控制台管理员会话)", params: IdParam, body: identity.EnrichApplyInput, response: identity.EnrichApplyResult, }), // META-2 补全命中率(按来源类别) enrichStats: ep({ method: "get", path: "/identity/enrich-stats", scope: "read", summary: "元数据补全命中率(最近 N 天,按来源类别:百科、平台、豆瓣摘要、其它网站摘要)", query: identity.EnrichStatsQuery, response: identity.EnrichStats, }), }, // ── 媒体库 / 库存 / 首页 ── library: { list: ep({ method: "get", path: "/library", scope: "read", summary: "媒体库列表(已登记资产或正在追踪的作品)", query: organization.LibraryListQuery, response: cursorPage(organization.LibraryItem), }), get: ep({ method: "get", path: "/library/{id}", scope: "read", summary: "作品详情:实际路径、身份映射、元数据来源、各收录版本、候选拒绝理由、下载历史与校验结果", params: IdParam, response: organization.LibraryDetail, }), versions: ep({ method: "get", path: "/library/{id}/versions", scope: "read", summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对", params: IdParam, query: libraryVersions.LibraryVersionsQuery, response: libraryVersions.LibraryVersions, }), reconcile: ep({ method: "post", path: "/library/reconcile", scope: "admin", summary: "立即做一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载)", body: z.object({ libraryRootId: z.uuid().optional() }), response: z.object({ runId: z.uuid(), stats: z.record(z.string(), z.unknown()) }), }), }, inventory: { overlay: ep({ method: "get", path: "/inventory", scope: "read", summary: "库存叠加(站点工作台):按作品返回已有版本 / 收录版本缺口 / 下载中", query: organization.InventoryQuery, response: organization.InventoryResponse, }), mediaServers: ep({ method: "get", path: "/inventory/media-servers", scope: "read", summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)", query: organization.MediaServerInventoryQuery, response: organization.MediaServerInventory, }), }, dashboard: { get: ep({ method: "get", path: "/dashboard", scope: "read", summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出", response: organization.Dashboard, }), }, downloadCache: { cleanup: ep({ method: "post", path: "/download-cache/cleanup", scope: "admin", summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)", body: z.object({ downloaderInstanceId: z.uuid().optional() }), response: z.object({ report: z.record(z.string(), z.unknown()) }), }), }, // ── 全局搜索(W9-G):顶栏搜索 / 命令面板 / 资源结果页 ── search: { all: ep({ method: "get", path: "/search", scope: "read", summary: "全局搜索聚合:本地作品 / TMDB 候选 / 跨站资源(15s 超时、并发合并、30s 缓存、库存叠加)/ 页面与操作;每组带 tookMs", query: search.SearchQuery, response: search.SearchResponse, }), releases: ep({ method: "get", path: "/search/releases", scope: "read", summary: "跨站资源结果页:站点/分辨率/来源/大小/免费/H&R 筛选、排序、游标;与 /search 共用缓存", query: search.ReleaseSearchQuery, response: search.ReleaseSearchPage, }), plan: ep({ method: "post", path: "/search/releases/{siteId}/{siteReleaseId}/plan", scope: "organize.execute", summary: "从搜索结果直接下载:定位资源后走 POST /acquisition-plans 同一流程", params: search.SearchReleaseParams, body: search.SearchReleasePlanRequest, response: execution.ManualAcquisitionResult, }), tmdbDetail: ep({ method: "get", path: "/media/tmdb/{type}/{id}", scope: "read", summary: "仅 TMDB 作品的详情:TMDB 信息与季列表、本地库存对比、是否已建档、搜资源的查询词;include=mediaServers 同时查 Emby", params: search.TmdbMediaParams, query: search.TmdbMediaQuery, response: search.TmdbMediaDetail, }), tmdbSeason: ep({ method: "get", path: "/media/tmdb/tv/{id}/season/{season}", scope: "read", summary: "仅 TMDB 剧集的季详情与每集信息(标题 / 简介按语言回退链;走 TMDB 请求缓存)", params: search.TmdbSeasonParams, response: search.TmdbSeasonDetailView, }), }, // ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ── browser: { providers: ep({ method: "get", path: "/browser/providers", scope: "admin", summary: "浏览器服务列表(地址、凭证引用、启用、能力 supported/verified);probe=true 现场探测并写回", query: browser.BrowserProvidersQuery, response: z.object({ items: z.array(browser.BrowserProviderView) }), }), createProvider: ep({ method: "post", path: "/browser/providers", scope: "admin", summary: "登记浏览器服务(camofox 地址、访问凭据存凭证库、启用)", body: browser.BrowserProviderCreate, response: browser.BrowserProviderView, }), updateProvider: ep({ method: "patch", path: "/browser/providers/{id}", scope: "admin", summary: "修改浏览器服务地址、访问凭据或启用(环境变量登记的只能改启用)", params: browser.BrowserProviderParams, body: browser.BrowserProviderUpdate, response: browser.BrowserProviderView, }), deleteProvider: ep({ method: "delete", path: "/browser/providers/{id}", scope: "admin", summary: "删除浏览器服务(有未关闭会话或由环境变量登记时拒绝)", params: browser.BrowserProviderParams, response: z.object({ id: z.string() }), }), probeProvider: ep({ method: "post", path: "/browser/providers/{id}/probe", scope: "admin", summary: "测试连通:探测该浏览器服务的 /health 与 /openapi.json 并写回能力", params: browser.BrowserProviderParams, response: browser.BrowserProviderView, }), sessions: ep({ method: "get", path: "/browser/sessions", scope: "admin", summary: "浏览器会话列表(以数据库为准;默认只列未关闭的)", query: browser.BrowserSessionsQuery, response: z.object({ items: z.array(browser.BrowserSessionView) }), }), open: ep({ method: "post", path: "/browser/sessions", scope: "admin", summary: "打开站点浏览器会话(同一站点/账号已有未关闭会话时直接返回它)", body: browser.BrowserSessionOpen, response: browser.BrowserSessionView, }), get: ep({ method: "get", path: "/browser/sessions/{id}", scope: "admin", summary: "浏览器会话详情(控制者、标签页、租约、是否需重新读取页面)", params: browser.BrowserSessionParams, response: browser.BrowserSessionView, }), close: ep({ method: "delete", path: "/browser/sessions/{id}", scope: "admin", summary: "关闭浏览器会话(关闭上游标签页,撤销租约)", params: browser.BrowserSessionParams, response: browser.BrowserSessionView, }), openTab: ep({ method: "post", path: "/browser/sessions/{id}/tabs", scope: "admin", summary: "新建标签页并设为当前(需持有控制权)", params: browser.BrowserSessionParams, body: browser.BrowserTabOpen, response: browser.BrowserSessionView, }), closeTab: ep({ method: "delete", path: "/browser/sessions/{id}/tabs/{tabId}", scope: "admin", summary: "关闭标签页(需持有控制权)", params: browser.BrowserTabParams, response: browser.BrowserSessionView, }), navigate: ep({ method: "post", path: "/browser/sessions/{id}/navigate", scope: "admin", summary: "地址栏导航或后退/前进/刷新(需持有控制权;限站点域名)", params: browser.BrowserSessionParams, body: browser.BrowserNavigate, response: browser.BrowserNavigation, }), snapshot: ep({ method: "get", path: "/browser/sessions/{id}/snapshot", scope: "admin", summary: "结构化页面快照(凭证已遮蔽)", params: browser.BrowserSessionParams, query: browser.BrowserSnapshotQuery, response: browser.BrowserSnapshot, }), screenshot: ep({ method: "get", path: "/browser/sessions/{id}/screenshot", scope: "admin", summary: "当前画面截图(base64,只给原站视图,不进模型)", params: browser.BrowserSessionParams, query: browser.BrowserScreenshotQuery, response: browser.BrowserScreenshotView, }), click: ep({ method: "post", path: "/browser/sessions/{id}/click", scope: "admin", summary: "点击(快照引用 / 选择器 / 截图坐标;需持有控制权)", params: browser.BrowserSessionParams, body: browser.BrowserClick, response: browser.BrowserActionResult, }), type: ep({ method: "post", path: "/browser/sessions/{id}/type", scope: "admin", summary: "输入文本 / 站点账号凭证 / 按键(需持有控制权;凭证由服务端注入)", params: browser.BrowserSessionParams, body: browser.BrowserType, response: browser.BrowserActionResult, }), scroll: ep({ method: "post", path: "/browser/sessions/{id}/scroll", scope: "admin", summary: "滚动页面(需持有控制权)", params: browser.BrowserSessionParams, body: browser.BrowserScroll, response: browser.BrowserActionResult, }), select: ep({ method: "post", path: "/browser/sessions/{id}/select", scope: "admin", summary: "下拉框选择(需持有控制权)", params: browser.BrowserSessionParams, body: browser.BrowserSelect, response: browser.BrowserActionResult, }), extract: ep({ method: "post", path: "/browser/sessions/{id}/extract", scope: "admin", summary: "按 JSON Schema(x-ref)提取页面字段", params: browser.BrowserSessionParams, body: browser.BrowserExtract, response: browser.BrowserExtractResult, }), download: ep({ method: "post", path: "/browser/sessions/{id}/download", scope: "organize.execute", summary: "捕获浏览器下载 → 后端暂存并核验 infohash、匹配本地资源;给出 submit 时经 POST /acquisition-plans 同一流程提交", params: browser.BrowserSessionParams, body: browser.BrowserDownload, response: browser.BrowserDownloadResult, }), reconnect: ep({ method: "post", path: "/browser/sessions/{id}/reconnect", scope: "admin", summary: "浏览器服务重启后重新连接(幂等):上游上下文还在就什么都不做;否则注入账号 Cookie、新开标签页回到原地址,控制权不变;失败 503 browser_reconnect_failed", params: browser.BrowserSessionParams, response: browser.BrowserSessionView, }), acquire: ep({ method: "post", path: "/browser/sessions/{id}/control/acquire", scope: "admin", summary: "人工接管:先停止该会话新的自动化输入并等进行中的操作收尾,再发放人工租约", params: browser.BrowserSessionParams, body: browser.BrowserControlAcquire, response: browser.BrowserControlResult, }), release: ep({ method: "post", path: "/browser/sessions/{id}/control/release", scope: "admin", summary: "交还控制权:智能体恢复前必须重新读取页面,旧元素引用失效", params: browser.BrowserSessionParams, body: browser.BrowserControlRelease, response: browser.BrowserControlResult, }), }, // ── 定时任务看板(W7-C):统一注册表 + Agent 巡检;全部只接受控制台会话 ── schedules: { list: ep({ method: "get", path: "/schedules", scope: "admin", auth: "session", summary: "定时任务看板:全部登记的周期任务与智能体巡检、统计、调度器是否在线", response: schedules.ScheduleBoard, }), create: ep({ method: "post", path: "/schedules", scope: "admin", auth: "session", summary: "新建自定义智能体巡检(目标模板 + 能力白名单 + 预算 + 间隔)", body: schedules.AgentCheckCreate, response: schedules.ScheduledJob, }), update: ep({ method: "patch", path: "/schedules/{id}", scope: "admin", auth: "session", summary: "修改开关 / 间隔或 cron / 参数;reset 恢复默认开关与调度", params: schedules.ScheduleIdParam, body: schedules.ScheduleUpdate, response: schedules.ScheduledJob, }), remove: ep({ method: "delete", path: "/schedules/{id}", scope: "admin", auth: "session", summary: "删除自定义智能体巡检(内置任务不可删)", params: schedules.ScheduleIdParam, response: OkResult, }), runNow: ep({ method: "post", path: "/schedules/{id}/run-now", scope: "admin", auth: "session", summary: "立即执行一次(不改下次运行时间;已在排队或运行中则合并)", params: schedules.ScheduleIdParam, response: schedules.ScheduleRunNowResult, }), skip: ep({ method: "post", path: "/schedules/{id}/skip", scope: "admin", auth: "session", summary: "跳过下一次运行(下次运行时间顺延一个周期)", params: schedules.ScheduleIdParam, response: schedules.ScheduledJob, }), runs: ep({ method: "get", path: "/schedules/{id}/runs", scope: "admin", auth: "session", summary: "最近运行历史(新→旧)", params: schedules.ScheduleIdParam, query: schedules.ScheduleRunsQuery, response: z.object({ items: z.array(schedules.ScheduledJobRun) }), }), }, } as const; /** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */ export function buildPath(path: string, params: Record = {}): string { const filled = path.replace(/\{(\w+)\}/g, (_, k: string) => { const v = params[k]; if (v === undefined) throw new Error(`缺少路径参数 ${k}`); return encodeURIComponent(v); }); return `${API_PREFIX}${filled}`; } /** 扁平列出全部端点(生成 OpenAPI、权限表与测试用) */ export function listEndpoints(): EndpointDef[] { return Object.values(Api).flatMap((group) => Object.values(group) as EndpointDef[]); } export type EndpointBody = E["body"] extends z.ZodType ? z.input : never; export type EndpointResponse = z.infer; export * from "./files.ts"; export * from "./mcp.ts"; export * from "./schedules.ts";