Merge branch 'md1'

# Conflicts:
#	packages/contracts/src/settings.ts
This commit is contained in:
root committed 2026-09-29 17:42:31 +08:00
commit b633eb5379
30 files changed
+3240 -222

No files matched your search

+46 -11
View File
@@ -149,6 +149,12 @@ const FieldOverrideResult = z.object({
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) });
@@ -219,8 +225,10 @@ export const Api = {
method: "post",
path: "/media-entities/{id}/refresh",
scope: "organize.execute",
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
summary:
"刷新元数据(字段级;用户锁优先,不动路径)。标题 / 简介按设置 metadata.languages 回退;full=1 拉取全部季的每集信息",
params: IdParam,
query: z.object({ full: z.enum(["0", "1", "true", "false"]).optional() }),
response: TaskAccepted,
}),
setField: ep({
@@ -241,16 +249,35 @@ export const Api = {
response: FieldOverrideResult,
}),
},
contentUnits: crud(
"/content-units",
"内容单元",
{
row: content.ContentUnit,
update: content.ContentUnitUpdate,
filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() },
},
R,
),
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",
@@ -2239,6 +2266,14 @@ export const Api = {
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):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ──
+47
View File
@@ -198,9 +198,56 @@ export const EntityMetadataField = z.enum([
"episodeCount",
"genres",
"posterUrl",
// MD-1 TMDB 详情(对照 MP 媒体详情):中文名、标语、最近播出、状态、评分、时长、季数、主创、外部 ID
"titleZh",
"tagline",
"lastAirDate",
"airStatus",
"voteAverage",
"voteCount",
"runtime",
"seasonCount",
"cast",
"crew",
"externalIds",
]);
export type EntityMetadataField = z.infer<typeof EntityMetadataField>;
/**
* 内容单元(季 / 集)可覆盖 / 锁定的字段(MD-1):来源值写 metadata_field(content_unit_id),
* 最终值投影到 content_unit.metadata;name → title 列、airDate → air_date 列。编号不属于元数据。
*/
export const UnitMetadataField = z.enum([
"name",
"overview",
"airDate",
"runtime",
"stillPath",
"posterPath",
"voteAverage",
"voteCount",
]);
export type UnitMetadataField = z.infer<typeof UnitMetadataField>;
/** 主创(cast / crew 字段的元素):TMDB 人物 ID、姓名(按回退链语言)、角色或职务、头像路径(经图片代理显示) */
export const MediaCredit = z.object({
id: z.number().int(),
name: z.string(),
originalName: z.string().nullable(),
/** 演员:角色名;幕后:职务(Director / Creator …) */
role: z.string().nullable(),
profilePath: z.string().nullable(),
});
export type MediaCredit = z.infer<typeof MediaCredit>;
/** externalIds 字段的值 */
export const MediaExternalIds = z.object({
imdb: z.string().nullable(),
tvdb: z.string().nullable(),
wikidata: z.string().nullable(),
});
export type MediaExternalIds = z.infer<typeof MediaExternalIds>;
export const FieldOverrideInput = z.object({
value: z.unknown(),
lock: z.boolean().default(true),
+53 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { Id, NonNegInt, Timestamp } from "./common.ts";
import { MediaEntityStatus, MediaKind } from "./content.ts";
import { MediaCredit, MediaEntityStatus, MediaKind } from "./content.ts";
import { ManualAcquisitionRequest } from "./execution.ts";
import { InventoryEntry, MediaServerInventory, SlotStatusCounts } from "./organization.ts";
@@ -338,6 +338,8 @@ export const TmdbMediaSeason = z.object({
airDate: z.string().nullable(),
/** 本地已入库的集数(整季资产按全季计);未收录为 null */
localHave: NonNegInt.nullable(),
overview: z.string().nullable().optional(),
posterPath: z.string().nullable().optional(),
});
export type TmdbMediaSeason = z.infer<typeof TmdbMediaSeason>;
@@ -367,5 +369,55 @@ export const TmdbMediaDetail = z.object({
mediaServers: MediaServerInventory.nullable(),
/** 搜资源时服务端会用的查询词 */
queries: z.array(z.string()),
// MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示)
/** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */
titleZh: z.string().nullable().optional(),
tagline: z.string().nullable().optional(),
genres: z.array(z.string()).optional(),
voteAverage: z.number().nullable().optional(),
voteCount: z.number().int().nullable().optional(),
/** TMDB status 原文(Returning Series / Ended / Released …) */
airStatus: z.string().nullable().optional(),
/** 分钟:电影片长;剧集为单集时长 */
runtime: z.number().int().nullable().optional(),
episodeCount: z.number().int().nullable().optional(),
lastAirDate: z.string().nullable().optional(),
platform: z.string().nullable().optional(),
cast: z.array(MediaCredit).optional(),
crew: z.array(MediaCredit).optional(),
});
export type TmdbMediaDetail = z.infer<typeof TmdbMediaDetail>;
/** 一集的 TMDB 信息(标题 / 简介按语言回退链,language 记实际取到的语言) */
export const MediaEpisodeInfo = z.object({
seasonNumber: z.number().int(),
episodeNumber: z.number().int(),
name: z.string().nullable(),
nameLanguage: z.string().nullable(),
overview: z.string().nullable(),
overviewLanguage: z.string().nullable(),
airDate: z.string().nullable(),
runtime: z.number().int().nullable(),
stillPath: z.string().nullable(),
voteAverage: z.number().nullable(),
voteCount: z.number().int().nullable(),
});
export type MediaEpisodeInfo = z.infer<typeof MediaEpisodeInfo>;
export const TmdbSeasonParams = z.object({
id: z.string().regex(/^\d{1,12}$/),
season: z.coerce.number().int().min(0).max(999),
});
export type TmdbSeasonParams = z.infer<typeof TmdbSeasonParams>;
/** GET /media/tmdb/tv/{id}/season/{season}:季详情与集列表(仅 TMDB 作品详情页按季展开时拉取) */
export const TmdbSeasonDetailView = z.object({
tvId: z.string(),
seasonNumber: z.number().int(),
name: z.string().nullable(),
overview: z.string().nullable(),
airDate: z.string().nullable(),
posterPath: z.string().nullable(),
episodes: z.array(MediaEpisodeInfo),
});
export type TmdbSeasonDetailView = z.infer<typeof TmdbSeasonDetailView>;
+11 -1
View File
@@ -217,6 +217,15 @@ export const MetadataSettings = z.object({
tmdbApiKeySecretRef: Id.nullable(),
/** 自建反代或测试 mock;null 用官方地址 */
tmdbBaseUrl: z.url().nullable(),
/**
* 元数据语言回退链(MD-1):作品 / 季 / 集的标题与简介按顺序取第一个非空值;中文名(titleZh)取链中第一个中文值。
* BCP47 语言-地区,如 zh-CN;缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US
*/
languages: z
.array(z.string().regex(/^[a-z]{2,3}(-[A-Z]{2})?$/))
.min(1)
.max(8)
.default(["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]),
});
export type MetadataSettings = z.infer<typeof MetadataSettings>;
@@ -602,7 +611,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
sessionDir: null,
webSearch: DEFAULT_WEB_SEARCH,
},
metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null },
metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null, languages: ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"] },
sites: {
defaultRateLimit: { requestsPerMinute: 20, concurrency: 2 },
defaultSnapshotIntervalSec: 3600,
@@ -896,6 +905,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
fields: [
"metadata.tmdbApiKeySecretRef",
"metadata.tmdbBaseUrl",
"metadata.languages",
"cache.tmdbMaxSize",
"identity.eagerTmdbLookup",
"identity.autoEnrich",