feat(server): W2-B sources, identity resolution, metadata and site workbench
- Sites / accounts / feeds: registry view (/site-definitions), site CRUD with
enable/disable (history stays queryable, marked site_disabled), accounts and
feed URLs stored as secrets (only secret_ref / redacted urlDisplay exposed),
feed enable/disable/reset/fetch; one shared rate limiter + adapter per site.
- Tasks: ingestFeed (FeedFetchState/FeedCursor on feed_source, observation
feed+guid upsert with digest-change updates, source_release site+id
aggregation across feeds, auth failure -> account invalid + decision, no
retry, gap -> feed.gap_suspected -> budgeted backfill snapshot),
resolveObservation (parseRelease -> local recall via title_alias pg_trgm +
accepted identities -> TMDB -> resolveIdentity; auto binds entity /
provisional units / identities / aliases, otherwise identity_candidate pool
with evidence version and bounded review backoff; manual lock respected),
refreshMetadata (metadata_field + field_override, user locks win, paths
untouched), snapshotSite (site_snapshot coverage records).
- Events: feed.due, feed.gap_suspected, release.observed (self-consumed),
release.resolved, decision.created/updated; scheduler in worker start().
- Identity API: entity detail with redirects, manual mapping correction with
identity_revision, merge with identity_redirect, pool list/assign/dismiss/
retry, content unit numbering lock, aliases, external identities.
- Decisions: list/summary (by entity and kind)/detail/resolve; identity
options executed directly.
- Workbench: /sites/{id}/releases (snapshot first, coalesced live search with
backoff, origin + freshness) and /sites/{id}/releases/{rid} (volatile field
recheck times, refresh keeps data on failure).
- Capabilities: catalog.lookup, release.search, release.inspect,
identity.resolve.
- Migrations 0018-0020: site.adapter api, source_release.resolution_locked,
site_snapshot, identity_candidate, resolution_attempt, identity_redirect,
metadata_field, field_override.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
829ca4f7ac
commit
a24bda952b
39 files changed
+6861
-15
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { DateOnly, EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common.ts";
|
||||
import { ActorType, DateOnly, EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common.ts";
|
||||
import { ExternalIdentity } from "./identity.ts";
|
||||
|
||||
// 内容域:本地作品、内容单元、别名(REQ §3.1、§5.1、§5.4、§7.5)
|
||||
// 本地 UUID 是主身份;季/集编号、外部 ID 都可修订,不是唯一身份。
|
||||
@@ -127,3 +128,82 @@ export const TitleAliasCreate = TitleAlias.pick({
|
||||
export type TitleAliasCreate = z.infer<typeof TitleAliasCreate>;
|
||||
export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial();
|
||||
export type TitleAliasUpdate = z.infer<typeof TitleAliasUpdate>;
|
||||
|
||||
// ── W2-B:字段级元数据(REQ §7.3)与作品详情 ──
|
||||
|
||||
export const MetadataSource = z.enum(["tmdb", "user", "agent", "nfo", "site", "import"]);
|
||||
export type MetadataSource = z.infer<typeof MetadataSource>;
|
||||
|
||||
/** 某来源给出的一个候选字段值(刷新只写这里,不直接改最终展示值) */
|
||||
export const MetadataField = EntityBase.extend({
|
||||
mediaEntityId: Id.nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
field: z.string().min(1),
|
||||
value: z.unknown(),
|
||||
language: z.string().nullable(),
|
||||
source: MetadataSource,
|
||||
sourceRevision: z.string().nullable(),
|
||||
fetchedAt: Timestamp,
|
||||
});
|
||||
export type MetadataField = z.infer<typeof MetadataField>;
|
||||
|
||||
/** 字段覆盖:用户锁定优先于任何自动刷新 */
|
||||
export const FieldOverride = EntityBase.extend({
|
||||
mediaEntityId: Id.nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
field: z.string().min(1),
|
||||
value: z.unknown(),
|
||||
locked: z.boolean(),
|
||||
source: MetadataSource,
|
||||
reason: z.string().nullable(),
|
||||
actorType: ActorType,
|
||||
actorId: z.string().nullable(),
|
||||
});
|
||||
export type FieldOverride = z.infer<typeof FieldOverride>;
|
||||
|
||||
/** 可覆盖/锁定的作品字段;title/originalTitle/year 同时投影到 media_entity 列 */
|
||||
export const EntityMetadataField = z.enum([
|
||||
"title",
|
||||
"originalTitle",
|
||||
"year",
|
||||
"overview",
|
||||
"releaseDate",
|
||||
"originalLanguage",
|
||||
"originCountry",
|
||||
"posterPath",
|
||||
]);
|
||||
export type EntityMetadataField = z.infer<typeof EntityMetadataField>;
|
||||
|
||||
export const FieldOverrideInput = z.object({
|
||||
value: z.unknown(),
|
||||
lock: z.boolean().default(true),
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
export type FieldOverrideInput = z.infer<typeof FieldOverrideInput>;
|
||||
|
||||
/** media_entity.metadata 中每个最终字段的形状 */
|
||||
export const FinalFieldValue = z.object({
|
||||
value: z.unknown(),
|
||||
source: MetadataSource,
|
||||
language: z.string().nullable(),
|
||||
at: Timestamp,
|
||||
locked: z.boolean(),
|
||||
});
|
||||
export type FinalFieldValue = z.infer<typeof FinalFieldValue>;
|
||||
|
||||
/** 作品详情:外部身份、别名、内容单元、字段来源与覆盖 */
|
||||
export const MediaEntityDetail = z.object({
|
||||
entity: MediaEntity,
|
||||
/** 请求的是已合并的旧 ID 时,指向跳转后的实体 */
|
||||
redirectedFrom: Id.nullable(),
|
||||
identities: z.array(ExternalIdentity),
|
||||
aliases: z.array(TitleAlias),
|
||||
contentUnits: z.array(ContentUnit),
|
||||
metadataFields: z.array(MetadataField),
|
||||
overrides: z.array(FieldOverride),
|
||||
});
|
||||
export type MediaEntityDetail = z.infer<typeof MediaEntityDetail>;
|
||||
|
||||
/** 合并重复作品(保留旧 ID 跳转) */
|
||||
export const MediaEntityMerge = z.object({ intoId: Id, reason: z.string().max(2000).optional() });
|
||||
export type MediaEntityMerge = z.infer<typeof MediaEntityMerge>;
|
||||
Reference in new issue
Block a user