Merge branch 'md1'
# Conflicts: # packages/contracts/src/settings.ts
This commit is contained in:
commit
b633eb5379
30 files changed
+3175
-157
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
|
||||
import { TmdbError } from "@umt/adapters";
|
||||
import { fetchRichDetail, fetchRichSeason, TmdbError, type TmdbRichDetail } from "@umt/adapters";
|
||||
import {
|
||||
Api,
|
||||
type ApiScope,
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type TmdbMediaDetail,
|
||||
type TmdbMediaParams,
|
||||
type TmdbMediaQuery,
|
||||
type TmdbSeasonDetailView,
|
||||
type TmdbSeasonParams,
|
||||
} from "@umt/contracts";
|
||||
import type { SiteRow } from "@umt/db";
|
||||
import type { Context } from "hono";
|
||||
@@ -26,6 +28,7 @@ import type { z } from "zod";
|
||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import { metadataLanguages } from "../sources-identity/service/metadata-rich.ts";
|
||||
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import { matchActions } from "./actions.ts";
|
||||
@@ -159,6 +162,23 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
}
|
||||
};
|
||||
|
||||
/** MD-1 富详情(语言回退链、类型、评分、主创);失败返回 null,详情页退回基础字段 */
|
||||
const tmdbRich = async (type: "movie" | "tv", id: string): Promise<TmdbRichDetail | null> => {
|
||||
const client = await d.runtime().tmdb();
|
||||
if (!client || typeof client.getJson !== "function") return null;
|
||||
try {
|
||||
const langs = await metadataLanguages(ctx);
|
||||
return await raceAbort(
|
||||
fetchRichDetail(client, type, id, langs),
|
||||
AbortSignal.timeout(d.siteTimeoutMs),
|
||||
"TMDB 查询超时",
|
||||
);
|
||||
} catch (e) {
|
||||
ctx.logger.debug({ type, id, err: { message: (e as Error).message } }, "TMDB 富详情获取失败");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** 查询词:给了 q 就只用 q;按作品搜时由作品名 / 原名 / 年份生成 */
|
||||
const releaseTerms = async (q: ReleaseSearchQuery): Promise<string[]> => {
|
||||
if (q.q) return [q.q];
|
||||
@@ -314,7 +334,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
|
||||
const p = params as TmdbMediaParams;
|
||||
const inc = (query as TmdbMediaQuery).include;
|
||||
const m = await tmdbDetails(p.type, p.id);
|
||||
const [m, rich] = await Promise.all([tmdbDetails(p.type, p.id), tmdbRich(p.type, p.id)]);
|
||||
const localId =
|
||||
(await localEntities(ctx, [{ entityType: p.type, externalId: p.id }])).get(`${p.type}:${p.id}`) ?? null;
|
||||
const [inv, counts] = localId
|
||||
@@ -356,8 +376,65 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
mediaServers,
|
||||
queries: searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: p.type === "movie" }),
|
||||
};
|
||||
if (rich) {
|
||||
// 标题 / 简介按语言回退链;季名 / 简介 / 海报取富详情
|
||||
out.title = rich.title || out.title;
|
||||
out.overview = rich.overview ?? out.overview;
|
||||
out.titleZh = rich.titleZh;
|
||||
out.tagline = rich.tagline;
|
||||
out.genres = rich.genres;
|
||||
out.voteAverage = rich.voteAverage;
|
||||
out.voteCount = rich.voteCount;
|
||||
out.airStatus = rich.status;
|
||||
out.runtime = rich.runtime;
|
||||
out.episodeCount = rich.episodeCount;
|
||||
out.lastAirDate = rich.lastAirDate;
|
||||
out.platform = rich.networks.join(" / ") || null;
|
||||
out.cast = rich.cast;
|
||||
out.crew = rich.crew;
|
||||
out.backdropPath = out.backdropPath ?? rich.backdropPath;
|
||||
out.aliases = [
|
||||
...new Set([rich.titleZh, ...out.aliases].filter((x): x is string => !!x && x !== rich.title)),
|
||||
].slice(0, 20);
|
||||
out.seasons = out.seasons.map((s) => {
|
||||
const r = rich.seasons.find((x) => x.seasonNumber === s.seasonNumber);
|
||||
return r ? { ...s, overview: r.overview, posterPath: r.posterPath } : s;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
route(app, Api.search.tmdbSeason, async ({ params }) => {
|
||||
const p = params as TmdbSeasonParams;
|
||||
const client = await d.runtime().tmdb();
|
||||
if (!client || typeof client.getJson !== "function")
|
||||
throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key");
|
||||
try {
|
||||
const langs = await metadataLanguages(ctx);
|
||||
const sd = await raceAbort(
|
||||
fetchRichSeason(client, p.id, p.season, langs),
|
||||
AbortSignal.timeout(d.siteTimeoutMs * 2),
|
||||
"TMDB 查询超时",
|
||||
);
|
||||
const out: TmdbSeasonDetailView = {
|
||||
tvId: sd.tvId,
|
||||
seasonNumber: sd.seasonNumber,
|
||||
name: sd.name,
|
||||
overview: sd.overview,
|
||||
airDate: sd.airDate,
|
||||
posterPath: sd.posterPath,
|
||||
episodes: sd.episodes,
|
||||
};
|
||||
return out;
|
||||
} catch (e) {
|
||||
if (e instanceof TmdbError && e.kind === "notfound")
|
||||
throw new ApiException("not_found", `TMDB 没有 tv/${p.id} 第 ${p.season} 季`);
|
||||
throw new ApiException(
|
||||
"upstream_unavailable",
|
||||
`TMDB 查询失败:${e instanceof TmdbError ? e.kind : (e as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url");
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
OkResult,
|
||||
ReleaseIdentityAssign,
|
||||
type TitleAlias,
|
||||
UnitMetadataField,
|
||||
} from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
updateExternalIdentity,
|
||||
} from "../service/identity-admin.ts";
|
||||
import { clearFieldOverride, setFieldOverride } from "../service/metadata.ts";
|
||||
import { clearUnitFieldOverride, setUnitFieldOverride } from "../service/metadata-rich.ts";
|
||||
|
||||
// 身份与元数据 API:作品(列表/详情/纠错/合并/字段覆盖)、内容单元、别名、外部身份、待识别池。
|
||||
|
||||
@@ -43,6 +45,12 @@ const T_ENT = ["media-entities"];
|
||||
const T_ID = ["identity"];
|
||||
const Accepted = z.object({ taskId: Id });
|
||||
const FieldParam = z.object({ id: Id, field: EntityMetadataField });
|
||||
const UnitFieldParam = z.object({ id: Id, field: UnitMetadataField });
|
||||
const UnitFieldResult = z.object({
|
||||
contentUnitId: Id,
|
||||
changedFields: z.array(z.string()),
|
||||
lockedFields: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** 列表带作品标题:按 media_entity_id 批量取;只挂在内容单元上的行以 `cu:<id>` 为键取所属作品标题 */
|
||||
async function entityTitles(
|
||||
@@ -221,18 +229,24 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/refresh",
|
||||
scope: "organize.execute",
|
||||
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
|
||||
summary:
|
||||
"刷新元数据(字段级;用户锁优先,不动路径)。标题 / 简介按设置 metadata.languages 回退;full=1 拉取全部季的每集信息",
|
||||
tags: T_ENT,
|
||||
params: IdParam,
|
||||
query: z.object({ full: z.enum(["0", "1", "true", "false"]).optional() }),
|
||||
response: Accepted,
|
||||
},
|
||||
async ({ params }) => {
|
||||
async ({ params, query }) => {
|
||||
const id = await resolveEntityId(db(), params.id);
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
const full = query.full === "1" || query.full === "true";
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"refreshMetadata",
|
||||
{ mediaEntityId: id },
|
||||
{ idempotencyKey: `refreshMetadata:${id}:${Math.floor(Date.now() / 60_000)}`, traceId: randomUUID() },
|
||||
{ mediaEntityId: id, ...(full ? { full: true } : {}) },
|
||||
{
|
||||
idempotencyKey: `refreshMetadata:${id}:${full ? "full:" : ""}${Math.floor(Date.now() / 60_000)}`,
|
||||
traceId: randomUUID(),
|
||||
},
|
||||
);
|
||||
return { taskId: r.runId };
|
||||
},
|
||||
@@ -336,6 +350,33 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
);
|
||||
return toApi(row);
|
||||
});
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "put",
|
||||
path: "/content-units/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "覆盖季 / 集字段并锁定(元数据刷新不再改写)",
|
||||
tags: T_ENT,
|
||||
params: UnitFieldParam,
|
||||
body: FieldOverrideInput,
|
||||
response: UnitFieldResult,
|
||||
},
|
||||
async ({ params, body, actor }) => setUnitFieldOverride(rt(), params.id, params.field, body, actor),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "delete",
|
||||
path: "/content-units/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "取消季 / 集字段覆盖与锁定",
|
||||
tags: T_ENT,
|
||||
params: UnitFieldParam,
|
||||
response: UnitFieldResult,
|
||||
},
|
||||
async ({ params, actor }) => clearUnitFieldOverride(rt(), params.id, params.field, actor),
|
||||
);
|
||||
|
||||
// ── 别名 ──
|
||||
route(app, D.titleAliases.list, async ({ query }) => {
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
DEFAULT_METADATA_LANGUAGES,
|
||||
scriptMatches,
|
||||
type TmdbRichDetail,
|
||||
type TmdbRichEpisode,
|
||||
type TmdbRichSeasonDetail,
|
||||
} from "@umt/adapters";
|
||||
import type {
|
||||
EntityMetadataField,
|
||||
FieldOverrideInput,
|
||||
FinalFieldValue,
|
||||
MetadataSource,
|
||||
UnitMetadataField,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import { ensureSeason } from "./metadata-totals.ts";
|
||||
|
||||
// MD-1 语言回退链 + 每集元数据(REQ §7.3 字段级):
|
||||
// - 作品:TMDB 富详情(fetchRichDetail)按设置 metadata.languages 取标题 / 简介 / 标语,另存中文名 titleZh、类型、评分、
|
||||
// 状态、时长、季数、主创、外部 ID;来源值写 metadata_field(language = 实际取到的语言),同字段换语言时删旧行;
|
||||
// - 别名:translations / alternative_titles 写 title_alias(来源 tmdb,标语言);已有同文本别名只补空的语言;
|
||||
// - 季 / 集:fetchRichSeason 的每集 name / overview / airDate / runtime / stillPath / vote 写 metadata_field(content_unit_id),
|
||||
// 投影到 content_unit.metadata(字段级,可锁),name → title 列、airDate → air_date 列;只写变化的字段,
|
||||
// 不改编号、不碰路径绑定与资产覆盖。集单元不存在时按 TMDB 建(confirmed,挂季单元)。
|
||||
|
||||
type FieldValue = { value: unknown; language: string | null };
|
||||
|
||||
/** 设置 metadata.languages(缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US) */
|
||||
export async function metadataLanguages(ctx: ServerContext): Promise<string[]> {
|
||||
const m = await ctx.settings.get("metadata").catch(() => null);
|
||||
const langs = (m as { languages?: string[] } | null)?.languages;
|
||||
return langs?.length ? langs : [...DEFAULT_METADATA_LANGUAGES];
|
||||
}
|
||||
|
||||
/** 富详情 → 作品字段(值 + 语言);海报 / 背景图没有时不写(保留旧值) */
|
||||
export function richEntityFields(r: TmdbRichDetail): Partial<Record<EntityMetadataField, FieldValue>> {
|
||||
const v = (value: unknown, language: string | null = null): FieldValue => ({ value, language });
|
||||
return {
|
||||
title: v(r.title, r.titleLanguage),
|
||||
...(r.titleZh ? { titleZh: v(r.titleZh, r.titleZhLanguage) } : {}),
|
||||
originalTitle: v(r.originalTitle, r.originalLanguage),
|
||||
year: v(r.year),
|
||||
overview: v(r.overview, r.overviewLanguage),
|
||||
tagline: v(r.tagline),
|
||||
releaseDate: v(r.releaseDate),
|
||||
...(r.entityType === "tv" ? { lastAirDate: v(r.lastAirDate) } : {}),
|
||||
originalLanguage: v(r.originalLanguage),
|
||||
originCountry: v(r.originCountry),
|
||||
...(r.posterPath ? { posterPath: v(r.posterPath) } : {}),
|
||||
...(r.backdropPath ? { backdropPath: v(r.backdropPath) } : {}),
|
||||
...(r.genres.length ? { genres: v(r.genres) } : {}),
|
||||
...(r.networks.length ? { platform: v(r.networks.join(" / ")) } : {}),
|
||||
...(r.entityType === "tv" && r.episodeCount !== null ? { episodeCount: v(r.episodeCount) } : {}),
|
||||
...(r.entityType === "tv" ? { seasonCount: v(r.seasonCount) } : {}),
|
||||
voteAverage: v(r.voteAverage),
|
||||
voteCount: v(r.voteCount),
|
||||
airStatus: v(r.status),
|
||||
runtime: v(r.runtime),
|
||||
cast: v(r.cast),
|
||||
crew: v(r.crew),
|
||||
externalIds: v(r.externalIds),
|
||||
};
|
||||
}
|
||||
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
/**
|
||||
* 写一组 tmdb 来源字段(作品或内容单元):值和语言都没变的跳过;语言变了删掉该字段其它语言的 tmdb 行再写。
|
||||
* 返回写入(新增 / 变化)的字段名。
|
||||
*/
|
||||
async function writeSourceFields(
|
||||
exec: Exec,
|
||||
target: { mediaEntityId: string } | { contentUnitId: string },
|
||||
fields: Record<string, FieldValue>,
|
||||
existing: readonly { field: string; value: unknown; language: string | null }[],
|
||||
sourceRevision: string,
|
||||
now: Date,
|
||||
): Promise<string[]> {
|
||||
const entityId = "mediaEntityId" in target ? target.mediaEntityId : null;
|
||||
const unitId = "contentUnitId" in target ? target.contentUnitId : null;
|
||||
const written: string[] = [];
|
||||
for (const [field, fv] of Object.entries(fields)) {
|
||||
const rows = existing.filter((x) => x.field === field);
|
||||
if (rows.length === 1 && rows[0]!.language === fv.language && same(rows[0]!.value, fv.value)) continue;
|
||||
if (rows.some((x) => x.language !== fv.language)) {
|
||||
let q = exec.deleteFrom("metadata_field").where("field", "=", field).where("source", "=", "tmdb");
|
||||
q = entityId
|
||||
? q.where("media_entity_id", "=", entityId).where("content_unit_id", "is", null)
|
||||
: q.where("content_unit_id", "=", unitId!).where("media_entity_id", "is", null);
|
||||
q =
|
||||
fv.language === null
|
||||
? q.where("language", "is not", null)
|
||||
: q.where(sql<boolean>`language is distinct from ${fv.language}`);
|
||||
await q.execute();
|
||||
}
|
||||
await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at)
|
||||
values (${entityId}, ${unitId}, ${field}, ${jsonb(fv.value)}::jsonb, ${fv.language}, 'tmdb', ${sourceRevision}, ${now})
|
||||
on conflict (media_entity_id, content_unit_id, field, source, language) do update set
|
||||
value = excluded.value, source_revision = excluded.source_revision, fetched_at = excluded.fetched_at`.execute(
|
||||
exec,
|
||||
);
|
||||
written.push(field);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/** 富详情写作品的字段来源表;调用方随后 project() 投影 */
|
||||
export async function writeRichEntityFields(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
rich: TmdbRichDetail,
|
||||
sourceRevision: string,
|
||||
now: Date,
|
||||
): Promise<string[]> {
|
||||
const existing = await exec
|
||||
.selectFrom("metadata_field")
|
||||
.select(["field", "value", "language"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.where("source", "=", "tmdb")
|
||||
.execute();
|
||||
return writeSourceFields(
|
||||
exec,
|
||||
{ mediaEntityId: entityId },
|
||||
richEntityFields(rich) as Record<string, FieldValue>,
|
||||
existing,
|
||||
sourceRevision,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
/** 别名的地区 → 语言:只在文字相符时标(CN 地区的英文别名不标 zh-CN) */
|
||||
function altLanguage(region: string | null, text: string): string | null {
|
||||
if (!region) return null;
|
||||
if (["CN", "SG", "TW", "HK", "MO"].includes(region)) return scriptMatches("zh", text) ? `zh-${region}` : null;
|
||||
if (region === "JP") return scriptMatches("ja", text) ? "ja-JP" : null;
|
||||
if (region === "KR") return scriptMatches("ko", text) ? "ko-KR" : null;
|
||||
if (["US", "GB", "CA", "AU", "NZ", "IE"].includes(region)) return scriptMatches("en", text) ? `en-${region}` : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* translations(译名,kind translated)与 alternative_titles(别名,kind other)写 title_alias,来源 tmdb、标语言。
|
||||
* 同作品已有同文本别名时不再新增,只给来源 tmdb 且语言为空的那行补语言。最多 80 条。
|
||||
*/
|
||||
export async function tagTitleAliases(exec: Exec, entityId: string, rich: TmdbRichDetail): Promise<number> {
|
||||
const want: { text: string; kind: "translated" | "other"; language: string | null }[] = [];
|
||||
if (rich.titleZh) want.push({ text: rich.titleZh, kind: "translated", language: rich.titleZhLanguage });
|
||||
for (const t of rich.translations) if (t.title) want.push({ text: t.title, kind: "translated", language: t.lang });
|
||||
for (const a of rich.alternativeTitles)
|
||||
want.push({ text: a.title, kind: "other", language: altLanguage(a.region, a.title) });
|
||||
const rows = await exec
|
||||
.selectFrom("title_alias")
|
||||
.select(["id", "normalized", "language", "source"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.execute();
|
||||
const byNorm = new Map<string, (typeof rows)[number][]>();
|
||||
for (const r of rows) byNorm.set(r.normalized, [...(byNorm.get(r.normalized) ?? []), r]);
|
||||
let n = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const w of want.slice(0, 120)) {
|
||||
const norm = normalizeTitle(w.text);
|
||||
if (!norm || seen.has(norm)) continue;
|
||||
seen.add(norm);
|
||||
const hit = byNorm.get(norm);
|
||||
if (hit?.length) {
|
||||
const blank = hit.find((r) => r.source === "tmdb" && r.language === null);
|
||||
if (w.language && blank && !hit.some((r) => r.language)) {
|
||||
await exec.updateTable("title_alias").set({ language: w.language }).where("id", "=", blank.id).execute();
|
||||
n++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (n >= 80) break;
|
||||
await sql`insert into title_alias (media_entity_id, content_unit_id, alias, normalized, language, kind, source, locked)
|
||||
values (${entityId}, null, ${w.text}, ${norm}, ${w.language}, ${w.kind}, 'tmdb', false)
|
||||
on conflict (media_entity_id, content_unit_id, normalized, kind) do nothing`.execute(exec);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── 内容单元字段 ──
|
||||
|
||||
const UNIT_COLUMN: Partial<Record<UnitMetadataField, "title" | "air_date">> = { name: "title", airDate: "air_date" };
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* 重新投影内容单元的最终字段:覆盖(锁)> field_locks 已锁字段保持 > tmdb 来源值。
|
||||
* name → title 列、airDate → air_date 列(来源没给日期时不清空列);编号不动。只在有变化时写。
|
||||
*/
|
||||
export async function projectUnit(
|
||||
exec: Exec,
|
||||
unitId: string,
|
||||
now: Date,
|
||||
): Promise<{ changedFields: string[]; lockedFields: string[] }> {
|
||||
const u = await exec
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "metadata", "field_locks", "title", sql<string | null>`to_char(air_date, 'YYYY-MM-DD')`.as("air")])
|
||||
.where("id", "=", unitId)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
const [overrides, sources] = await Promise.all([
|
||||
exec.selectFrom("field_override").selectAll().where("content_unit_id", "=", unitId).execute(),
|
||||
exec
|
||||
.selectFrom("metadata_field")
|
||||
.selectAll()
|
||||
.where("content_unit_id", "=", unitId)
|
||||
.orderBy("fetched_at", "desc")
|
||||
.execute(),
|
||||
]);
|
||||
const meta = { ...((u.metadata ?? {}) as Record<string, FinalFieldValue>) };
|
||||
const locks = new Set(u.field_locks);
|
||||
const changed: string[] = [];
|
||||
const lockedFields: string[] = [];
|
||||
const colPatch: { title?: string; air_date?: string } = {};
|
||||
const names = new Set([...sources.map((s) => s.field), ...overrides.map((o) => o.field)]);
|
||||
for (const field of names) {
|
||||
const ov = overrides.find((o) => o.field === field);
|
||||
let next: FinalFieldValue;
|
||||
if (ov) {
|
||||
next = {
|
||||
value: ov.value,
|
||||
source: ov.source as MetadataSource,
|
||||
language: null,
|
||||
at: ov.updated_at.toISOString(),
|
||||
locked: ov.locked,
|
||||
};
|
||||
if (ov.locked) lockedFields.push(field);
|
||||
} else if (locks.has(field)) {
|
||||
lockedFields.push(field);
|
||||
continue;
|
||||
} else {
|
||||
const src =
|
||||
sources.find((s) => s.field === field && s.source === "tmdb") ?? sources.find((s) => s.field === field);
|
||||
if (!src) continue;
|
||||
next = {
|
||||
value: src.value,
|
||||
source: src.source as MetadataSource,
|
||||
language: src.language,
|
||||
at: src.fetched_at.toISOString(),
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
const prev = meta[field];
|
||||
if (
|
||||
!prev ||
|
||||
!same(prev.value, next.value) ||
|
||||
prev.source !== next.source ||
|
||||
prev.language !== next.language ||
|
||||
prev.locked !== next.locked
|
||||
) {
|
||||
changed.push(field);
|
||||
meta[field] = next;
|
||||
}
|
||||
const col = UNIT_COLUMN[field as UnitMetadataField];
|
||||
if (col === "title" && typeof next.value === "string" && next.value && next.value !== u.title)
|
||||
colPatch.title = next.value;
|
||||
if (col === "air_date" && typeof next.value === "string" && DATE_RE.test(next.value) && next.value !== u.air)
|
||||
colPatch.air_date = next.value;
|
||||
}
|
||||
if (changed.length || Object.keys(colPatch).length) {
|
||||
await exec
|
||||
.updateTable("content_unit")
|
||||
.set({ ...colPatch, metadata: jsonb(meta), updated_at: now })
|
||||
.where("id", "=", unitId)
|
||||
.execute();
|
||||
}
|
||||
return { changedFields: changed, lockedFields };
|
||||
}
|
||||
|
||||
function episodeFields(e: TmdbRichEpisode): Record<string, FieldValue> {
|
||||
const air = e.airDate && DATE_RE.test(e.airDate.slice(0, 10)) ? e.airDate.slice(0, 10) : null;
|
||||
return {
|
||||
name: { value: e.name, language: e.nameLanguage },
|
||||
overview: { value: e.overview, language: e.overviewLanguage },
|
||||
airDate: { value: air, language: null },
|
||||
runtime: { value: e.runtime, language: null },
|
||||
stillPath: { value: e.stillPath, language: null },
|
||||
voteAverage: { value: e.voteAverage, language: null },
|
||||
voteCount: { value: e.voteCount, language: null },
|
||||
};
|
||||
}
|
||||
|
||||
export interface SeasonMetadataResult {
|
||||
season: number;
|
||||
episodes: number;
|
||||
/** 新建的集单元数 */
|
||||
created: number;
|
||||
/** 字段有变化(投影改写)的内容单元数(含季单元) */
|
||||
changedUnits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 季详情 → 季单元与集单元的字段级元数据。集单元按(作品, 季, 集号)找最早建的未退役单元,没有时:
|
||||
* create = true(full 刷新 / 追踪中的季)建 confirmed 单元;否则只更新已有单元(集单元仍由发布 / 文件懒建)。
|
||||
* 只写变化的来源字段,只投影有变化的单元。
|
||||
*/
|
||||
export async function applySeasonMetadata(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
sd: TmdbRichSeasonDetail,
|
||||
sourceRevision: string,
|
||||
now: Date,
|
||||
opts: { create?: boolean } = {},
|
||||
): Promise<SeasonMetadataResult> {
|
||||
const create = opts.create ?? true;
|
||||
const out: SeasonMetadataResult = { season: sd.seasonNumber, episodes: 0, created: 0, changedUnits: 0 };
|
||||
if (sd.seasonNumber < 1) return out;
|
||||
const seasonUnit = await ensureSeason(exec, entityId, sd.seasonNumber, sd.name);
|
||||
const units = await exec
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "episode_number", "metadata"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("kind", "=", "episode")
|
||||
.where("season_number", "=", sd.seasonNumber)
|
||||
.where("status", "!=", "retired")
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
const unitOf = new Map<number, { id: string; metadata: unknown }>();
|
||||
for (const u of units)
|
||||
if (u.episode_number !== null && !unitOf.has(u.episode_number)) unitOf.set(u.episode_number, u);
|
||||
const eps = sd.episodes.filter((e) => e.episodeNumber >= 1).slice(0, 500);
|
||||
const targets: { unitId: string; fields: Record<string, FieldValue>; fresh: boolean }[] = [];
|
||||
for (const e of eps) {
|
||||
let hit = unitOf.get(e.episodeNumber);
|
||||
let fresh = false;
|
||||
if (!hit && !create) continue;
|
||||
if (!hit) {
|
||||
const row = await exec
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
parent_unit_id: seasonUnit,
|
||||
kind: "episode",
|
||||
season_number: sd.seasonNumber,
|
||||
episode_number: e.episodeNumber,
|
||||
status: "confirmed",
|
||||
display_order: e.episodeNumber,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
hit = { id: row.id, metadata: {} };
|
||||
unitOf.set(e.episodeNumber, hit);
|
||||
out.created++;
|
||||
fresh = true;
|
||||
}
|
||||
targets.push({ unitId: hit.id, fields: episodeFields(e), fresh });
|
||||
}
|
||||
const seasonFields: Record<string, FieldValue> = {
|
||||
name: { value: sd.name, language: sd.nameLanguage },
|
||||
overview: { value: sd.overview, language: sd.overviewLanguage },
|
||||
airDate: { value: sd.airDate && DATE_RE.test(sd.airDate) ? sd.airDate : null, language: null },
|
||||
...(sd.posterPath ? { posterPath: { value: sd.posterPath, language: null } } : {}),
|
||||
};
|
||||
targets.push({ unitId: seasonUnit, fields: seasonFields, fresh: false });
|
||||
const ids = targets.map((t) => t.unitId);
|
||||
const existing = ids.length
|
||||
? await exec
|
||||
.selectFrom("metadata_field")
|
||||
.select(["content_unit_id", "field", "value", "language"])
|
||||
.where("content_unit_id", "in", ids)
|
||||
.where("source", "=", "tmdb")
|
||||
.execute()
|
||||
: [];
|
||||
for (const t of targets) {
|
||||
const mine = existing.filter((x) => x.content_unit_id === t.unitId);
|
||||
const written = await writeSourceFields(exec, { contentUnitId: t.unitId }, t.fields, mine, sourceRevision, now);
|
||||
if (written.length || t.fresh) {
|
||||
const r = await projectUnit(exec, t.unitId, now);
|
||||
if (r.changedFields.length) out.changedUnits++;
|
||||
}
|
||||
}
|
||||
out.episodes = eps.length;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 该作品已有集单元(或整季资产覆盖)的季号:默认刷新时这些季也拉每集信息 */
|
||||
export async function seasonsInLibrary(exec: Exec, entityId: string): Promise<number[]> {
|
||||
const r = await sql<{ season_number: number }>`
|
||||
select distinct season_number from content_unit
|
||||
where media_entity_id = ${entityId} and kind = 'episode' and status <> 'retired' and season_number >= 1
|
||||
order by season_number`.execute(exec);
|
||||
return r.rows.map((x) => x.season_number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次刷新要拉每集信息的季:full = 全部正片季(最多 60);否则 = 追踪中的季 ∪ 库里已有集单元的季,取最近 8 季
|
||||
* (没有追踪订阅、库里也没有集单元时不拉季详情,定时刷新不额外请求 TMDB)。
|
||||
*/
|
||||
export function seasonsToRefresh(
|
||||
known: readonly number[],
|
||||
opts: { full: boolean; tracked: readonly number[]; inLibrary: readonly number[] },
|
||||
): number[] {
|
||||
const all = [...new Set(known.filter((n) => n >= 1))].sort((a, b) => a - b);
|
||||
if (opts.full) return all.slice(0, 60);
|
||||
const want = new Set<number>([...opts.tracked, ...opts.inLibrary].filter((n) => all.includes(n)));
|
||||
return [...want].sort((a, b) => a - b).slice(-8);
|
||||
}
|
||||
|
||||
// ── 内容单元字段覆盖 / 锁 ──
|
||||
|
||||
function validateUnitValue(field: UnitMetadataField, v: unknown): void {
|
||||
const bad = (m: string) => {
|
||||
throw new ApiException("bad_request", m);
|
||||
};
|
||||
if (v === null) {
|
||||
if (field === "name") bad("name 必须是非空字符串");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(field === "name" || field === "overview" || field === "stillPath" || field === "posterPath") &&
|
||||
typeof v !== "string"
|
||||
)
|
||||
bad(`${field} 必须是字符串`);
|
||||
if (field === "name" && !(v as string).trim()) bad("name 必须是非空字符串");
|
||||
if (field === "airDate" && (typeof v !== "string" || !DATE_RE.test(v))) bad("airDate 必须是 YYYY-MM-DD");
|
||||
if ((field === "runtime" || field === "voteCount") && (typeof v !== "number" || !Number.isInteger(v) || v < 0))
|
||||
bad(`${field} 必须是非负整数`);
|
||||
if (field === "voteAverage" && (typeof v !== "number" || v < 0 || v > 10)) bad("voteAverage 必须在 0–10 之间");
|
||||
}
|
||||
|
||||
export async function setUnitFieldOverride(
|
||||
rt: SourcesRuntime,
|
||||
unitId: string,
|
||||
field: UnitMetadataField,
|
||||
input: FieldOverrideInput,
|
||||
actor: Actor,
|
||||
) {
|
||||
validateUnitValue(field, input.value);
|
||||
return rt.ctx.db.transaction().execute(async (trx) => {
|
||||
const u = await trx
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "field_locks", "media_entity_id"])
|
||||
.where("id", "=", unitId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!u) throw new ApiException("not_found", "内容单元不存在");
|
||||
await sql`insert into field_override (media_entity_id, content_unit_id, field, value, locked, source, reason, actor_type, actor_id)
|
||||
values (null, ${u.id}, ${field}, ${jsonb(input.value)}::jsonb, ${input.lock}, 'user', ${input.reason ?? null}, ${actor.type}, ${actor.id})
|
||||
on conflict (media_entity_id, content_unit_id, field) do update set
|
||||
value = excluded.value, locked = excluded.locked, reason = excluded.reason,
|
||||
actor_type = excluded.actor_type, actor_id = excluded.actor_id`.execute(trx);
|
||||
const locks = new Set(u.field_locks);
|
||||
if (input.lock) locks.add(field);
|
||||
else locks.delete(field);
|
||||
await trx
|
||||
.updateTable("content_unit")
|
||||
.set({ field_locks: [...locks].sort() })
|
||||
.where("id", "=", u.id)
|
||||
.execute();
|
||||
const r = await projectUnit(trx, u.id, rt.now());
|
||||
await audit(
|
||||
trx,
|
||||
actor,
|
||||
"metadata.unit_field_override",
|
||||
{ type: "content_unit", id: u.id },
|
||||
{ field, lock: input.lock, mediaEntityId: u.media_entity_id },
|
||||
);
|
||||
return { contentUnitId: u.id, ...r };
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearUnitFieldOverride(
|
||||
rt: SourcesRuntime,
|
||||
unitId: string,
|
||||
field: UnitMetadataField,
|
||||
actor: Actor,
|
||||
) {
|
||||
return rt.ctx.db.transaction().execute(async (trx) => {
|
||||
const u = await trx
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "field_locks", "metadata"])
|
||||
.where("id", "=", unitId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!u) throw new ApiException("not_found", "内容单元不存在");
|
||||
await trx.deleteFrom("field_override").where("content_unit_id", "=", u.id).where("field", "=", field).execute();
|
||||
// 最终值里去掉覆盖留下的值,重新按来源投影(没有来源值时该字段消失)
|
||||
const meta = { ...((u.metadata ?? {}) as Record<string, unknown>) };
|
||||
delete meta[field];
|
||||
await trx
|
||||
.updateTable("content_unit")
|
||||
.set({ field_locks: u.field_locks.filter((f) => f !== field), metadata: jsonb(meta) })
|
||||
.where("id", "=", u.id)
|
||||
.execute();
|
||||
const r = await projectUnit(trx, u.id, rt.now());
|
||||
await audit(trx, actor, "metadata.unit_field_override_cleared", { type: "content_unit", id: u.id }, { field });
|
||||
return { contentUnitId: u.id, ...r };
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,8 @@ export interface SeasonTotalsResult {
|
||||
activated: number;
|
||||
}
|
||||
|
||||
async function ensureSeason(exec: Exec, entityId: string, season: number, name: string | null): Promise<string> {
|
||||
/** 季单元:按(作品, 季号)找最早建的未退役季单元,没有就建(confirmed) */
|
||||
export async function ensureSeason(exec: Exec, entityId: string, season: number, name: string | null): Promise<string> {
|
||||
const hit = await exec
|
||||
.selectFrom("content_unit")
|
||||
.select("id")
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type MetadataCandidate, TmdbError, type TmdbSeasonDetail } from "@umt/adapters";
|
||||
import {
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
type MetadataCandidate,
|
||||
TmdbError,
|
||||
type TmdbRichDetail,
|
||||
type TmdbRichSeasonDetail,
|
||||
type TmdbSeasonDetail,
|
||||
} from "@umt/adapters";
|
||||
import type {
|
||||
EntityMetadataField,
|
||||
FieldEvidence,
|
||||
@@ -12,6 +20,15 @@ import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import { resolveEntityId, seedAliases } from "./entities.ts";
|
||||
import {
|
||||
applySeasonMetadata,
|
||||
metadataLanguages,
|
||||
type SeasonMetadataResult,
|
||||
seasonsInLibrary,
|
||||
seasonsToRefresh,
|
||||
tagTitleAliases,
|
||||
writeRichEntityFields,
|
||||
} from "./metadata-rich.ts";
|
||||
import {
|
||||
applyEpisodeAirDates,
|
||||
applySeasonTotals,
|
||||
@@ -103,9 +120,22 @@ export interface RefreshResult {
|
||||
lockedFields: string[];
|
||||
/** 剧集:季总集数账本刷新结果(W5-C) */
|
||||
seasons?: SeasonTotalsResult["seasons"];
|
||||
/** MD-1:本次拉了每集信息的季 */
|
||||
episodes?: SeasonMetadataResult[];
|
||||
/** MD-1:回退链取到的标题 / 中文名(语言) */
|
||||
titles?: { title: string; language: string | null; titleZh: string | null };
|
||||
}
|
||||
|
||||
export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string): Promise<RefreshResult> {
|
||||
export interface RefreshOptions {
|
||||
/** true = 拉全部正片季的每集信息(POST /media-entities/{id}/refresh?full=1);默认只拉追踪中 / 库里已有 / 最新的季 */
|
||||
full?: boolean;
|
||||
}
|
||||
|
||||
export async function refreshMetadata(
|
||||
rt: SourcesRuntime,
|
||||
mediaEntityId: string,
|
||||
opts: RefreshOptions = {},
|
||||
): Promise<RefreshResult> {
|
||||
const { ctx } = rt;
|
||||
const id = await resolveEntityId(ctx.db, mediaEntityId);
|
||||
if (!id) return { status: "skipped", reason: "not_found", changedFields: [], lockedFields: [] };
|
||||
@@ -144,9 +174,41 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// MD-1 富详情(语言回退链、类型、评分、主创…)与每集信息;注入的简化客户端没有 getJson 时走旧路径
|
||||
const rich = typeof tmdb.getJson === "function";
|
||||
const langs = rich ? await metadataLanguages(ctx) : [];
|
||||
let richDetail: TmdbRichDetail | null = null;
|
||||
const richSeasons: TmdbRichSeasonDetail[] = [];
|
||||
// 可以新建集单元的季:full 刷新的全部季、追踪中订阅的季;其余季只更新已有集单元
|
||||
let creatable = new Set<number>();
|
||||
if (rich) {
|
||||
try {
|
||||
richDetail = await fetchRichDetail(tmdb, ident.entity_type, ident.external_id, langs);
|
||||
} catch (e) {
|
||||
if (!(e instanceof TmdbError)) throw e;
|
||||
ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新");
|
||||
}
|
||||
}
|
||||
if (rich && ident.entity_type === "tv" && detail.seasons?.length) {
|
||||
const known = detail.seasons.map((x) => x.seasonNumber);
|
||||
const tracked = await trackedSeasonsForAirDates(ctx.db, id, known);
|
||||
const want = seasonsToRefresh(known, {
|
||||
full: !!opts.full,
|
||||
tracked,
|
||||
inLibrary: await seasonsInLibrary(ctx.db, id),
|
||||
});
|
||||
creatable = new Set(opts.full ? want : tracked);
|
||||
for (const n of want) {
|
||||
try {
|
||||
richSeasons.push(await fetchRichSeason(tmdb, ident.external_id, n, langs, { now: rt.now() }));
|
||||
} catch (e) {
|
||||
ctx.logger.debug({ entityId: id, season: n, err: { message: (e as Error).message } }, "季详情获取失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新
|
||||
const airSeasons: TmdbSeasonDetail[] = [];
|
||||
if (ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
|
||||
if (!rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
|
||||
const want = await trackedSeasonsForAirDates(
|
||||
ctx.db,
|
||||
id,
|
||||
@@ -161,8 +223,10 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
|
||||
}
|
||||
}
|
||||
const now = rt.now();
|
||||
const rev = `tmdb:${ident.entity_type}/${ident.external_id}`;
|
||||
return ctx.db.transaction().execute(async (trx) => {
|
||||
await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now);
|
||||
if (richDetail) await writeRichEntityFields(trx, id, richDetail, rev, now);
|
||||
else await writeTmdbFields(trx, id, detail, rev, now);
|
||||
await trx
|
||||
.updateTable("external_identity")
|
||||
.set({ verify_status: "ok", verified_at: now })
|
||||
@@ -171,6 +235,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
await seedAliases(trx, id, detail);
|
||||
if (richDetail) await tagTitleAliases(trx, id, richDetail);
|
||||
const r = await project(trx, id, now);
|
||||
// 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此)
|
||||
const totals =
|
||||
@@ -178,8 +243,19 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
|
||||
? await applySeasonTotals(trx, id, detail.seasons, now)
|
||||
: null;
|
||||
for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd);
|
||||
const episodes: SeasonMetadataResult[] = [];
|
||||
for (const sd of richSeasons)
|
||||
episodes.push(await applySeasonMetadata(trx, id, sd, rev, now, { create: creatable.has(sd.seasonNumber) }));
|
||||
await trx.updateTable("media_entity").set({ last_metadata_refresh_at: now }).where("id", "=", id).execute();
|
||||
return { status: "refreshed" as const, ...r, ...(totals ? { seasons: totals.seasons } : {}) };
|
||||
return {
|
||||
status: "refreshed" as const,
|
||||
...r,
|
||||
...(totals ? { seasons: totals.seasons } : {}),
|
||||
...(richSeasons.length ? { episodes } : {}),
|
||||
...(richDetail
|
||||
? { titles: { title: richDetail.title, language: richDetail.titleLanguage, titleZh: richDetail.titleZh } }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -67,9 +67,10 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
|
||||
defineTask({
|
||||
name: "refreshMetadata",
|
||||
version: 1,
|
||||
inputSchema: z.object({ mediaEntityId: id }),
|
||||
inputSchema: z.object({ mediaEntityId: id, full: z.boolean().optional() }),
|
||||
retryPolicy: NETWORK_RETRY,
|
||||
handler: (t) => t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId)),
|
||||
handler: (t) =>
|
||||
t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full })),
|
||||
}),
|
||||
defineTask({
|
||||
name: "searchSubscription",
|
||||
|
||||
@@ -346,6 +346,33 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
|
||||
expect(counts()).toEqual(before);
|
||||
});
|
||||
|
||||
it("MD-1 GET /media/tmdb/tv/{id}(语言回退链)与 /season/{n}(每集信息)", async () => {
|
||||
const d = await req(`/media/tmdb/tv/95396`);
|
||||
expect(d.status).toBe(200);
|
||||
expect(d.body.data).toMatchObject({
|
||||
title: "人生切割术",
|
||||
titleZh: "人生切割术",
|
||||
overview: "故事发生在一家名为卢蒙的超级大公司内。",
|
||||
genres: ["剧情", "悬疑", "Sci-Fi & Fantasy"],
|
||||
voteAverage: 8.4,
|
||||
airStatus: "Returning Series",
|
||||
platform: "Apple TV",
|
||||
backdropPath: "/severance-bg.jpg",
|
||||
});
|
||||
expect(d.body.data.cast[0]).toMatchObject({ name: "亚当·斯科特", role: "Mark Scout", profilePath: "/adam.jpg" });
|
||||
expect(d.body.data.seasons[0]).toMatchObject({ seasonNumber: 1, posterPath: "/sev-s1.jpg" });
|
||||
|
||||
const s1 = await req(`/media/tmdb/tv/95396/season/1`);
|
||||
expect(s1.status).toBe(200);
|
||||
expect(s1.body.data.episodes.map((e: Loose) => [e.episodeNumber, e.name, e.nameLanguage])).toEqual([
|
||||
[1, "有关地狱的好消息", "zh-CN"],
|
||||
[2, "半循環", "zh-TW"],
|
||||
[3, "In Perpetuity", "en-US"],
|
||||
]);
|
||||
expect(s1.body.data.episodes[0]).toMatchObject({ runtime: 59, stillPath: "/sev-101.jpg", voteAverage: 8.2 });
|
||||
expect((await req(`/media/tmdb/tv/95396/season/9`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /inventory/media-servers 可按 TMDB 条目查(未建档时 mediaEntityId = null)", async () => {
|
||||
const r = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=872585`);
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts";
|
||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { seasonsToRefresh } from "../../src/modules/sources-identity/service/metadata-rich.ts";
|
||||
import { createHarness, type Harness, json, pgAvailable } from "./harness.ts";
|
||||
|
||||
const up = await pgAvailable();
|
||||
type Final = { value: unknown; source: string; language: string | null; locked: boolean };
|
||||
|
||||
describe("seasonsToRefresh", () => {
|
||||
it("full 拉全部正片季;默认 = 追踪 ∪ 库里已有,最多 8 季;都没有时不拉", () => {
|
||||
expect(seasonsToRefresh([0, 1, 2, 3], { full: true, tracked: [], inLibrary: [] })).toEqual([1, 2, 3]);
|
||||
expect(seasonsToRefresh([0, 1, 2, 3], { full: false, tracked: [], inLibrary: [] })).toEqual([]);
|
||||
expect(seasonsToRefresh([1, 2, 3, 4], { full: false, tracked: [2], inLibrary: [1, 9] })).toEqual([1, 2]);
|
||||
const many = Array.from({ length: 20 }, (_, i) => i + 1);
|
||||
expect(seasonsToRefresh(many, { full: false, tracked: [], inLibrary: many })).toEqual(many.slice(-8));
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("MD-1 语言回退链 + 每集元数据", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let id: string;
|
||||
const actor = { type: "user" as const, id: "tester" };
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb();
|
||||
h = await createHarness("md1", { tmdbUrl: tmdb.url, now: () => new Date("2026-01-01T00:00:00Z") });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
const entity = () => h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const episodes = () =>
|
||||
h.db
|
||||
.selectFrom("content_unit")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("kind", "=", "episode")
|
||||
.orderBy("season_number")
|
||||
.orderBy("episode_number")
|
||||
.execute();
|
||||
|
||||
it("建档后 full 刷新:标题取 CN 别名「人生切割术」,中文名 / 简介带语言,别名标语言", async () => {
|
||||
const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "95396", kind: "tv" }, actor);
|
||||
id = r.mediaEntityId;
|
||||
// details() 的 zh-CN name 是原名(TMDB 缺 zh-CN 译名时回退)
|
||||
expect((await entity()).title).toBe("Severance");
|
||||
const before = await episodes();
|
||||
expect(before.map((u) => [u.season_number, u.episode_number, u.status])).toEqual([
|
||||
[1, 1, "provisional"],
|
||||
[1, 2, "provisional"],
|
||||
[1, 3, "provisional"],
|
||||
[2, 1, "provisional"],
|
||||
]);
|
||||
|
||||
const acc = await json(await h.req(`/media-entities/${id}/refresh?full=1`, { method: "POST" }));
|
||||
expect(acc.status).toBe(200);
|
||||
expect(h.engine.enqueued.at(-1)).toMatchObject({
|
||||
task: "refreshMetadata",
|
||||
input: { mediaEntityId: id, full: true },
|
||||
});
|
||||
|
||||
const res = await refreshMetadata(h.rt, id, { full: true });
|
||||
expect(res.status).toBe("refreshed");
|
||||
expect(res.titles).toEqual({ title: "人生切割术", language: "zh-CN", titleZh: "人生切割术" });
|
||||
const e = await entity();
|
||||
expect(e.title).toBe("人生切割术");
|
||||
const meta = e.metadata as Record<string, Final>;
|
||||
expect(meta.title).toMatchObject({ value: "人生切割术", language: "zh-CN", source: "tmdb" });
|
||||
expect(meta.titleZh).toMatchObject({ value: "人生切割术", language: "zh-CN" });
|
||||
expect(meta.overview).toMatchObject({ value: "故事发生在一家名为卢蒙的超级大公司内。", language: "zh-CN" });
|
||||
expect(meta.genres?.value).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]);
|
||||
expect(meta.voteAverage?.value).toBe(8.4);
|
||||
expect(meta.airStatus?.value).toBe("Returning Series");
|
||||
expect(meta.platform?.value).toBe("Apple TV");
|
||||
expect(meta.backdropPath?.value).toBe("/severance-bg.jpg");
|
||||
expect((meta.cast?.value as { name: string }[] | undefined)?.[0]?.name).toBe("亚当·斯科特");
|
||||
expect(meta.externalIds?.value).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null });
|
||||
// 同字段只留一行 tmdb 来源(语言变了删旧行)
|
||||
const titleRows = await h.db
|
||||
.selectFrom("metadata_field")
|
||||
.select(["language"])
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("field", "=", "title")
|
||||
.execute();
|
||||
expect(titleRows).toEqual([{ language: "zh-CN" }]);
|
||||
|
||||
const aliases = await h.db
|
||||
.selectFrom("title_alias")
|
||||
.select(["alias", "language", "source", "kind"])
|
||||
.where("media_entity_id", "=", id)
|
||||
.execute();
|
||||
const lang = (a: string) => aliases.find((x) => x.alias === a)?.language;
|
||||
expect(lang("人生切割术")).toBe("zh-CN");
|
||||
// 繁简归一化后与「人生切割术」同键,不重复登记
|
||||
expect(aliases.filter((a) => a.alias === "人生切割術")).toHaveLength(0);
|
||||
expect(lang("生活割離術")).toBe("zh-HK");
|
||||
expect(aliases.every((a) => a.source === "tmdb")).toBe(true);
|
||||
});
|
||||
|
||||
it("每集元数据:字段级写入 content_unit,编号与已有单元不变,名称按链回退", async () => {
|
||||
const units = await episodes();
|
||||
expect(units).toHaveLength(4);
|
||||
const [e1, e2, e3, s2e1] = units;
|
||||
const m = (u: (typeof units)[number]) => u.metadata as Record<string, Final>;
|
||||
expect(e1!.title).toBe("有关地狱的好消息");
|
||||
expect(e1!.air_date).toBeTruthy();
|
||||
expect(m(e1!).name).toMatchObject({ value: "有关地狱的好消息", language: "zh-CN", source: "tmdb" });
|
||||
expect(m(e1!).overview?.value).toBe("马克升职成为部门主管。");
|
||||
expect(m(e1!).runtime?.value).toBe(59);
|
||||
expect(m(e1!).stillPath?.value).toBe("/sev-101.jpg");
|
||||
expect(m(e1!).voteAverage?.value).toBe(8.2);
|
||||
expect(m(e2!).name).toMatchObject({ value: "半循環", language: "zh-TW" });
|
||||
expect(m(e3!).name).toMatchObject({ value: "In Perpetuity", language: "en-US" });
|
||||
expect(m(e3!).overview?.value).toBeNull();
|
||||
// 未播出:占位名,语言未知
|
||||
expect(m(s2e1!).name).toMatchObject({ value: "第 1 集", language: null });
|
||||
// 编号 / 状态不被元数据刷新改动(provisional 仍是 provisional)
|
||||
expect(units.map((u) => [u.season_number, u.episode_number, u.status, u.numbering_locked])).toEqual([
|
||||
[1, 1, "provisional", false],
|
||||
[1, 2, "provisional", false],
|
||||
[1, 3, "provisional", false],
|
||||
[2, 1, "provisional", false],
|
||||
]);
|
||||
const s1 = await h.db
|
||||
.selectFrom("content_unit")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("kind", "=", "season")
|
||||
.where("season_number", "=", 1)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((s1.metadata as Record<string, Final>).posterPath?.value).toBe("/sev-s1.jpg");
|
||||
expect((s1.metadata as Record<string, Final>).overview?.value).toContain("卢蒙");
|
||||
});
|
||||
|
||||
it("增量:再刷新没有变化的单元不改写;锁定集名后刷新不覆盖,解锁回到来源值", async () => {
|
||||
const at = async () =>
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("metadata_field")
|
||||
.select(["fetched_at"])
|
||||
.where("content_unit_id", "is not", null)
|
||||
.execute()
|
||||
)
|
||||
.map((x) => x.fetched_at.getTime())
|
||||
.sort();
|
||||
const t0 = await at();
|
||||
const again = await refreshMetadata(h.rt, id, { full: true });
|
||||
expect(again.episodes?.every((s) => s.changedUnits === 0 && s.created === 0)).toBe(true);
|
||||
expect(await at()).toEqual(t0);
|
||||
|
||||
const e1 = (await episodes())[0]!;
|
||||
const put = await json(
|
||||
await h.req(`/content-units/${e1.id}/fields/name`, { method: "PUT", body: { value: "自定义标题" } }),
|
||||
);
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.data).toMatchObject({ contentUnitId: e1.id, lockedFields: ["name"] });
|
||||
const bad = await json(
|
||||
await h.req(`/content-units/${e1.id}/fields/airDate`, { method: "PUT", body: { value: "x" } }),
|
||||
);
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
await refreshMetadata(h.rt, id, { full: true });
|
||||
let u = (await episodes())[0]!;
|
||||
expect(u.title).toBe("自定义标题");
|
||||
expect(u.field_locks).toEqual(["name"]);
|
||||
expect((u.metadata as Record<string, Final>).name).toMatchObject({
|
||||
value: "自定义标题",
|
||||
source: "user",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
const del = await json(await h.req(`/content-units/${e1.id}/fields/name`, { method: "DELETE" }));
|
||||
expect(del.status).toBe(200);
|
||||
u = (await episodes())[0]!;
|
||||
expect(u.title).toBe("有关地狱的好消息");
|
||||
expect(u.field_locks).toEqual([]);
|
||||
});
|
||||
|
||||
it("设置 metadata.languages 换链:en-US 优先时标题取英文,中文名仍是 zh-CN 值", async () => {
|
||||
await h.db
|
||||
.insertInto("settings")
|
||||
.values({ key: "metadata", value: JSON.stringify({ languages: ["en-US", "zh-CN"] }) } as never)
|
||||
.execute();
|
||||
const r = await refreshMetadata(h.rt, id);
|
||||
expect(r.titles).toEqual({ title: "Severance", language: "en-US", titleZh: "人生切割术" });
|
||||
const e = await entity();
|
||||
expect(e.title).toBe("Severance");
|
||||
expect((e.metadata as Record<string, Final>).overview).toMatchObject({
|
||||
value: "Mark leads a team of office workers.",
|
||||
language: "en-US",
|
||||
});
|
||||
await h.db.deleteFrom("settings").where("key", "=", "metadata").execute();
|
||||
await refreshMetadata(h.rt, id);
|
||||
expect((await entity()).title).toBe("人生切割术");
|
||||
});
|
||||
|
||||
it("作品详情 API 带内容单元元数据;作品级字段来源不含集字段", async () => {
|
||||
const d = await json(await h.req(`/media-entities/${id}/detail`));
|
||||
expect(d.status).toBe(200);
|
||||
const ep = d.body.data.contentUnits.find(
|
||||
(u: { kind: string; episodeNumber: number | null; seasonNumber: number | null }) =>
|
||||
u.kind === "episode" && u.seasonNumber === 1 && u.episodeNumber === 2,
|
||||
);
|
||||
expect(ep.metadata.name.value).toBe("半循環");
|
||||
expect(d.body.data.metadataFields.some((f: { field: string }) => f.field === "stillPath")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MediaEntity,
|
||||
MediaEntityDetail,
|
||||
type MediaKind,
|
||||
type UnitMetadataField,
|
||||
} from "@umt/contracts";
|
||||
import type { z } from "zod";
|
||||
import { ApiRequestError, api, type QueryParams } from "~/lib/api.ts";
|
||||
@@ -144,11 +145,42 @@ export function useUnlockField(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 刷新元数据(字段级;用户锁优先) */
|
||||
/**
|
||||
* 刷新元数据(字段级;用户锁优先)。full = 拉全部季的每集信息。刷新是后台任务:
|
||||
* 提交后隔几秒再取一次详情,拿到任务写回的结果。
|
||||
*/
|
||||
export function useRefreshMetadata(id: string) {
|
||||
const done = useInvalidateEntity(id);
|
||||
return useMutation({
|
||||
mutationFn: () => api.post<unknown>(`/media-entities/${id}/refresh`),
|
||||
mutationFn: (opts?: { full?: boolean }) =>
|
||||
api.post<unknown>(`/media-entities/${id}/refresh${opts?.full ? "?full=1" : ""}`),
|
||||
onSuccess: () => {
|
||||
done();
|
||||
for (const ms of [3_000, 8_000, 20_000]) setTimeout(done, ms);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 季 / 集字段覆盖并锁定(MD-1) */
|
||||
export function useLockUnitField(entityId: string) {
|
||||
const done = useInvalidateEntity(entityId);
|
||||
return useMutation({
|
||||
mutationFn: (v: { unitId: string; field: UnitMetadataField; value: unknown; reason?: string }) =>
|
||||
api.put<unknown>(`/content-units/${v.unitId}/fields/${v.field}`, {
|
||||
value: v.value,
|
||||
lock: true,
|
||||
reason: v.reason,
|
||||
}),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消季 / 集字段覆盖与锁定 */
|
||||
export function useUnlockUnitField(entityId: string) {
|
||||
const done = useInvalidateEntity(entityId);
|
||||
return useMutation({
|
||||
mutationFn: (v: { unitId: string; field: UnitMetadataField }) =>
|
||||
api.del<unknown>(`/content-units/${v.unitId}/fields/${v.field}`),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SearchResponse,
|
||||
type SearchScope,
|
||||
TmdbMediaDetail,
|
||||
TmdbSeasonDetailView,
|
||||
} from "@umt/contracts";
|
||||
import { api } from "~/lib/api.ts";
|
||||
import { getParsed, parseOrThrow } from "./library.ts";
|
||||
@@ -20,6 +21,7 @@ export const searchKeys = {
|
||||
aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const,
|
||||
releases: (f: ReleaseSearchFilters) => ["search", "releases", f] as const,
|
||||
tmdb: (type: string, id: string, ms: boolean) => ["search", "tmdb", type, id, ms] as const,
|
||||
tmdbSeason: (id: string, season: number) => ["search", "tmdb-season", id, season] as const,
|
||||
mediaServers: (ref: MediaServerRef) => ["search", "media-servers", ref] as const,
|
||||
};
|
||||
|
||||
@@ -114,6 +116,16 @@ export function useTmdbMedia(type: string | undefined, id: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 仅 TMDB 剧集的季详情与每集信息(MD-1,季折叠展开时才拉) */
|
||||
export function useTmdbSeason(id: string | undefined, season: number, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: searchKeys.tmdbSeason(id ?? "", season),
|
||||
enabled: enabled && !!id && /^\d+$/.test(id),
|
||||
queryFn: ({ signal }) => getParsed(TmdbSeasonDetailView, `/media/tmdb/tv/${id}/season/${season}`, {}, signal),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type MediaServerRef = { mediaEntityId: string } | { tmdbType: "movie" | "tv"; tmdbId: string };
|
||||
|
||||
/** Emby 库存对比(按本地作品或 TMDB 条目);实例失败 / 超时为 unknown,不等于缺失 */
|
||||
|
||||
@@ -246,6 +246,29 @@ export const ENTITY_FIELD_LABEL: Record<string, string> = {
|
||||
episodeCount: "集数",
|
||||
genres: "类型",
|
||||
posterUrl: "网页海报",
|
||||
titleZh: "中文名",
|
||||
tagline: "标语",
|
||||
lastAirDate: "最近播出",
|
||||
airStatus: "播出状态",
|
||||
voteAverage: "评分",
|
||||
voteCount: "评分人数",
|
||||
runtime: "时长(分钟)",
|
||||
seasonCount: "季数",
|
||||
cast: "演员",
|
||||
crew: "导演 / 主创",
|
||||
externalIds: "外部 ID",
|
||||
};
|
||||
|
||||
/** 季 / 集字段(contracts UnitMetadataField) */
|
||||
export const UNIT_FIELD_LABEL: Record<string, string> = {
|
||||
name: "标题",
|
||||
overview: "简介",
|
||||
airDate: "播出日期",
|
||||
runtime: "时长(分钟)",
|
||||
stillPath: "剧照",
|
||||
posterPath: "海报",
|
||||
voteAverage: "评分",
|
||||
voteCount: "评分人数",
|
||||
};
|
||||
|
||||
/** 补全字段(contracts EnrichFieldName) */
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { type ContentUnit, FinalFieldValue, type UnitMetadataField } from "@umt/contracts";
|
||||
import { LoaderCircle, Lock, LockOpen } from "lucide-react";
|
||||
import { type FormEvent, useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useLockUnitField, useUnlockUnitField } from "~/api/library.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Label } from "~/components/ui/label.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { METADATA_SOURCE, UNIT_FIELD_LABEL } from "./labels.ts";
|
||||
|
||||
// 季 / 集字段覆盖与锁定(MD-1):改标题 / 简介 / 播出日期即锁定,元数据刷新不再改写;解锁回到来源值。
|
||||
|
||||
const EDITABLE: UnitMetadataField[] = ["name", "airDate", "overview"];
|
||||
|
||||
const pad = (n: number | null) => String(n ?? 0).padStart(2, "0");
|
||||
|
||||
export function finalOf(u: ContentUnit, field: string): FinalFieldValue | null {
|
||||
const p = FinalFieldValue.safeParse((u.metadata as Record<string, unknown>)[field]);
|
||||
return p.success ? p.data : null;
|
||||
}
|
||||
|
||||
export function UnitFieldDialog({
|
||||
entityId,
|
||||
unit,
|
||||
onOpenChange,
|
||||
}: {
|
||||
entityId: string;
|
||||
/** null 表示关闭 */
|
||||
unit: ContentUnit | null;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const lock = useLockUnitField(entityId);
|
||||
const unlock = useUnlockUnitField(entityId);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastId, setLastId] = useState<string | null>(null);
|
||||
if ((unit?.id ?? null) !== lastId) {
|
||||
setLastId(unit?.id ?? null);
|
||||
setError(null);
|
||||
setValues(
|
||||
Object.fromEntries(
|
||||
EDITABLE.map((f) => {
|
||||
const v = unit ? finalOf(unit, f)?.value : null;
|
||||
return [f, typeof v === "string" ? v : ""];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!unit) return <Dialog open={false} />;
|
||||
const busy = lock.isPending || unlock.isPending;
|
||||
const code = `S${pad(unit.seasonNumber)}E${pad(unit.episodeNumber)}`;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const changed = EDITABLE.filter((f) => {
|
||||
const cur = finalOf(unit, f)?.value;
|
||||
return (values[f] ?? "").trim() !== (typeof cur === "string" ? cur : "");
|
||||
});
|
||||
if (!changed.length) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
const date = values.airDate?.trim();
|
||||
if (changed.includes("airDate") && date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
setError("播出日期格式是 YYYY-MM-DD");
|
||||
return;
|
||||
}
|
||||
if (changed.includes("name") && !values.name?.trim()) {
|
||||
setError("标题不能为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (const f of changed) {
|
||||
const v = (values[f] ?? "").trim();
|
||||
await lock.mutateAsync({ unitId: unit.id, field: f, value: v || null });
|
||||
}
|
||||
toast.success(`已锁定 ${code} 的 ${changed.map((f) => UNIT_FIELD_LABEL[f]).join("、")}`);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const locked = EDITABLE.filter((f) => unit.fieldLocks.includes(f));
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !busy && onOpenChange(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改 {code}</DialogTitle>
|
||||
<DialogDescription>改过的字段会锁定,刷新元数据不再改写。编号和文件位置不变。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
{EDITABLE.map((f) => {
|
||||
const fin = finalOf(unit, f);
|
||||
const isLocked = unit.fieldLocks.includes(f);
|
||||
return (
|
||||
<div key={f} className="grid gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`${id}-${f}`}>{UNIT_FIELD_LABEL[f]}</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{fin ? (
|
||||
<span className="text-meta text-muted-foreground">
|
||||
来源 {METADATA_SOURCE[fin.source] ?? fin.source}
|
||||
{fin.language ? ` · ${fin.language}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{isLocked ? (
|
||||
<StatusBadge>
|
||||
<Lock className="h-3 w-3" />
|
||||
已锁定
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{f === "overview" ? (
|
||||
<Textarea
|
||||
id={`${id}-${f}`}
|
||||
rows={4}
|
||||
value={values[f] ?? ""}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f]: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`${id}-${f}`}
|
||||
value={values[f] ?? ""}
|
||||
placeholder={f === "airDate" ? "YYYY-MM-DD" : undefined}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error ? <p className="text-meta text-destructive">{error}</p> : null}
|
||||
<DialogFooter className="flex-wrap gap-2">
|
||||
{locked.length ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
className="sm:mr-auto"
|
||||
onClick={async () => {
|
||||
try {
|
||||
for (const f of locked) await unlock.mutateAsync({ unitId: unit.id, field: f });
|
||||
toast.success(`已解锁 ${code}`);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "解锁失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LockOpen />
|
||||
全部解锁
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{lock.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存并锁定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 外部身份 → 外站链接(媒体详情头部):TMDB / IMDb / TVDB / Wikidata。只拼公开地址,不带任何凭证。
|
||||
|
||||
export function externalLinks(ids: {
|
||||
tmdb?: { type: "movie" | "tv"; id: string } | null;
|
||||
imdb?: string | null;
|
||||
tvdb?: string | null;
|
||||
wikidata?: string | null;
|
||||
tvdbKind?: "movie" | "series";
|
||||
}): { label: string; href: string }[] {
|
||||
const out: { label: string; href: string }[] = [];
|
||||
if (ids.tmdb && /^\d+$/.test(ids.tmdb.id))
|
||||
out.push({ label: "TMDB", href: `https://www.themoviedb.org/${ids.tmdb.type}/${ids.tmdb.id}` });
|
||||
if (ids.imdb && /^tt\d+$/.test(ids.imdb))
|
||||
out.push({ label: "IMDb", href: `https://www.imdb.com/title/${ids.imdb}/` });
|
||||
if (ids.tvdb && /^\d+$/.test(ids.tvdb))
|
||||
out.push({
|
||||
label: "TVDB",
|
||||
href: `https://thetvdb.com/dereferrer/${ids.tvdbKind ?? (ids.tmdb?.type === "movie" ? "movie" : "series")}/${ids.tvdb}`,
|
||||
});
|
||||
if (ids.wikidata && /^Q\d+$/.test(ids.wikidata))
|
||||
out.push({ label: "Wikidata", href: `https://www.wikidata.org/wiki/${ids.wikidata}` });
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
|
||||
import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { CoverPlaceholder } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、
|
||||
// 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。
|
||||
// 图片一律经图片代理(tmdbImageUrl);背景图上压 scrim 遮罩,文字用 on-media 色(不随日夜翻转)。
|
||||
|
||||
/** TMDB status 原文 → 界面用词 */
|
||||
export const AIR_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||||
"Returning Series": { label: "连载中", tone: "success" },
|
||||
"In Production": { label: "制作中", tone: "neutral" },
|
||||
Planned: { label: "计划中", tone: "neutral" },
|
||||
Pilot: { label: "试播", tone: "neutral" },
|
||||
Ended: { label: "已完结", tone: "neutral" },
|
||||
Canceled: { label: "已取消", tone: "warning" },
|
||||
Released: { label: "已上映", tone: "neutral" },
|
||||
"Post Production": { label: "后期制作", tone: "neutral" },
|
||||
Rumored: { label: "传闻", tone: "neutral" },
|
||||
};
|
||||
|
||||
export interface MediaFacts {
|
||||
title: string;
|
||||
originalTitle: string | null;
|
||||
/** 中文名与标题不同时显示在原名一行 */
|
||||
titleZh?: string | null;
|
||||
year: number | null;
|
||||
/** movie / series / variety … */
|
||||
kind: string;
|
||||
kindLabel: string;
|
||||
genres: string[];
|
||||
voteAverage: number | null;
|
||||
voteCount: number | null;
|
||||
airStatus: string | null;
|
||||
runtime: number | null;
|
||||
seasonCount: number | null;
|
||||
episodeCount: number | null;
|
||||
releaseDate: string | null;
|
||||
lastAirDate: string | null;
|
||||
platform: string | null;
|
||||
originCountry: string[];
|
||||
overview: string | null;
|
||||
tagline: string | null;
|
||||
posterPath: string | null;
|
||||
backdropPath: string | null;
|
||||
externalLinks: { label: string; href: string }[];
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
export const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function runtimeText(min: number | null, kind: string): string | null {
|
||||
if (!min) return null;
|
||||
const s = min >= 60 ? `${Math.floor(min / 60)} 小时${min % 60 ? ` ${min % 60} 分钟` : ""}` : `${min} 分钟`;
|
||||
return kind === "movie" ? s : `单集 ${s}`;
|
||||
}
|
||||
|
||||
/** 可折叠的长文本:默认最多 lines 行,超出给「展开」 */
|
||||
export function ClampText({
|
||||
text,
|
||||
lines = 3,
|
||||
className,
|
||||
onMedia = false,
|
||||
}: {
|
||||
text: string;
|
||||
lines?: 2 | 3 | 4;
|
||||
className?: string;
|
||||
onMedia?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const long = text.length > lines * 38;
|
||||
const clamp = lines === 2 ? "line-clamp-2" : lines === 3 ? "line-clamp-3" : "line-clamp-4";
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className={cn("break-words whitespace-pre-line", !open && long && clamp)}>{text}</p>
|
||||
{long ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"mt-0.5 cursor-pointer text-meta underline-offset-4 hover:underline",
|
||||
onMedia
|
||||
? "text-on-media-tertiary hover:text-on-media-strong"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{open ? "收起" : "展开"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 头部:背景图(压暗)+ 海报 + 标题与事实行 + 简介 + 外部链接;badges 放标题下方,操作按钮由页面放在头部之后 */
|
||||
export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: ReactNode }) {
|
||||
const [bgFailed, setBgFailed] = useState(false);
|
||||
const bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
|
||||
const status = facts.airStatus ? (AIR_STATUS[facts.airStatus] ?? { label: facts.airStatus, tone: "neutral" }) : null;
|
||||
const sub = [
|
||||
facts.titleZh && facts.titleZh !== facts.title ? facts.titleZh : null,
|
||||
facts.originalTitle && facts.originalTitle !== facts.title && facts.originalTitle !== facts.titleZh
|
||||
? facts.originalTitle
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const line = [
|
||||
facts.kindLabel,
|
||||
facts.year ? String(facts.year) : null,
|
||||
facts.kind !== "movie" && facts.seasonCount ? `${facts.seasonCount} 季` : null,
|
||||
facts.kind !== "movie" && facts.episodeCount ? `${facts.episodeCount} 集` : null,
|
||||
runtimeText(facts.runtime, facts.kind),
|
||||
facts.platform,
|
||||
facts.originCountry.join(" / ") || null,
|
||||
].filter(Boolean);
|
||||
const dates = [
|
||||
facts.releaseDate ? `${facts.kind === "movie" ? "上映" : "首播"} ${facts.releaseDate}` : null,
|
||||
facts.kind !== "movie" && facts.lastAirDate ? `最近播出 ${facts.lastAirDate}` : null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<section className="relative isolate overflow-hidden rounded-2xl border border-border bg-scrim text-on-media">
|
||||
{bg ? (
|
||||
<img
|
||||
src={bg}
|
||||
alt=""
|
||||
aria-hidden
|
||||
onError={() => setBgFailed(true)}
|
||||
className="absolute inset-0 -z-10 h-full w-full object-cover object-top opacity-55"
|
||||
/>
|
||||
) : null}
|
||||
<div aria-hidden className="absolute inset-0 -z-10 bg-gradient-to-r from-scrim/90 via-scrim/70 to-scrim/35" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-10 bg-gradient-to-t from-scrim/80 via-transparent to-transparent"
|
||||
/>
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:gap-6 sm:p-6">
|
||||
<CoverPlaceholder
|
||||
title={facts.title}
|
||||
kind={facts.kind}
|
||||
posterPath={facts.posterPath}
|
||||
className="h-40 w-28 self-start border-on-media-wash/20 shadow-lg sm:h-60 sm:w-40"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-title-lg font-semibold tracking-tight break-words text-on-media-strong sm:text-headline-sm">
|
||||
{facts.title}
|
||||
{facts.year ? <span className="font-normal text-on-media-tertiary"> ({facts.year})</span> : null}
|
||||
</h1>
|
||||
{sub.length ? <p className="mt-1 text-body break-words text-on-media-secondary">{sub.join(" · ")}</p> : null}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-on-media-secondary">
|
||||
{facts.voteAverage ? (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-on-media-strong">
|
||||
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
|
||||
{facts.voteAverage.toFixed(1)}
|
||||
{facts.voteCount ? (
|
||||
<span className="font-normal text-on-media-tertiary">({facts.voteCount} 人评分)</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
|
||||
{line.length ? <span>{line.join(" · ")}</span> : null}
|
||||
</div>
|
||||
{facts.genres.length ? (
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="类型">
|
||||
{facts.genres.map((g) => (
|
||||
<li
|
||||
key={g}
|
||||
className="rounded-full border border-on-media-wash/25 px-2 py-0.5 text-meta text-on-media-secondary"
|
||||
>
|
||||
{g}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{badges ? <div className="mt-2 flex flex-wrap items-center gap-1.5">{badges}</div> : null}
|
||||
{facts.tagline ? <p className="mt-3 text-caption text-on-media-tertiary italic">{facts.tagline}</p> : null}
|
||||
{facts.overview ? (
|
||||
<ClampText
|
||||
text={facts.overview}
|
||||
lines={4}
|
||||
onMedia
|
||||
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-3 text-caption text-on-media-tertiary">没有简介。</p>
|
||||
)}
|
||||
{dates.length || facts.externalLinks.length ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-on-media-tertiary">
|
||||
{dates.map((d) => (
|
||||
<span key={d}>{d}</span>
|
||||
))}
|
||||
{facts.externalLinks.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 underline-offset-4 hover:text-on-media-strong hover:underline"
|
||||
>
|
||||
{l.label}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ path, name }: { path: string | null; name: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const src = failed ? null : tmdbImageUrl(path, "w185");
|
||||
return src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="h-16 w-16 shrink-0 rounded-full border border-border bg-foreground/[0.05] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
title={name}
|
||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-full border border-border bg-foreground/[0.05] text-muted-foreground"
|
||||
>
|
||||
<UserRound className="h-6 w-6" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const CREW_ROLE: Record<string, string> = { Director: "导演", Creator: "主创" };
|
||||
|
||||
/** 主创:导演 / 主创在前,演员前 10;头像经图片代理,点姓名去 TMDB 人物页 */
|
||||
export function CreditsStrip({ cast, crew }: { cast: MediaCredit[]; crew: MediaCredit[] }) {
|
||||
const people = [
|
||||
...crew.map((c) => ({ ...c, role: c.role ? (CREW_ROLE[c.role] ?? c.role) : null, key: `c${c.id}` })),
|
||||
...cast.slice(0, 10).map((c) => ({ ...c, key: `a${c.id}` })),
|
||||
];
|
||||
if (!people.length) return null;
|
||||
return (
|
||||
<ul className="scroll-bleed -mx-1 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap sm:overflow-visible">
|
||||
{people.map((p) => (
|
||||
<li key={p.key} className="flex w-20 shrink-0 flex-col items-center text-center">
|
||||
<Avatar path={p.profilePath} name={p.name} />
|
||||
<a
|
||||
href={`https://www.themoviedb.org/person/${p.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground hover:underline"
|
||||
>
|
||||
{p.name}
|
||||
</a>
|
||||
{p.role ? <span className="line-clamp-2 text-micro break-words text-muted-foreground">{p.role}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EpisodeRowData {
|
||||
key: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
airDate: string | null;
|
||||
runtime: number | null;
|
||||
stillPath: string | null;
|
||||
voteAverage: number | null;
|
||||
/** 库内状态;null = 不显示(仅 TMDB 作品未收录) */
|
||||
status?: { label: string; tone: StatusTone } | null;
|
||||
/** 有锁定的字段 */
|
||||
locked?: boolean;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
function Still({ path }: { path: string | null }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const src = failed ? null : tmdbImageUrl(path, "w300");
|
||||
return (
|
||||
<div className="aspect-video w-28 shrink-0 overflow-hidden rounded-lg border border-border bg-foreground/[0.05] sm:w-40">
|
||||
{src ? (
|
||||
<img src={src} alt="" loading="lazy" onError={() => setFailed(true)} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div aria-hidden className="grid h-full w-full place-items-center text-muted-foreground">
|
||||
<ImageOff className="h-4 w-4 opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 每集一行:剧照 · 集号与标题 · 播出日期 · 时长 · 简介(折叠)· 库内状态 */
|
||||
export function EpisodeRow({ e }: { e: EpisodeRowData }) {
|
||||
const unaired = !!e.airDate && e.airDate > today();
|
||||
const meta = [
|
||||
e.airDate ?? "播出日期未知",
|
||||
e.runtime ? `${e.runtime} 分钟` : null,
|
||||
e.voteAverage ? `★ ${e.voteAverage.toFixed(1)}` : null,
|
||||
].filter(Boolean);
|
||||
const status = e.status === undefined ? (unaired ? { label: "未播出", tone: "neutral" as const } : null) : e.status;
|
||||
return (
|
||||
<li className="flex gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<Still path={e.stillPath} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
|
||||
<p className="min-w-0 text-sm font-medium break-words text-foreground">
|
||||
<span className="mr-1.5 font-mono text-caption text-muted-foreground">
|
||||
S{pad(e.seasonNumber)}E{pad(e.episodeNumber)}
|
||||
</span>
|
||||
{e.name ?? `第 ${e.episodeNumber} 集`}
|
||||
{e.locked ? <Lock className="ml-1 inline h-3 w-3 text-muted-foreground" aria-label="有锁定字段" /> : null}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
|
||||
{e.onEdit ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`修改 S${pad(e.seasonNumber)}E${pad(e.episodeNumber)}`}
|
||||
onClick={e.onEdit}
|
||||
>
|
||||
<PencilLine />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">{meta.join(" · ")}</p>
|
||||
{e.overview ? <ClampText text={e.overview} lines={2} className="mt-1 text-caption text-foreground/85" /> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SeasonHeaderData {
|
||||
seasonNumber: number;
|
||||
name: string | null;
|
||||
airDate: string | null;
|
||||
episodeCount: number | null;
|
||||
posterPath: string | null;
|
||||
overview: string | null;
|
||||
/** 季头右侧的摘要,如「已入库 5/9」 */
|
||||
summary?: ReactNode;
|
||||
}
|
||||
|
||||
/** 季折叠:季头(海报缩略、季名、首播年、集数、摘要)+ 展开后的内容(集列表由页面传入) */
|
||||
export function SeasonSection({
|
||||
s,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
s: SeasonHeaderData;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const title = s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
|
||||
return (
|
||||
<section className="rounded-xl border border-border/70">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-base hover:bg-wash/[0.04]"
|
||||
>
|
||||
<CoverPlaceholder title={title} kind="series" small posterPath={s.posterPath} className="h-14 w-10" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium break-words text-foreground">
|
||||
{title}
|
||||
<span className="ml-2 font-mono text-meta font-normal text-muted-foreground">S{pad(s.seasonNumber)}</span>
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{[s.airDate ? s.airDate.slice(0, 4) : null, s.episodeCount !== null ? `${s.episodeCount} 集` : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{s.summary ? <div className="hidden shrink-0 sm:block">{s.summary}</div> : null}
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-base",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="border-t border-border/60 px-3 py-3">
|
||||
{s.summary ? <div className="mb-2 sm:hidden">{s.summary}</div> : null}
|
||||
{s.overview ? (
|
||||
<ClampText text={s.overview} lines={2} className="mb-3 text-caption text-muted-foreground" />
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_SETTINGS } from "@umt/contracts";
|
||||
import { toast } from "sonner";
|
||||
import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts";
|
||||
import { useUpdateSettings } from "~/api/settings.ts";
|
||||
@@ -7,7 +8,23 @@ import { errorMessage } from "~/lib/api.ts";
|
||||
import { str } from "./resource-section.tsx";
|
||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 元数据:TMDB API Key(只写,存 secret)与 Base URL
|
||||
// 元数据:TMDB API Key(只写,存 secret)、Base URL、语言回退链
|
||||
|
||||
const DEFAULT_LANGUAGES = DEFAULT_SETTINGS.metadata.languages;
|
||||
|
||||
/** "zh-CN, zh_sg en" → ["zh-CN","zh-SG","en"];不合法的项丢弃,全空回缺省 */
|
||||
function parseLanguages(v: string): string[] {
|
||||
const out = v
|
||||
.split(/[,,、\s]+/)
|
||||
.map((x) => x.trim().replace("_", "-"))
|
||||
.filter(Boolean)
|
||||
.map((x) => {
|
||||
const [l, r] = x.split("-");
|
||||
return r ? `${l!.toLowerCase()}-${r.toUpperCase()}` : l!.toLowerCase();
|
||||
})
|
||||
.filter((x) => /^[a-z]{2,3}(-[A-Z]{2})?$/.test(x));
|
||||
return out.length ? [...new Set(out)].slice(0, 8) : [...DEFAULT_LANGUAGES];
|
||||
}
|
||||
|
||||
const TMDB_CARD: CardSpec = {
|
||||
anchor: "tmdb",
|
||||
@@ -29,11 +46,24 @@ const TMDB_CARD: CardSpec = {
|
||||
hint: "自建反代时填写;留空用官方地址",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "metadata.languages",
|
||||
label: "语言回退链",
|
||||
kind: "text",
|
||||
placeholder: DEFAULT_LANGUAGES.join(", "),
|
||||
hint: "标题、简介和每集信息按顺序取第一个有值的语言;中文名取链中第一个中文值。逗号分隔,如 zh-CN, zh-SG",
|
||||
wide: true,
|
||||
},
|
||||
],
|
||||
fromSettings: (s) => ({ "metadata.tmdbApiKey": "", "metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "" }),
|
||||
fromSettings: (s) => ({
|
||||
"metadata.tmdbApiKey": "",
|
||||
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
|
||||
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
metadata: {
|
||||
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
|
||||
languages: parseLanguages(str(v["metadata.languages"])),
|
||||
...(str(v["metadata.tmdbApiKey"]) ? { tmdbApiKey: str(v["metadata.tmdbApiKey"]) } : {}),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,9 @@ import type {
|
||||
LibraryAssetView,
|
||||
LibraryDetail,
|
||||
LibraryDownloadView,
|
||||
MediaCredit,
|
||||
MediaEntityDetail,
|
||||
SubscriptionCoverage,
|
||||
} from "@umt/contracts";
|
||||
import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contracts";
|
||||
import {
|
||||
@@ -22,8 +24,9 @@ import {
|
||||
PencilLine,
|
||||
RefreshCw,
|
||||
SearchX,
|
||||
Tv,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { mediaServers } from "~/api/instances.ts";
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
useRefreshMetadata,
|
||||
useUnlockField,
|
||||
} from "~/api/library.ts";
|
||||
import { useSubscriptionCoverage, useSubscriptions } from "~/api/subscriptions.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
@@ -55,20 +59,23 @@ import {
|
||||
UNIT_KIND,
|
||||
VERIFY_STATUS,
|
||||
} from "~/components/library/labels.ts";
|
||||
import {
|
||||
Chip,
|
||||
ChipList,
|
||||
CoverPlaceholder,
|
||||
Panel,
|
||||
ProgressLine,
|
||||
posterPathOf,
|
||||
relTime,
|
||||
Tone,
|
||||
} from "~/components/library/parts.tsx";
|
||||
import { Chip, ChipList, Panel, ProgressLine, posterPathOf, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { SlotCard } from "~/components/library/slot-card.tsx";
|
||||
import { finalOf, UnitFieldDialog } from "~/components/library/unit-field-dialog.tsx";
|
||||
import { externalLinks } from "~/components/media/external-links.ts";
|
||||
import {
|
||||
AIR_STATUS,
|
||||
CreditsStrip,
|
||||
EpisodeRow,
|
||||
type MediaFacts,
|
||||
MediaHero,
|
||||
SeasonSection,
|
||||
today,
|
||||
} from "~/components/media/media-detail.tsx";
|
||||
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
|
||||
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
||||
import { EmbyBadge } from "~/components/search/work-card.tsx";
|
||||
import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts";
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -76,6 +83,8 @@ import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
|
||||
// 内容单元、下载历史与校验结果,同一页同时展示。
|
||||
// MD-1(对照 MP 媒体详情):头部背景图 + 海报 + 评分 / 类型 / 状态 + 简介、主创、季折叠与每集行(库内状态按版本目标),
|
||||
// 头部 / 主创 / 季集组件与仅 TMDB 的作品详情页共用(components/media/media-detail)。
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
@@ -251,7 +260,52 @@ function IdentityRow({ i }: { i: ExternalIdentity }) {
|
||||
|
||||
// ── 元数据字段来源与锁 ──
|
||||
|
||||
const ENRICH_ONLY_FIELDS = new Set<string>(["platform", "episodeCount", "genres", "posterUrl"]);
|
||||
/** 只在有值时显示的字段:补全专有(平台 / 集数 / 类型 / 网页海报)与 MD-1 TMDB 详情字段 */
|
||||
const ENRICH_ONLY_FIELDS = new Set<string>([
|
||||
"platform",
|
||||
"episodeCount",
|
||||
"genres",
|
||||
"posterUrl",
|
||||
"titleZh",
|
||||
"tagline",
|
||||
"lastAirDate",
|
||||
"airStatus",
|
||||
"voteAverage",
|
||||
"voteCount",
|
||||
"runtime",
|
||||
"seasonCount",
|
||||
"cast",
|
||||
"crew",
|
||||
"externalIds",
|
||||
]);
|
||||
/** 结构化值(列表 / 对象)不走文本覆盖对话框 */
|
||||
const NOT_EDITABLE = new Set<string>(["cast", "crew", "externalIds"]);
|
||||
|
||||
/** 字段值 → 一行文本 */
|
||||
function fieldText(field: string, v: unknown): string {
|
||||
if (v === null || v === undefined || v === "") return "—";
|
||||
if (field === "cast" || field === "crew") {
|
||||
const list = (Array.isArray(v) ? v : []) as MediaCredit[];
|
||||
return (
|
||||
list
|
||||
.map((c) => (c.role ? `${c.name}(${c.role})` : c.name))
|
||||
.slice(0, 6)
|
||||
.join("、") || "—"
|
||||
);
|
||||
}
|
||||
if (field === "externalIds" && typeof v === "object") {
|
||||
return (
|
||||
Object.entries(v as Record<string, unknown>)
|
||||
.filter(([, x]) => x)
|
||||
.map(([k, x]) => `${k} ${String(x)}`)
|
||||
.join(" · ") || "—"
|
||||
);
|
||||
}
|
||||
if (field === "airStatus" && typeof v === "string") return AIR_STATUS[v]?.label ?? v;
|
||||
if (Array.isArray(v)) return v.join("、");
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** META-1 补全写入的字段:证据等级 + 出处网页(来源 web = 网页原文,agent = Agent 归纳,都不是 TMDB) */
|
||||
function EvidenceLine({ field }: { field: MediaEntityDetail["metadataFields"][number] | undefined }) {
|
||||
@@ -307,7 +361,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="元数据"
|
||||
title="字段来源与锁"
|
||||
description={`锁定的字段刷新时不变 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
|
||||
actions={
|
||||
<Button
|
||||
@@ -315,10 +369,13 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
size="sm"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() =>
|
||||
refresh.mutate(undefined, {
|
||||
onSuccess: () => toast.success("已提交刷新"),
|
||||
refresh.mutate(
|
||||
{ full: true },
|
||||
{
|
||||
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
|
||||
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
@@ -333,16 +390,8 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
<li key={field} className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
|
||||
<div className="w-24 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENTITY_FIELD_LABEL[field]}</div>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<p className="text-sm break-words text-foreground">
|
||||
{final ? (
|
||||
Array.isArray(final.value) ? (
|
||||
final.value.join("、")
|
||||
) : (
|
||||
String(final.value ?? "—")
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">未提供</span>
|
||||
)}
|
||||
<p className="line-clamp-4 text-sm break-words text-foreground">
|
||||
{final ? fieldText(field, final.value) : <span className="text-muted-foreground">未提供</span>}
|
||||
</p>
|
||||
{final ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
@@ -354,7 +403,9 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
{cands.length ? (
|
||||
<p className="mt-0.5 text-meta break-words text-muted-foreground">
|
||||
候选:
|
||||
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
|
||||
{cands
|
||||
.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${fieldText(field, c.value)}」`)
|
||||
.join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -386,6 +437,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{NOT_EDITABLE.has(field) ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -394,6 +446,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
>
|
||||
<PencilLine />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -540,6 +593,210 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
|
||||
);
|
||||
}
|
||||
|
||||
// ── 季与集(MD-1):季折叠 + 每集行,库内状态按版本目标 ──
|
||||
|
||||
const CELL_TONE: Record<CellKind, "success" | "warning" | "destructive" | "neutral"> = {
|
||||
have: "success",
|
||||
upgradable: "success",
|
||||
inflight: "neutral",
|
||||
deferred: "warning",
|
||||
missing: "destructive",
|
||||
suspected: "warning",
|
||||
not_aired: "neutral",
|
||||
skipped: "neutral",
|
||||
};
|
||||
|
||||
type EpisodeStatus = { label: string; tone: "success" | "warning" | "destructive" | "neutral"; have: boolean };
|
||||
|
||||
/** 覆盖矩阵 → (季, 集) 的格:按内容单元 ID 或集号对列 */
|
||||
function coverageLookup(cov: SubscriptionCoverage | undefined, rowIdx: number) {
|
||||
const map = new Map<string, EpisodeStatus>();
|
||||
if (!cov) return map;
|
||||
const row = cov.rows[rowIdx] ?? cov.rows[0];
|
||||
if (!row) return map;
|
||||
let i = 0;
|
||||
for (const s of cov.seasons) {
|
||||
for (const col of s.columns) {
|
||||
const cell = row.cells[i++];
|
||||
if (!cell || s.seasonNumber === null) continue;
|
||||
const k = cellKind(cell);
|
||||
const st = { label: CELL_LABEL[k], tone: CELL_TONE[k], have: k === "have" || k === "upgradable" };
|
||||
if (col.contentUnitId) map.set(`u:${col.contentUnitId}`, st);
|
||||
if (col.episode !== null) map.set(`e:${s.seasonNumber}:${col.episode}`, st);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
|
||||
const e = detail.entity;
|
||||
const [editing, setEditing] = useState<ContentUnit | null>(null);
|
||||
const [rowIdx, setRowIdx] = useState(0);
|
||||
const refresh = useRefreshMetadata(e.id);
|
||||
const subs = useSubscriptions({ mediaEntityId: e.id });
|
||||
const sub = subs.data?.pages
|
||||
.flatMap((p) => p.items)
|
||||
.find((x) => x.status === "active" || x.status === "pending" || x.status === "paused");
|
||||
const cov = useSubscriptionCoverage(sub?.id);
|
||||
const live = units.filter((u) => u.status !== "retired");
|
||||
const seasonUnits = live.filter((u) => u.kind === "season" && u.seasonNumber !== null);
|
||||
const episodes = live
|
||||
.filter((u) => u.kind === "episode" && u.seasonNumber !== null && u.episodeNumber !== null)
|
||||
.sort((a, b) => a.episodeNumber! - b.episodeNumber! || a.createdAt.localeCompare(b.createdAt));
|
||||
const covered = new Set(detail.assets.filter((a) => a.asset.status === "active").flatMap((a) => a.contentUnitIds));
|
||||
const lookup = useMemo(() => coverageLookup(cov.data, rowIdx), [cov.data, rowIdx]);
|
||||
const seasonNums = [
|
||||
...new Set([...seasonUnits.map((u) => u.seasonNumber!), ...episodes.map((u) => u.seasonNumber!)]),
|
||||
].sort((a, b) => (a || 999) - (b || 999));
|
||||
const seasonUnitOf = (n: number) => seasonUnits.find((u) => u.seasonNumber === n);
|
||||
const trackedSeason = sub?.seasonUnitId ? units.find((u) => u.id === sub.seasonUnitId)?.seasonNumber : undefined;
|
||||
const statusOf = (u: ContentUnit): EpisodeStatus | null => {
|
||||
const hit = lookup.get(`u:${u.id}`) ?? lookup.get(`e:${u.seasonNumber}:${u.episodeNumber}`);
|
||||
if (hit) return hit;
|
||||
const season = seasonUnitOf(u.seasonNumber!);
|
||||
if (covered.has(u.id) || (season && covered.has(season.id)))
|
||||
return { label: "已入库", tone: "success", have: true };
|
||||
const air = finalOf(u, "airDate")?.value ?? u.airDate;
|
||||
if (typeof air === "string" && air > today()) return { label: "未播出", tone: "neutral", have: false };
|
||||
return { label: sub ? "缺失" : "未入库", tone: sub ? "destructive" : "neutral", have: false };
|
||||
};
|
||||
const haveIn = (n: number) => episodes.filter((u) => u.seasonNumber === n && statusOf(u)?.have).length;
|
||||
const initial =
|
||||
trackedSeason ??
|
||||
[...seasonNums].reverse().find((n) => n > 0 && haveIn(n) > 0) ??
|
||||
[...seasonNums].reverse().find((n) => n > 0) ??
|
||||
null;
|
||||
const [open, setOpen] = useState<Set<number> | null>(null);
|
||||
const openSet = open ?? new Set(initial !== null && initial !== undefined ? [initial] : []);
|
||||
const toggle = (n: number) => {
|
||||
const next = new Set(openSet);
|
||||
if (next.has(n)) next.delete(n);
|
||||
else next.add(n);
|
||||
setOpen(next);
|
||||
};
|
||||
const rows = cov.data?.rows ?? [];
|
||||
|
||||
const refreshFull = () =>
|
||||
refresh.mutate(
|
||||
{ full: true },
|
||||
{
|
||||
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
|
||||
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="季与集"
|
||||
description={
|
||||
sub
|
||||
? "库内状态按订阅的覆盖矩阵;标题与简介按语言回退链取值"
|
||||
: "没有订阅时只看是否已入库;标题与简介按语言回退链取值"
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" disabled={refresh.isPending} onClick={refreshFull}>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
更新每集信息
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{rows.length > 1 ? (
|
||||
<fieldset aria-label="按版本目标看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
|
||||
{rows.map((r, i) => (
|
||||
<button
|
||||
key={r.slotId ?? "any"}
|
||||
type="button"
|
||||
aria-pressed={i === rowIdx}
|
||||
onClick={() => setRowIdx(i)}
|
||||
className={
|
||||
i === rowIdx
|
||||
? "pill-selected inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium text-foreground"
|
||||
: "inline-flex h-7 cursor-pointer items-center rounded-full border border-border px-2.5 text-meta font-medium text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
{seasonNums.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Tv}
|
||||
title="还没有每集信息"
|
||||
description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{seasonNums.map((n) => {
|
||||
const su = seasonUnitOf(n);
|
||||
const eps = episodes.filter((u) => u.seasonNumber === n);
|
||||
const total = su?.episodeTotal ?? (eps.length || null);
|
||||
const nameV = su ? finalOf(su, "name")?.value : null;
|
||||
const posterV = su ? finalOf(su, "posterPath")?.value : null;
|
||||
const ovV = su ? finalOf(su, "overview")?.value : null;
|
||||
const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null;
|
||||
const have = haveIn(n);
|
||||
return (
|
||||
<SeasonSection
|
||||
key={n}
|
||||
s={{
|
||||
seasonNumber: n,
|
||||
name: typeof nameV === "string" ? nameV : (su?.title ?? null),
|
||||
airDate: typeof airV === "string" ? airV : null,
|
||||
episodeCount: total,
|
||||
posterPath: typeof posterV === "string" ? posterV : null,
|
||||
overview: typeof ovV === "string" ? ovV : null,
|
||||
summary: (
|
||||
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
|
||||
已入库 {have}/{total ?? "?"}
|
||||
</StatusBadge>
|
||||
),
|
||||
}}
|
||||
open={openSet.has(n)}
|
||||
onToggle={() => toggle(n)}
|
||||
>
|
||||
{eps.length === 0 ? (
|
||||
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{eps.map((u) => {
|
||||
const v = (f: string) => finalOf(u, f)?.value;
|
||||
const str = (x: unknown) => (typeof x === "string" && x ? x : null);
|
||||
const num = (x: unknown) => (typeof x === "number" ? x : null);
|
||||
return (
|
||||
<EpisodeRow
|
||||
key={u.id}
|
||||
e={{
|
||||
key: u.id,
|
||||
seasonNumber: n,
|
||||
episodeNumber: u.episodeNumber!,
|
||||
name: str(v("name")) ?? u.title,
|
||||
overview: str(v("overview")),
|
||||
airDate: str(v("airDate")) ?? u.airDate,
|
||||
runtime: num(v("runtime")),
|
||||
stillPath: str(v("stillPath")),
|
||||
voteAverage: num(v("voteAverage")),
|
||||
status: statusOf(u),
|
||||
locked: u.fieldLocks.length > 0,
|
||||
onEdit: () => setEditing(u),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SeasonSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 下载历史与校验 ──
|
||||
function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
const columns: DataColumn<LibraryDownloadView>[] = [
|
||||
@@ -628,6 +885,71 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** media_entity.metadata 某字段的最终值 */
|
||||
function metaValue(metadata: unknown, field: string): unknown {
|
||||
const p = FinalFieldValue.safeParse((metadata as Record<string, unknown> | null)?.[field]);
|
||||
return p.success ? p.data.value : undefined;
|
||||
}
|
||||
const strOf = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||
const numOf = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
function creditList(v: unknown): MediaCredit[] {
|
||||
return Array.isArray(v)
|
||||
? (v as MediaCredit[]).filter((c) => c && typeof c.name === "string" && typeof c.id === "number")
|
||||
: [];
|
||||
}
|
||||
|
||||
/** 本地作品 → 媒体详情头部数据(字段来自最终元数据;外部链接取已接受的外部身份与 externalIds) */
|
||||
function factsOf(d: LibraryDetail, entity: MediaEntityDetail | undefined): MediaFacts {
|
||||
const e = d.entity;
|
||||
const m = (f: string) => metaValue(e.metadata, f);
|
||||
const accepted = d.identities.filter((i) => i.status === "accepted");
|
||||
const tmdb = accepted.find((i) => i.provider === "tmdb" && (i.entityType === "movie" || i.entityType === "tv"));
|
||||
const ext = (m("externalIds") ?? {}) as { imdb?: string | null; tvdb?: string | null; wikidata?: string | null };
|
||||
const genres = m("genres");
|
||||
const country = m("originCountry");
|
||||
const seasonCount =
|
||||
numOf(m("seasonCount")) ??
|
||||
(new Set(
|
||||
(entity?.contentUnits ?? [])
|
||||
.filter((u) => u.kind === "season" && (u.seasonNumber ?? 0) > 0 && u.status !== "retired")
|
||||
.map((u) => u.seasonNumber),
|
||||
).size ||
|
||||
null);
|
||||
return {
|
||||
title: e.title,
|
||||
originalTitle: e.originalTitle,
|
||||
titleZh: strOf(m("titleZh")),
|
||||
year: e.year,
|
||||
kind: e.kind,
|
||||
kindLabel: MEDIA_KIND[e.kind] ?? e.kind,
|
||||
genres: Array.isArray(genres)
|
||||
? genres.filter((x): x is string => typeof x === "string")
|
||||
: strOf(genres)
|
||||
? [genres as string]
|
||||
: [],
|
||||
voteAverage: numOf(m("voteAverage")),
|
||||
voteCount: numOf(m("voteCount")),
|
||||
airStatus: strOf(m("airStatus")),
|
||||
runtime: numOf(m("runtime")),
|
||||
seasonCount: e.kind === "movie" ? null : seasonCount,
|
||||
episodeCount: e.kind === "movie" ? null : numOf(m("episodeCount")),
|
||||
releaseDate: strOf(m("releaseDate")),
|
||||
lastAirDate: strOf(m("lastAirDate")),
|
||||
platform: strOf(m("platform")),
|
||||
originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [],
|
||||
overview: strOf(m("overview")),
|
||||
tagline: strOf(m("tagline")),
|
||||
posterPath: posterPathOf(e.metadata),
|
||||
backdropPath: strOf(m("backdropPath")),
|
||||
externalLinks: externalLinks({
|
||||
tmdb: tmdb ? { type: tmdb.entityType as "movie" | "tv", id: tmdb.externalId } : null,
|
||||
imdb: ext.imdb ?? accepted.find((i) => i.provider === "imdb")?.externalId ?? null,
|
||||
tvdb: ext.tvdb ?? accepted.find((i) => i.provider === "tvdb")?.externalId ?? null,
|
||||
wikidata: ext.wikidata ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default function LibraryDetailPage() {
|
||||
const { id } = useParams();
|
||||
const detail = useLibraryDetail(id);
|
||||
@@ -671,6 +993,10 @@ export default function LibraryDetailPage() {
|
||||
const d = detail.data;
|
||||
const e = d.entity;
|
||||
const redirected = entity.data?.redirectedFrom;
|
||||
const credits = {
|
||||
cast: creditList(metaValue(e.metadata, "cast")),
|
||||
crew: creditList(metaValue(e.metadata, "crew")),
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
@@ -685,44 +1011,23 @@ export default function LibraryDetailPage() {
|
||||
{redirected ? (
|
||||
<p className="mb-4 text-meta text-muted-foreground">旧 ID {redirected} 已跳转到当前作品。</p>
|
||||
) : null}
|
||||
<header className="mb-6 flex flex-wrap items-start gap-4">
|
||||
<CoverPlaceholder
|
||||
title={e.title}
|
||||
kind={e.kind}
|
||||
posterPath={posterPathOf(e.metadata)}
|
||||
className="h-28 w-20 sm:h-36 sm:w-24"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="min-w-0 text-title-lg font-semibold tracking-tight break-words text-foreground">
|
||||
{e.title}
|
||||
</h1>
|
||||
<MediaHero
|
||||
facts={factsOf(d, entity.data)}
|
||||
badges={
|
||||
<>
|
||||
<Tone v={ENTITY_STATUS[e.status]} />
|
||||
</div>
|
||||
<p className="mt-1 text-body text-muted-foreground">
|
||||
{MEDIA_KIND[e.kind]}
|
||||
{e.year ? ` · ${e.year}` : ""}
|
||||
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
|
||||
{(ms.data?.items.length ?? 0) > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<EmbyBadge refOf={{ mediaEntityId: e.id }} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(ms.data?.items.length ?? 0) > 0 ? <EmbyBadge refOf={{ mediaEntityId: e.id }} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="mt-4 mb-5 flex flex-wrap items-center gap-2">
|
||||
{e.status !== "merged" ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setSubscribing(true)}>
|
||||
<CalendarPlus />
|
||||
订阅
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}>
|
||||
<PackageSearch />
|
||||
搜索资源
|
||||
</Button>
|
||||
@@ -741,10 +1046,16 @@ export default function LibraryDetailPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
||||
<span className="font-mono text-meta break-all text-muted-foreground sm:ml-auto">{e.id}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-5">
|
||||
{credits.cast.length || credits.crew.length ? (
|
||||
<Panel title="主创">
|
||||
<CreditsStrip cast={credits.cast} crew={credits.crew} />
|
||||
</Panel>
|
||||
) : null}
|
||||
{e.kind !== "movie" ? <EpisodesPanel detail={d} units={entity.data?.contentUnits ?? []} /> : null}
|
||||
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<IdentityPanel detail={d} />
|
||||
|
||||
@@ -5,12 +5,20 @@ import { Link, useNavigate, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEntityFromCandidate } from "~/api/files.ts";
|
||||
import { mediaServers } from "~/api/instances.ts";
|
||||
import { useMediaServerInventory, useTmdbMedia } from "~/api/search.ts";
|
||||
import { useMediaServerInventory, useTmdbMedia, useTmdbSeason } from "~/api/search.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { MEDIA_KIND } from "~/components/library/labels.ts";
|
||||
import { CoverPlaceholder, Panel } from "~/components/library/parts.tsx";
|
||||
import { Panel } from "~/components/library/parts.tsx";
|
||||
import { externalLinks } from "~/components/media/external-links.ts";
|
||||
import {
|
||||
CreditsStrip,
|
||||
EpisodeRow,
|
||||
type MediaFacts,
|
||||
MediaHero,
|
||||
SeasonSection,
|
||||
} from "~/components/media/media-detail.tsx";
|
||||
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
||||
import { InventoryBadges, mediaServerSummary, slotLine } from "~/components/search/work-card.tsx";
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
@@ -18,12 +26,11 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 作品详情(仅 TMDB,SEARCH-2):TMDB 信息与季列表 + 本地账本 / Emby 对比;在这里「订阅」「搜索资源」「建档不订阅」。
|
||||
// 已建档的作品同时给媒体库详情入口。
|
||||
// 已建档的作品同时给媒体库详情入口。MD-1:头部 / 主创 / 季折叠与每集行复用 components/media/media-detail(与媒体库详情同一组件),
|
||||
// 每季的集信息在展开时才拉 GET /media/tmdb/tv/{id}/season/{n}。
|
||||
|
||||
export const meta = () => [{ title: "作品详情 · UHD 媒体自动化控制台" }];
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export default function TmdbMediaPage() {
|
||||
const { type, id } = useParams();
|
||||
const q = useTmdbMedia(type, id);
|
||||
@@ -89,31 +96,39 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
|
||||
},
|
||||
);
|
||||
|
||||
const facts: MediaFacts = {
|
||||
title: d.title,
|
||||
originalTitle: d.originalTitle,
|
||||
titleZh: d.titleZh ?? null,
|
||||
year: d.year,
|
||||
kind,
|
||||
kindLabel: MEDIA_KIND[kind] ?? kind,
|
||||
genres: d.genres ?? [],
|
||||
voteAverage: d.voteAverage ?? null,
|
||||
voteCount: d.voteCount ?? null,
|
||||
airStatus: d.airStatus ?? null,
|
||||
runtime: d.runtime ?? null,
|
||||
seasonCount: d.entityType === "tv" ? d.seasons.filter((x) => x.seasonNumber > 0).length || null : null,
|
||||
episodeCount: d.episodeCount ?? null,
|
||||
releaseDate: d.releaseDate,
|
||||
lastAirDate: d.lastAirDate ?? null,
|
||||
platform: d.platform ?? null,
|
||||
originCountry: d.originCountry,
|
||||
overview: d.overview,
|
||||
tagline: d.tagline ?? null,
|
||||
posterPath: d.posterPath,
|
||||
backdropPath: d.backdropPath,
|
||||
externalLinks: externalLinks({
|
||||
tmdb: { type: d.entityType, id: d.externalId },
|
||||
imdb: d.externalIds.imdb,
|
||||
tvdb: d.externalIds.tvdb,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="mb-6 flex flex-wrap items-start gap-4">
|
||||
<CoverPlaceholder title={d.title} kind={kind} posterPath={d.posterPath} className="h-40 w-28 sm:h-48 sm:w-32" />
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<h1 className="text-title-lg font-semibold tracking-tight break-words text-foreground">
|
||||
{d.title}
|
||||
{d.year ? <span className="font-normal text-muted-foreground"> ({d.year})</span> : null}
|
||||
</h1>
|
||||
<p className="mt-1 text-body break-words text-muted-foreground">
|
||||
{[
|
||||
MEDIA_KIND[kind],
|
||||
d.originalTitle && d.originalTitle !== d.title ? d.originalTitle : null,
|
||||
d.releaseDate,
|
||||
d.originCountry.join(" / ") || null,
|
||||
`TMDB ${d.externalId}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<InventoryBadges inv={d.inventory} />
|
||||
</div>
|
||||
{d.overview ? <p className="mt-3 max-w-3xl text-caption text-foreground">{d.overview}</p> : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<MediaHero facts={facts} badges={<InventoryBadges inv={d.inventory} />} />
|
||||
<div className="mt-4 mb-5 flex flex-wrap gap-2">
|
||||
<Button onClick={() => setSubscribing(true)}>
|
||||
<CalendarPlus />
|
||||
订阅
|
||||
@@ -136,25 +151,18 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-5">
|
||||
<InventoryPanel d={d} />
|
||||
{d.entityType === "tv" ? <SeasonsPanel d={d} /> : null}
|
||||
{d.aliases.length || d.externalIds.imdb || d.externalIds.tvdb ? (
|
||||
<Panel title="别名与外部身份">
|
||||
<p className="text-caption break-words text-muted-foreground">
|
||||
{[
|
||||
d.externalIds.imdb ? `IMDb ${d.externalIds.imdb}` : null,
|
||||
d.externalIds.tvdb ? `TVDB ${d.externalIds.tvdb}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
{d.aliases.length ? (
|
||||
<p className="mt-1 text-caption break-words text-foreground">{d.aliases.join("、")}</p>
|
||||
{d.cast?.length || d.crew?.length ? (
|
||||
<Panel title="主创">
|
||||
<CreditsStrip cast={d.cast ?? []} crew={d.crew ?? []} />
|
||||
</Panel>
|
||||
) : null}
|
||||
{d.entityType === "tv" ? <SeasonsPanel d={d} /> : null}
|
||||
<InventoryPanel d={d} />
|
||||
{d.aliases.length ? (
|
||||
<Panel title="别名">
|
||||
<p className="text-caption break-words text-foreground">{d.aliases.join("、")}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -254,41 +262,80 @@ function EmbyInstances({ ms }: { ms: MediaServerInventory }) {
|
||||
}
|
||||
|
||||
function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
|
||||
const regular = d.seasons.filter((x) => x.seasonNumber > 0 && x.episodeCount > 0);
|
||||
// 默认展开:本地已有的最新一季;未收录时展开最新一季
|
||||
const initial =
|
||||
[...regular].reverse().find((x) => (x.localHave ?? 0) > 0)?.seasonNumber ?? regular.at(-1)?.seasonNumber ?? null;
|
||||
const [open, setOpen] = useState<Set<number>>(() => new Set(initial !== null ? [initial] : []));
|
||||
if (d.seasons.length === 0) {
|
||||
return (
|
||||
<Panel title="季">
|
||||
<Panel title="季与集">
|
||||
<EmptyState plain title="TMDB 还没有季信息" className="py-6" />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
const toggle = (n: number) =>
|
||||
setOpen((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(n)) next.delete(n);
|
||||
else next.add(n);
|
||||
return next;
|
||||
});
|
||||
const seasons = [...d.seasons].sort((a, b) => (a.seasonNumber || 999) - (b.seasonNumber || 999));
|
||||
return (
|
||||
<Panel title="季" description={d.localEntityId ? "本地一列是已入库集数" : "未收录,本地一列不显示"}>
|
||||
<table className="w-full text-caption">
|
||||
<thead>
|
||||
<tr className="text-left text-meta text-muted-foreground">
|
||||
<th className="py-1.5 pr-3 font-normal">季</th>
|
||||
<th className="py-1.5 pr-3 font-normal">名称</th>
|
||||
<th className="py-1.5 pr-3 font-normal">首播</th>
|
||||
<th className="py-1.5 pr-3 text-right font-normal">集数</th>
|
||||
{d.localEntityId ? <th className="py-1.5 text-right font-normal">本地</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{d.seasons.map((s) => (
|
||||
<tr key={s.seasonNumber}>
|
||||
<td className="py-1.5 pr-3 font-mono text-foreground">S{pad(s.seasonNumber)}</td>
|
||||
<td className="py-1.5 pr-3 break-words text-foreground">{s.name ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-muted-foreground tabular-nums">{s.airDate ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{s.episodeCount}</td>
|
||||
{d.localEntityId ? (
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{s.localHave === null ? "—" : `${s.localHave}/${s.episodeCount}`}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "未收录,不显示库内状态"}>
|
||||
<div className="space-y-2">
|
||||
{seasons.map((s) => (
|
||||
<SeasonSection
|
||||
key={s.seasonNumber}
|
||||
s={{
|
||||
seasonNumber: s.seasonNumber,
|
||||
name: s.name,
|
||||
airDate: s.airDate,
|
||||
episodeCount: s.episodeCount,
|
||||
posterPath: s.posterPath ?? null,
|
||||
overview: s.overview ?? null,
|
||||
summary:
|
||||
d.localEntityId && s.localHave !== null ? (
|
||||
<StatusBadge tone={s.localHave >= s.episodeCount && s.episodeCount > 0 ? "success" : "neutral"}>
|
||||
已入库 {s.localHave}/{s.episodeCount}
|
||||
</StatusBadge>
|
||||
) : undefined,
|
||||
}}
|
||||
open={open.has(s.seasonNumber)}
|
||||
onToggle={() => toggle(s.seasonNumber)}
|
||||
>
|
||||
<SeasonEpisodes tvId={d.externalId} season={s.seasonNumber} />
|
||||
</SeasonSection>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonEpisodes({ tvId, season }: { tvId: string; season: number }) {
|
||||
const q = useTmdbSeason(tvId, season, true);
|
||||
if (q.isPending) return <LoadingState rows={3} />;
|
||||
if (q.isError) return <ErrorState title="季详情加载失败" error={q.error} onRetry={() => q.refetch()} />;
|
||||
if (!q.data.episodes.length) return <EmptyState plain title="TMDB 还没有这一季的集信息" className="py-6" />;
|
||||
return (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{q.data.episodes.map((e) => (
|
||||
<EpisodeRow
|
||||
key={e.episodeNumber}
|
||||
e={{
|
||||
key: String(e.episodeNumber),
|
||||
seasonNumber: e.seasonNumber,
|
||||
episodeNumber: e.episodeNumber,
|
||||
name: e.name,
|
||||
overview: e.overview,
|
||||
airDate: e.airDate,
|
||||
runtime: e.runtime,
|
||||
stillPath: e.stillPath,
|
||||
voteAverage: e.voteAverage,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# 元数据运行手册(MD-1:语言回退链 + 每集元数据 + 媒体详情)
|
||||
|
||||
## 语言回退链
|
||||
|
||||
- 设置 → 元数据 → TMDB →「语言回退链」,即 `settings.metadata.languages`,缺省 `zh-CN, zh-SG, zh-TW, zh-HK, en-US`。
|
||||
- 作品标题:按链逐个语言查 TMDB `translations`(语言 + 地区精确匹配);该语言译名为空时查同地区、文字相符的 `alternative_titles`(类型为空的优先);原始语言等于链中语言时原名也算。全部落空用请求语言下的 name / 原名。对照 MP `__update_tmdbinfo_cn_title`(中文名 → CN 别名 → SG 译名)。
|
||||
- 中文名 `titleZh`:只在链里的中文语言中按顺序取(zh-CN 优先),值必须含汉字。链首改成 en-US 时标题变英文,`titleZh` 不变。
|
||||
- 简介 / 标语:按链取 `translations` 的第一个非空值。
|
||||
- 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停;链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。
|
||||
- 每个字段的来源值写 `metadata_field`,`language` 记实际取到的语言;同字段语言变了,旧语言的 tmdb 行删掉。
|
||||
- `translations` 与 `alternative_titles` 写 `title_alias`(来源 tmdb,标语言;别名只在地区与文字相符时标,如 CN + 汉字 → zh-CN)。已有同文本别名(繁简归一化后同键)不重复登记,只给语言为空的 tmdb 行补语言。
|
||||
|
||||
## 刷新
|
||||
|
||||
| 触发 | 拉哪些季的每集信息 | 新建集单元 |
|
||||
|---|---|---|
|
||||
| `POST /media-entities/{id}/refresh`(定时追踪刷新同) | 追踪中订阅的季 ∪ 库里已有集单元的季(最近 8 季) | 只在追踪中的季 |
|
||||
| `POST /media-entities/{id}/refresh?full=1`(详情页「刷新元数据」「更新每集信息」) | 全部正片季(最多 60) | 是 |
|
||||
|
||||
- 刷新是后台任务 `refreshMetadata`(输入 `{mediaEntityId, full?}`),结果里 `titles` 是本次取到的标题 / 语言 / 中文名,`episodes[]` 是各季写入统计(`created` 新建单元数、`changedUnits` 有变化的单元数)。
|
||||
- 增量:来源值和语言都没变的字段不写;只投影有变化的单元。不改编号、状态、路径绑定和资产覆盖。
|
||||
- 现有作品补齐中文名 / 每集信息:对作品跑一次 `full=1`。批量:
|
||||
|
||||
```sh
|
||||
# 所有有 TMDB 身份的作品各跑一次(TMDB 请求走缓存;数量多时分批)
|
||||
curl -s -H "Authorization: Bearer $KEY" "$BASE/api/v1/media-entities?limit=200" | jq -r '.data.items[].id' |
|
||||
while read -r id; do curl -s -X POST -H "Authorization: Bearer $KEY" "$BASE/api/v1/media-entities/$id/refresh?full=1"; done
|
||||
```
|
||||
|
||||
## 集字段(字段级,可锁)
|
||||
|
||||
- 集单元字段:`name`(投影到 `content_unit.title`)、`overview`、`airDate`(投影到 `air_date`)、`runtime`、`stillPath`、`voteAverage`、`voteCount`;季单元另有 `posterPath`。最终值在 `content_unit.metadata`,每个字段带来源 / 语言 / 时间 / 锁。
|
||||
- 覆盖并锁定:`PUT /content-units/{id}/fields/{field}` `{value, lock?, reason?}`;取消:`DELETE` 同路径。锁定的字段刷新不改。界面:媒体库详情 → 季与集 → 每集行右侧铅笔。
|
||||
|
||||
## TMDB 请求与缓存
|
||||
|
||||
- 富详情一次请求:`/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`;季详情每语言一次。都走 `tmdb.request` PG 缓存(TTL / 空结果短 TTL / 条数上限见设置 → 元数据 → TMDB 缓存;对照 MP `@cached`:成功才缓存、业务失败不缓存)。
|
||||
- 想立刻拿到 TMDB 刚改的数据:设置 → 元数据 → TMDB 缓存「清空」,再刷新。
|
||||
- 仅 TMDB 作品详情页(`/console/media/tmdb/{type}/{id}`)的季在展开时拉 `GET /api/v1/media/tmdb/tv/{id}/season/{n}`,不落库。
|
||||
|
||||
## 媒体详情页
|
||||
|
||||
- 媒体库详情与仅 TMDB 作品详情共用 `apps/web/app/components/media/media-detail.tsx`:背景图(图片代理 w1280,scrim 遮罩)、海报、标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 季集数、简介、外部链接,主创(导演 / 主创 + 演员前 10,头像 w185),季折叠 + 每集行(剧照 w300、集号与标题、播出日期、时长、评分、简介折叠、库内状态)。
|
||||
- 库内状态:作品有订阅时取覆盖矩阵(可切换版本目标);没有订阅时只看是否已入库。默认展开订阅的季,否则库里有文件的最新季,否则最新季。
|
||||
|
||||
## 排查
|
||||
|
||||
| 现象 | 看哪里 |
|
||||
|---|---|
|
||||
| 标题仍是英文原名 | 作品详情「字段来源与锁」里 `标题` 的来源与语言;是否被锁;设置里的语言链;对作品跑 `full=1` |
|
||||
| 某集没有中文标题 | 该集在 TMDB 各语言都没有译名时保留占位名 / 英文名,属正常;可在每集行手动改并锁定 |
|
||||
| 季里没有集 | 默认刷新不为未追踪的季建集单元,点「更新每集信息」(full) |
|
||||
@@ -257,6 +257,17 @@ export class TmdbClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始 JSON 请求(走同一套缓存 / 限流 / 重试 / 脱敏):details.ts 的多语言详情、季集详情用。
|
||||
* path 以 `/` 开头(不含 /3 前缀),params 不含凭证。
|
||||
*/
|
||||
async getJson<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
params: Record<string, string | undefined> = {},
|
||||
): Promise<T> {
|
||||
return this.get<T>(path, params);
|
||||
}
|
||||
|
||||
/** 单语言原始搜索(匹配链使用,对照 MP search.movies/tv_shows,语言取首选)。 */
|
||||
async searchRaw(type: MediaEntityType, query: string, year?: string | number): Promise<MetadataCandidate[]> {
|
||||
const lang = this.langs[0]!;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createTmdbClient,
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
isPlaceholderEpisodeName,
|
||||
parseAltTitles,
|
||||
parseTranslations,
|
||||
pickChineseTitle,
|
||||
pickText,
|
||||
pickTitle,
|
||||
} from "./index.ts";
|
||||
|
||||
let mock: MockTmdb | null = null;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = null;
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
mock = await createMockTmdb();
|
||||
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 });
|
||||
return { mock, tmdb };
|
||||
}
|
||||
|
||||
const CHAIN = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
|
||||
|
||||
describe("语言回退链(纯函数)", () => {
|
||||
const translations = parseTranslations(
|
||||
{
|
||||
translations: [
|
||||
{ iso_639_1: "zh", iso_3166_1: "CN", data: { name: "", overview: "简中简介" } },
|
||||
{ iso_639_1: "zh", iso_3166_1: "TW", data: { name: "人生切割術", overview: "繁中簡介" } },
|
||||
{ iso_639_1: "en", iso_3166_1: "US", data: { name: "", overview: "English" } },
|
||||
],
|
||||
},
|
||||
"name",
|
||||
);
|
||||
const altTitles = parseAltTitles({
|
||||
results: [
|
||||
{ iso_3166_1: "CN", title: "Severance CN", type: "" },
|
||||
{ iso_3166_1: "CN", title: "人生切割术", type: "" },
|
||||
{ iso_3166_1: "HK", title: "生活割離術", type: "" },
|
||||
],
|
||||
});
|
||||
const src = { translations, altTitles, originalTitle: "Severance", originalLanguage: "en", primary: "Severance" };
|
||||
|
||||
it("zh-CN 译名为空时取 CN 地区的中文别名(对照 MP __update_tmdbinfo_cn_title)", () => {
|
||||
expect(pickTitle(CHAIN, src)).toEqual({ value: "人生切割术", language: "zh-CN", via: "alternative_title" });
|
||||
expect(pickChineseTitle(CHAIN, src)?.value).toBe("人生切割术");
|
||||
});
|
||||
|
||||
it("没有 CN 别名时按链落到 zh-TW 译名;链只有英文时英文原名", () => {
|
||||
const noCn = { ...src, altTitles: altTitles.filter((a) => a.region !== "CN") };
|
||||
expect(pickTitle(CHAIN, noCn)).toMatchObject({ value: "人生切割術", language: "zh-TW", via: "translation" });
|
||||
expect(pickTitle(["en-US"], noCn)).toMatchObject({ value: "Severance", language: "en-US", via: "original" });
|
||||
expect(pickTitle(["ja-JP"], { ...noCn, originalLanguage: "ko" })).toMatchObject({ via: "primary" });
|
||||
});
|
||||
|
||||
it("简介按链取第一个非空值", () => {
|
||||
expect(pickText(CHAIN, translations, "overview")).toMatchObject({ value: "简中简介", language: "zh-CN" });
|
||||
expect(pickText(["zh-HK", "en-US"], translations, "overview")).toMatchObject({ value: "English" });
|
||||
expect(pickText(["ja-JP"], translations, "overview", { value: null, language: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("占位集名识别", () => {
|
||||
expect(isPlaceholderEpisodeName("第 3 集", 3)).toBe(true);
|
||||
expect(isPlaceholderEpisodeName("Episode 3", 3)).toBe(true);
|
||||
expect(isPlaceholderEpisodeName("第3話")).toBe(true);
|
||||
expect(isPlaceholderEpisodeName("第 3 集", 4)).toBe(false);
|
||||
expect(isPlaceholderEpisodeName("有关地狱的好消息", 1)).toBe(false);
|
||||
expect(isPlaceholderEpisodeName("")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRichDetail / fetchRichSeason(mock TMDB)", () => {
|
||||
it("Severance:标题取 CN 别名,中文名 zh-CN,简介 zh-CN,主创与外部 ID", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
const d = await fetchRichDetail(tmdb, "tv", 95396, CHAIN);
|
||||
expect(d.title).toBe("人生切割术");
|
||||
expect(d.titleLanguage).toBe("zh-CN");
|
||||
expect(d.titleZh).toBe("人生切割术");
|
||||
expect(d.overview).toBe("故事发生在一家名为卢蒙的超级大公司内。");
|
||||
expect(d.overviewLanguage).toBe("zh-CN");
|
||||
expect(d.genres).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]);
|
||||
expect(d.voteAverage).toBe(8.4);
|
||||
expect(d.status).toBe("Returning Series");
|
||||
expect(d.networks).toEqual(["Apple TV"]);
|
||||
expect(d.cast[0]).toEqual({
|
||||
id: 4495,
|
||||
name: "亚当·斯科特",
|
||||
originalName: null,
|
||||
role: "Mark Scout",
|
||||
profilePath: "/adam.jpg",
|
||||
});
|
||||
expect(d.crew[0]).toMatchObject({ name: "丹·埃里克森", role: "Creator" });
|
||||
expect(d.externalIds).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null });
|
||||
expect(d.seasons.map((s) => s.seasonNumber)).toEqual([1, 2]);
|
||||
// 一次请求(credits 等随 append_to_response)
|
||||
expect(mock.calls.filter((r) => r.path === "/3/tv/95396")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("链首 en-US:英文原名、英文简介,中文名仍按中文链取", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const d = await fetchRichDetail(tmdb, "tv", 95396, ["en-US"]);
|
||||
expect(d.title).toBe("Severance");
|
||||
expect(d.overview).toBe("Mark leads a team of office workers.");
|
||||
expect(d.titleZh).toBe("人生切割术");
|
||||
});
|
||||
|
||||
it("季集:首选语言缺标题 / 简介的已播集按链补拉;未播出的集不触发补拉", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
const s = await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
expect(s.name).toBe("第 1 季");
|
||||
expect(s.overview).toContain("卢蒙");
|
||||
const [e1, e2, e3] = s.episodes;
|
||||
expect(e1).toMatchObject({
|
||||
name: "有关地狱的好消息",
|
||||
nameLanguage: "zh-CN",
|
||||
overview: "马克升职成为部门主管。",
|
||||
runtime: 59,
|
||||
stillPath: "/sev-101.jpg",
|
||||
voteAverage: 8.2,
|
||||
voteCount: 171,
|
||||
});
|
||||
expect(e2).toMatchObject({ name: "半循環", nameLanguage: "zh-TW", overview: "馬克帶領新員工參觀。" });
|
||||
// 链走完仍没有中文名:en-US 标题;简介各语言都空
|
||||
expect(e3).toMatchObject({ name: "In Perpetuity", nameLanguage: "en-US", overview: null });
|
||||
expect(s.fetchedLanguages).toEqual(CHAIN);
|
||||
const perLang = mock.calls.filter((r) => r.path === "/3/tv/95396/season/1").map((r) => r.query.language);
|
||||
expect(perLang).toEqual(CHAIN);
|
||||
|
||||
mock.calls.length = 0;
|
||||
const s2 = await fetchRichSeason(tmdb, 95396, 2, CHAIN, { now: new Date("2026-01-01") });
|
||||
expect(s2.episodes[0]).toMatchObject({ name: "第 1 集", nameLanguage: null });
|
||||
expect(s2.fetchedLanguages).toEqual(["zh-CN"]);
|
||||
});
|
||||
|
||||
it("补拉走请求缓存:同一季第二次不再请求", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
const n = mock.calls.length;
|
||||
await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
expect(mock.calls.length).toBe(n);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* TMDB 富详情与季集详情(MD-1,媒体详情页 / 字段级元数据用)。
|
||||
* 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8)
|
||||
* __get_movie_detail / __get_tv_detail(append_to_response=credits,alternative_titles,translations,external_ids …)→ fetchRichDetail;
|
||||
* get_tv_season_detail(季名、简介、海报、episodes[]:name/overview/air_date/runtime/still_path/vote_average)→ fetchRichSeason;
|
||||
* 请求缓存语义同 client.ts(MP tmdbv3api request 的 @cached:成功才缓存、空结果短 TTL)。
|
||||
* 调整:标题 / 简介按语言回退链取(translations.ts);季集在首选语言缺标题(占位名)或缺简介时按链逐个语言补拉,
|
||||
* 链走完或全部补齐即停。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。
|
||||
*/
|
||||
import type { MediaEntityType, TmdbClient } from "./client.ts";
|
||||
import {
|
||||
DEFAULT_METADATA_LANGUAGES,
|
||||
isPlaceholderEpisodeName,
|
||||
isPlaceholderSeasonName,
|
||||
parseAltTitles,
|
||||
parseTranslations,
|
||||
pickChineseTitle,
|
||||
pickText,
|
||||
pickTitle,
|
||||
type TmdbAltTitle,
|
||||
type TmdbTranslation,
|
||||
} from "./translations.ts";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const s = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
||||
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
const int = (v: unknown): number | null => {
|
||||
const x = n(v);
|
||||
return x === null ? null : Math.round(x);
|
||||
};
|
||||
const yearOf = (d: string | null) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null);
|
||||
|
||||
export interface TmdbCredit {
|
||||
id: number;
|
||||
name: string;
|
||||
originalName: string | null;
|
||||
/** 演员:角色名;幕后:职务 */
|
||||
role: string | null;
|
||||
profilePath: string | null;
|
||||
}
|
||||
|
||||
export interface TmdbRichSeason {
|
||||
seasonNumber: number;
|
||||
episodeCount: number;
|
||||
name: string | null;
|
||||
airDate: string | null;
|
||||
overview: string | null;
|
||||
posterPath: string | null;
|
||||
}
|
||||
|
||||
export interface TmdbRichDetail {
|
||||
entityType: MediaEntityType;
|
||||
externalId: string;
|
||||
/** 回退链取到的标题及语言(null = 链全落空后用的请求语言值 / 原名) */
|
||||
title: string;
|
||||
titleLanguage: string | null;
|
||||
/** 中文名(链中第一个中文值,zh-CN 优先);没有为 null */
|
||||
titleZh: string | null;
|
||||
titleZhLanguage: string | null;
|
||||
originalTitle: string | null;
|
||||
originalLanguage: string | null;
|
||||
overview: string | null;
|
||||
overviewLanguage: string | null;
|
||||
tagline: string | null;
|
||||
year: number | null;
|
||||
releaseDate: string | null;
|
||||
lastAirDate: string | null;
|
||||
genres: string[];
|
||||
voteAverage: number | null;
|
||||
voteCount: number | null;
|
||||
/** TMDB status 原文 */
|
||||
status: string | null;
|
||||
/** 分钟:电影片长 / 剧集单集时长(episode_run_time 首项) */
|
||||
runtime: number | null;
|
||||
seasonCount: number | null;
|
||||
episodeCount: number | null;
|
||||
networks: string[];
|
||||
originCountry: string[];
|
||||
posterPath: string | null;
|
||||
backdropPath: string | null;
|
||||
cast: TmdbCredit[];
|
||||
crew: TmdbCredit[];
|
||||
externalIds: { imdb: string | null; tvdb: string | null; wikidata: string | null };
|
||||
translations: TmdbTranslation[];
|
||||
alternativeTitles: TmdbAltTitle[];
|
||||
seasons: TmdbRichSeason[];
|
||||
/** 本次使用的语言链 */
|
||||
languages: string[];
|
||||
}
|
||||
|
||||
export interface TmdbRichEpisode {
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
nameLanguage: string | null;
|
||||
overview: string | null;
|
||||
overviewLanguage: string | null;
|
||||
airDate: string | null;
|
||||
runtime: number | null;
|
||||
stillPath: string | null;
|
||||
voteAverage: number | null;
|
||||
voteCount: number | null;
|
||||
}
|
||||
|
||||
export interface TmdbRichSeasonDetail {
|
||||
tvId: string;
|
||||
seasonNumber: number;
|
||||
name: string | null;
|
||||
nameLanguage: string | null;
|
||||
overview: string | null;
|
||||
overviewLanguage: string | null;
|
||||
airDate: string | null;
|
||||
posterPath: string | null;
|
||||
episodes: TmdbRichEpisode[];
|
||||
/** 实际请求过的语言(首选 + 补拉) */
|
||||
fetchedLanguages: string[];
|
||||
}
|
||||
|
||||
const chainOf = (languages?: readonly string[]) =>
|
||||
languages?.length ? [...languages] : [...DEFAULT_METADATA_LANGUAGES];
|
||||
|
||||
function credits(raw: unknown, type: MediaEntityType, createdBy: unknown): { cast: TmdbCredit[]; crew: TmdbCredit[] } {
|
||||
const c = (raw ?? {}) as Json;
|
||||
const cast = ((Array.isArray(c.cast) ? c.cast : []) as Json[])
|
||||
.filter((x) => n(x.id) !== null && s(x.name))
|
||||
.sort((a, b) => (n(a.order) ?? 999) - (n(b.order) ?? 999))
|
||||
.slice(0, 10)
|
||||
.map((x) => ({
|
||||
id: n(x.id)!,
|
||||
name: s(x.name)!,
|
||||
originalName: s(x.original_name),
|
||||
role: s(x.character),
|
||||
profilePath: s(x.profile_path),
|
||||
}));
|
||||
const crew: TmdbCredit[] = [];
|
||||
const seen = new Set<number>();
|
||||
const push = (x: Json, role: string) => {
|
||||
const id = n(x.id);
|
||||
if (id === null || seen.has(id) || !s(x.name)) return;
|
||||
seen.add(id);
|
||||
crew.push({ id, name: s(x.name)!, originalName: s(x.original_name), role, profilePath: s(x.profile_path) });
|
||||
};
|
||||
if (type === "tv") for (const x of (Array.isArray(createdBy) ? createdBy : []) as Json[]) push(x, "Creator");
|
||||
for (const x of (Array.isArray(c.crew) ? c.crew : []) as Json[]) {
|
||||
if (x.job === "Director") push(x, "Director");
|
||||
}
|
||||
return { cast, crew: crew.slice(0, 6) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 富详情:一次请求 `/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`,
|
||||
* 标题 / 简介 / 标语按链从 translations(+ 同地区别名)取。不存在抛 TmdbError(notfound)。
|
||||
*/
|
||||
export async function fetchRichDetail(
|
||||
client: Pick<TmdbClient, "getJson">,
|
||||
type: MediaEntityType,
|
||||
id: string | number,
|
||||
languages?: readonly string[],
|
||||
): Promise<TmdbRichDetail> {
|
||||
const chain = chainOf(languages);
|
||||
const j = await client.getJson<Json>(`/${type}/${id}`, {
|
||||
language: chain[0],
|
||||
append_to_response: "alternative_titles,credits,external_ids,translations",
|
||||
});
|
||||
const titleKey = type === "movie" ? "title" : "name";
|
||||
const translations = parseTranslations(j.translations, titleKey);
|
||||
const altTitles = parseAltTitles(j.alternative_titles);
|
||||
const originalTitle = s(type === "movie" ? j.original_title : j.original_name);
|
||||
const originalLanguage = s(j.original_language);
|
||||
const primary = s(j[titleKey]);
|
||||
const titleSrc = { translations, altTitles, originalTitle, originalLanguage, primary };
|
||||
const title = pickTitle(chain, titleSrc);
|
||||
const zh = pickChineseTitle(chain, titleSrc);
|
||||
const overview = pickText(chain, translations, "overview", { value: s(j.overview), language: chain[0] ?? null });
|
||||
const tagline = pickText(chain, translations, "tagline", { value: s(j.tagline), language: chain[0] ?? null });
|
||||
const date = s(type === "movie" ? j.release_date : j.first_air_date);
|
||||
const ext = (j.external_ids ?? {}) as Json;
|
||||
const runtimes = Array.isArray(j.episode_run_time) ? (j.episode_run_time as unknown[]) : [];
|
||||
const { cast, crew } = credits(j.credits, type, j.created_by);
|
||||
return {
|
||||
entityType: type,
|
||||
externalId: String(id),
|
||||
title: title?.value ?? primary ?? originalTitle ?? "",
|
||||
titleLanguage: title?.language ?? null,
|
||||
titleZh: zh?.value ?? null,
|
||||
titleZhLanguage: zh?.language ?? null,
|
||||
originalTitle,
|
||||
originalLanguage,
|
||||
overview: overview?.value ?? null,
|
||||
overviewLanguage: overview?.language ?? null,
|
||||
tagline: tagline?.value ?? null,
|
||||
year: yearOf(date),
|
||||
releaseDate: date,
|
||||
lastAirDate: s(j.last_air_date),
|
||||
genres: ((Array.isArray(j.genres) ? j.genres : []) as Json[]).map((g) => s(g.name)).filter((x): x is string => !!x),
|
||||
voteAverage: n(j.vote_average) !== null ? Math.round(n(j.vote_average)! * 10) / 10 : null,
|
||||
voteCount: int(j.vote_count),
|
||||
status: s(j.status),
|
||||
runtime: type === "movie" ? int(j.runtime) : int(runtimes[0]),
|
||||
seasonCount: type === "tv" ? int(j.number_of_seasons) : null,
|
||||
episodeCount: type === "tv" ? int(j.number_of_episodes) : null,
|
||||
networks: ((Array.isArray(j.networks) ? j.networks : []) as Json[])
|
||||
.map((x) => s(x.name))
|
||||
.filter((x): x is string => !!x),
|
||||
originCountry: Array.isArray(j.origin_country)
|
||||
? (j.origin_country as unknown[]).filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
posterPath: s(j.poster_path),
|
||||
backdropPath: s(j.backdrop_path),
|
||||
cast,
|
||||
crew,
|
||||
externalIds: {
|
||||
imdb: s(ext.imdb_id),
|
||||
tvdb: ext.tvdb_id != null && ext.tvdb_id !== "" ? String(ext.tvdb_id) : null,
|
||||
wikidata: s(ext.wikidata_id),
|
||||
},
|
||||
translations,
|
||||
alternativeTitles: altTitles,
|
||||
seasons: ((Array.isArray(j.seasons) ? j.seasons : []) as Json[]).map((x) => ({
|
||||
seasonNumber: int(x.season_number) ?? 0,
|
||||
episodeCount: int(x.episode_count) ?? 0,
|
||||
name: s(x.name),
|
||||
airDate: s(x.air_date),
|
||||
overview: s(x.overview),
|
||||
posterPath: s(x.poster_path),
|
||||
})),
|
||||
languages: chain,
|
||||
};
|
||||
}
|
||||
|
||||
function episodeOf(e: Json, seasonNumber: number, lang: string): TmdbRichEpisode {
|
||||
const num = int(e.episode_number) ?? 0;
|
||||
const name = s(e.name);
|
||||
const overview = s(e.overview);
|
||||
const named = name && !isPlaceholderEpisodeName(name, num);
|
||||
return {
|
||||
seasonNumber: int(e.season_number) ?? seasonNumber,
|
||||
episodeNumber: num,
|
||||
name: named ? name : null,
|
||||
nameLanguage: named ? lang : null,
|
||||
overview,
|
||||
overviewLanguage: overview ? lang : null,
|
||||
airDate: s(e.air_date),
|
||||
runtime: int(e.runtime),
|
||||
stillPath: s(e.still_path),
|
||||
voteAverage:
|
||||
n(e.vote_average) !== null && (int(e.vote_count) ?? 0) > 0 ? Math.round(n(e.vote_average)! * 10) / 10 : null,
|
||||
voteCount: int(e.vote_count),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 季详情:先按链首请求 `/tv/{id}/season/{n}`;有集缺标题(TMDB 占位名"第 N 集")或缺简介时,
|
||||
* 按链逐个语言补拉同一端点(各自走请求缓存),补齐即停。补不到标题的集保留链首的占位名。
|
||||
*/
|
||||
export async function fetchRichSeason(
|
||||
client: Pick<TmdbClient, "getJson">,
|
||||
tvId: string | number,
|
||||
seasonNumber: number,
|
||||
languages?: readonly string[],
|
||||
opts: { now?: Date } = {},
|
||||
): Promise<TmdbRichSeasonDetail> {
|
||||
const chain = chainOf(languages);
|
||||
const first = chain[0]!;
|
||||
const j = await client.getJson<Json>(`/tv/${tvId}/season/${seasonNumber}`, { language: first });
|
||||
const rawEps = (Array.isArray(j.episodes) ? j.episodes : []) as Json[];
|
||||
const eps = rawEps.map((e) => episodeOf(e, seasonNumber, first));
|
||||
const placeholders = new Map(rawEps.map((e) => [int(e.episode_number) ?? 0, s(e.name)]));
|
||||
let seasonName = s(j.name);
|
||||
let seasonNameLang: string | null = seasonName ? first : null;
|
||||
let seasonOverview = s(j.overview);
|
||||
let seasonOverviewLang: string | null = seasonOverview ? first : null;
|
||||
const fetched = [first];
|
||||
// 只为已播出的集补拉(未播出的集各语言通常都空,补拉只会白白耗请求);季名"第 N 季"各语言都是占位,不触发补拉
|
||||
const today = (opts.now ?? new Date()).toISOString().slice(0, 10);
|
||||
const aired = (e: TmdbRichEpisode) => !!e.airDate && e.airDate <= today;
|
||||
const missing = () => eps.some((e) => aired(e) && (!e.name || !e.overview)) || (!seasonOverview && eps.some(aired));
|
||||
for (const lang of chain.slice(1)) {
|
||||
if (!eps.length || !missing()) break;
|
||||
let k: Json;
|
||||
try {
|
||||
k = await client.getJson<Json>(`/tv/${tvId}/season/${seasonNumber}`, { language: lang });
|
||||
} catch {
|
||||
// 补拉失败不影响首选语言结果
|
||||
break;
|
||||
}
|
||||
fetched.push(lang);
|
||||
const byNum = new Map(
|
||||
((Array.isArray(k.episodes) ? k.episodes : []) as Json[]).map((e) => [int(e.episode_number) ?? 0, e]),
|
||||
);
|
||||
for (const e of eps) {
|
||||
const alt = byNum.get(e.episodeNumber);
|
||||
if (!alt) continue;
|
||||
const other = episodeOf(alt, seasonNumber, lang);
|
||||
if (!e.name && other.name) {
|
||||
e.name = other.name;
|
||||
e.nameLanguage = lang;
|
||||
}
|
||||
if (!e.overview && other.overview) {
|
||||
e.overview = other.overview;
|
||||
e.overviewLanguage = lang;
|
||||
}
|
||||
}
|
||||
if (!seasonOverview && s(k.overview)) {
|
||||
seasonOverview = s(k.overview);
|
||||
seasonOverviewLang = lang;
|
||||
}
|
||||
if (
|
||||
isPlaceholderSeasonName(seasonName, seasonNumber) &&
|
||||
s(k.name) &&
|
||||
!isPlaceholderSeasonName(s(k.name), seasonNumber)
|
||||
) {
|
||||
seasonName = s(k.name);
|
||||
seasonNameLang = lang;
|
||||
}
|
||||
}
|
||||
// 链走完仍没有标题:保留首选语言的占位名(界面显示"第 N 集"比空白好)
|
||||
for (const e of eps) {
|
||||
if (!e.name) {
|
||||
const p = placeholders.get(e.episodeNumber);
|
||||
if (p) {
|
||||
e.name = p;
|
||||
e.nameLanguage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
tvId: String(tvId),
|
||||
seasonNumber: int(j.season_number) ?? seasonNumber,
|
||||
name: seasonName,
|
||||
nameLanguage: seasonNameLang,
|
||||
overview: seasonOverview,
|
||||
overviewLanguage: seasonOverviewLang,
|
||||
airDate: s(j.air_date),
|
||||
posterPath: s(j.poster_path),
|
||||
episodes: eps,
|
||||
fetchedLanguages: fetched,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { RequestLimiter, TtlLruCache } from "./cache.ts";
|
||||
export * from "./client.ts";
|
||||
export * from "./details.ts";
|
||||
export * from "./match.ts";
|
||||
export * from "./translations.ts";
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* TMDB 多语言取值(MD-1):按语言回退链从 translations / alternative_titles 取标题、简介。
|
||||
* 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8)
|
||||
* __update_tmdbinfo_cn_title:本地化名不含中文 → alternative_titles 的 CN 地区中文名 → 新加坡译名(sg_title);
|
||||
* __update_tmdbinfo_extra_title:translations 按地区取 en / hk / tw / sg 译名。
|
||||
* 调整:MP 的"中文名 → CN 别名 → SG"写死在代码里,这里泛化成可配置的语言链(缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US),
|
||||
* 链中每一项先看 translations(语言 + 地区精确匹配),再看同地区、文字相符的 alternative_titles;
|
||||
* TMDB 在某语言没有译名时会把 name 回退成原名,所以不信任请求语言下的 name,只信 translations。
|
||||
* MP 用 zhconv 判断 CN 别名是否简体,这里不引入繁简转换,只要求含汉字。
|
||||
*/
|
||||
|
||||
export const DEFAULT_METADATA_LANGUAGES: readonly string[] = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
|
||||
|
||||
export interface TmdbTranslation {
|
||||
/** BCP47,如 zh-CN;没有地区时只有语言 */
|
||||
lang: string;
|
||||
iso639: string;
|
||||
region: string | null;
|
||||
title: string | null;
|
||||
overview: string | null;
|
||||
tagline: string | null;
|
||||
}
|
||||
|
||||
export interface TmdbAltTitle {
|
||||
title: string;
|
||||
region: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
/** 取到的值与其来源语言;via 说明来自哪里(给元数据来源页和测试看) */
|
||||
export interface PickedText {
|
||||
value: string;
|
||||
language: string | null;
|
||||
via: "translation" | "alternative_title" | "original" | "primary";
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const str = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
||||
|
||||
const HAN = /\p{Script=Han}/u;
|
||||
const KANA = /[\p{Script=Hiragana}\p{Script=Katakana}]/u;
|
||||
const HANGUL = /\p{Script=Hangul}/u;
|
||||
const LATIN = /\p{Script=Latin}/u;
|
||||
|
||||
const containsChinese = (s: string | null | undefined): boolean => !!s && HAN.test(s);
|
||||
|
||||
/** 标题的文字与语言是否相符(用于 alternative_titles 只有地区、没有语言的情况) */
|
||||
export function scriptMatches(lang: string, text: string): boolean {
|
||||
const l = lang.split("-")[0]!.toLowerCase();
|
||||
if (l === "zh") return HAN.test(text) && !KANA.test(text);
|
||||
if (l === "ja") return KANA.test(text) || HAN.test(text);
|
||||
if (l === "ko") return HANGUL.test(text);
|
||||
if (["en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi", "pl", "cs", "tr"].includes(l))
|
||||
return LATIN.test(text) && !HAN.test(text) && !KANA.test(text) && !HANGUL.test(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitTag(tag: string): { lang: string; region: string | null } {
|
||||
const [l, r] = tag.split("-");
|
||||
return { lang: (l ?? "").toLowerCase(), region: r ? r.toUpperCase() : null };
|
||||
}
|
||||
|
||||
/** translations.translations[] → 归一化;type 决定标题键(movie: title,tv / season / episode: name) */
|
||||
export function parseTranslations(raw: unknown, titleKey: "title" | "name"): TmdbTranslation[] {
|
||||
const list = Array.isArray((raw as Json | null)?.translations)
|
||||
? ((raw as Json).translations as Json[])
|
||||
: Array.isArray(raw)
|
||||
? (raw as Json[])
|
||||
: [];
|
||||
const out: TmdbTranslation[] = [];
|
||||
for (const t of list) {
|
||||
const iso639 = str(t?.iso_639_1)?.toLowerCase();
|
||||
if (!iso639) continue;
|
||||
const region = str(t.iso_3166_1)?.toUpperCase() ?? null;
|
||||
const data = (t.data ?? {}) as Json;
|
||||
out.push({
|
||||
lang: region ? `${iso639}-${region}` : iso639,
|
||||
iso639,
|
||||
region,
|
||||
title: str(data[titleKey]) ?? str(data.title) ?? str(data.name),
|
||||
overview: str(data.overview),
|
||||
tagline: str(data.tagline),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** alternative_titles(movie: titles,tv: results)→ 归一化 */
|
||||
export function parseAltTitles(raw: unknown): TmdbAltTitle[] {
|
||||
const r = raw as Json | null;
|
||||
const list = (Array.isArray(r?.titles) ? r.titles : Array.isArray(r?.results) ? r.results : []) as Json[];
|
||||
const out: TmdbAltTitle[] = [];
|
||||
for (const a of list) {
|
||||
const title = str(a?.title);
|
||||
if (!title) continue;
|
||||
out.push({ title, region: str(a.iso_3166_1)?.toUpperCase() ?? null, type: str(a.type) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 语言标签是否匹配某条译名:zh-CN 只匹配 zh/CN;只写语言(zh)时匹配该语言任意地区 */
|
||||
function tagMatches(tag: string, t: Pick<TmdbTranslation, "iso639" | "region">): boolean {
|
||||
const { lang, region } = splitTag(tag);
|
||||
if (t.iso639 !== lang) return false;
|
||||
return region === null || t.region === region;
|
||||
}
|
||||
|
||||
export interface TitleSources {
|
||||
translations: readonly TmdbTranslation[];
|
||||
altTitles?: readonly TmdbAltTitle[];
|
||||
originalTitle?: string | null;
|
||||
originalLanguage?: string | null;
|
||||
/** 请求语言下的 name/title(TMDB 缺译名时是原名);链全部落空时的兜底 */
|
||||
primary?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按语言链取标题:每个语言先查 translations,再查同地区、文字相符的别名(类型为空的优先),
|
||||
* 原始语言与链中语言相同时原名也算该语言的值。全部落空:primary → 原名。
|
||||
*/
|
||||
export function pickTitle(chain: readonly string[], src: TitleSources): PickedText | null {
|
||||
const origLang = src.originalLanguage?.toLowerCase() ?? null;
|
||||
for (const tag of chain) {
|
||||
const tr = src.translations.find((t) => tagMatches(tag, t) && t.title);
|
||||
if (tr?.title) return { value: tr.title, language: tag, via: "translation" };
|
||||
const { lang, region } = splitTag(tag);
|
||||
if (region && src.altTitles?.length) {
|
||||
const alts = src.altTitles
|
||||
.filter((a) => a.region === region && scriptMatches(lang, a.title))
|
||||
.sort((a, b) => (a.type ? 1 : 0) - (b.type ? 1 : 0));
|
||||
if (alts[0]) return { value: alts[0].title, language: tag, via: "alternative_title" };
|
||||
}
|
||||
if (origLang === lang && src.originalTitle) return { value: src.originalTitle, language: tag, via: "original" };
|
||||
}
|
||||
if (src.primary) return { value: src.primary, language: null, via: "primary" };
|
||||
if (src.originalTitle) return { value: src.originalTitle, language: origLang, via: "original" };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 中文名:链里的中文语言按链顺序(没有中文语言时用缺省中文链),取到的值必须含汉字 */
|
||||
export function pickChineseTitle(chain: readonly string[], src: TitleSources): PickedText | null {
|
||||
const zh = chain.filter((t) => splitTag(t).lang === "zh");
|
||||
const zhChain = zh.length ? zh : DEFAULT_METADATA_LANGUAGES.filter((t) => t.startsWith("zh"));
|
||||
const p = pickTitle(zhChain, { ...src, primary: null, originalTitle: src.originalTitle });
|
||||
if (p && containsChinese(p.value)) return p;
|
||||
if (containsChinese(src.primary)) return { value: src.primary!, language: null, via: "primary" };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 简介 / 标语:按链取 translations 的非空值;全部落空时用 primary(请求语言下的原值) */
|
||||
export function pickText(
|
||||
chain: readonly string[],
|
||||
translations: readonly TmdbTranslation[],
|
||||
key: "overview" | "tagline",
|
||||
primary?: { value: string | null; language: string | null },
|
||||
): PickedText | null {
|
||||
for (const tag of chain) {
|
||||
const tr = translations.find((t) => tagMatches(tag, t) && t[key]);
|
||||
if (tr?.[key]) return { value: tr[key]!, language: tag, via: "translation" };
|
||||
}
|
||||
if (primary?.value) return { value: primary.value, language: primary.language, via: "primary" };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TMDB 缺译名时给集生成的占位名("第 3 集"、"Episode 3"、"第 3 話"…),当作没有标题继续回退。
|
||||
*/
|
||||
export function isPlaceholderEpisodeName(name: string | null | undefined, episodeNumber?: number): boolean {
|
||||
if (!name) return true;
|
||||
const s = name.trim();
|
||||
if (!s) return true;
|
||||
const n = episodeNumber !== undefined ? String(episodeNumber) : "\\d+";
|
||||
return new RegExp(
|
||||
`^(第\\s*${n}\\s*[集話话回]|Episode\\s*${n}|Épisode\\s*${n}|Folge\\s*${n}|Episodio\\s*${n}|エピソード\\s*${n}|${n}\\s*화|에피소드\\s*${n})$`,
|
||||
"i",
|
||||
).test(s);
|
||||
}
|
||||
|
||||
/** 季的占位名("第 1 季"、"Season 1"、"特别篇"不算占位) */
|
||||
export function isPlaceholderSeasonName(name: string | null | undefined, seasonNumber?: number): boolean {
|
||||
if (!name) return true;
|
||||
const n = seasonNumber !== undefined ? String(seasonNumber) : "\\d+";
|
||||
return new RegExp(`^(第\\s*${n}\\s*季|Season\\s*${n}|シーズン\\s*${n}|시즌\\s*${n})$`, "i").test(name.trim());
|
||||
}
|
||||
@@ -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,7 +249,8 @@ export const Api = {
|
||||
response: FieldOverrideResult,
|
||||
}),
|
||||
},
|
||||
contentUnits: crud(
|
||||
contentUnits: {
|
||||
...crud(
|
||||
"/content-units",
|
||||
"内容单元",
|
||||
{
|
||||
@@ -251,6 +260,24 @@ export const Api = {
|
||||
},
|
||||
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):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ──
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
|
||||
@@ -38,6 +38,7 @@ const STRING_CANDIDATES = [
|
||||
"ALL",
|
||||
"zh-hans",
|
||||
"movie:157336",
|
||||
"zh-CN",
|
||||
];
|
||||
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
|
||||
|
||||
|
||||
@@ -5,14 +5,39 @@
|
||||
export interface TmdbFixtureEpisode {
|
||||
episode_number: number;
|
||||
air_date: string | null;
|
||||
/** 按语言的集名;请求语言没有时 mock 返回 TMDB 式占位名("第 N 集" / "Episode N") */
|
||||
names: Record<string, string>;
|
||||
/** MD-1:按语言的简介;没有时返回空串 */
|
||||
overviews?: Record<string, string>;
|
||||
runtime?: number | null;
|
||||
still_path?: string | null;
|
||||
vote_average?: number;
|
||||
vote_count?: number;
|
||||
}
|
||||
export interface TmdbFixtureSeason {
|
||||
season_number: number;
|
||||
air_date: string | null;
|
||||
names: Record<string, string>;
|
||||
overviews?: Record<string, string>;
|
||||
poster_path?: string | null;
|
||||
episodes: TmdbFixtureEpisode[];
|
||||
}
|
||||
/** MD-1 富详情字段(credits / 类型 / 评分 / 状态…);缺省不返回 */
|
||||
export interface TmdbFixtureRich {
|
||||
genres?: string[];
|
||||
vote_average?: number;
|
||||
vote_count?: number;
|
||||
status?: string;
|
||||
runtime?: number;
|
||||
episode_run_time?: number[];
|
||||
last_air_date?: string;
|
||||
backdrop_path?: string;
|
||||
networks?: string[];
|
||||
tagline?: Record<string, string>;
|
||||
cast?: { id: number; name: string; character: string; profile_path: string | null }[];
|
||||
crew?: { id: number; name: string; job: string; profile_path: string | null }[];
|
||||
created_by?: { id: number; name: string; profile_path: string | null }[];
|
||||
}
|
||||
export interface TmdbFixture {
|
||||
type: "movie" | "tv";
|
||||
id: number;
|
||||
@@ -25,8 +50,9 @@ export interface TmdbFixture {
|
||||
popularity: number;
|
||||
alternative_titles: { iso_3166_1: string; title: string; type: string }[];
|
||||
external_ids: { imdb_id?: string | null; tvdb_id?: number | null };
|
||||
/** 额外译名(titles 中的语言会自动生成 translations) */
|
||||
extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string }[];
|
||||
/** 额外译名(titles 中的语言会自动生成 translations);title 为空串表示该语言只有简介没有译名(TMDB 常见) */
|
||||
extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string; overview?: string }[];
|
||||
rich?: TmdbFixtureRich;
|
||||
seasons?: TmdbFixtureSeason[];
|
||||
/** 海报路径(拼 image.tmdb.org);缺省 null */
|
||||
poster_path?: string | null;
|
||||
@@ -219,4 +245,86 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
// MD-1 语言回退链:zh-CN 译名为空(只有简介)、CN 地区别名是简中名、zh-TW / zh-HK 有译名(对照 TMDB tv/95396 真实形态)
|
||||
{
|
||||
type: "tv",
|
||||
id: 95396,
|
||||
original_title: "Severance",
|
||||
original_language: "en",
|
||||
origin_country: ["US"],
|
||||
date: "2022-02-17",
|
||||
titles: { "en-US": "Severance", "zh-TW": "人生切割術", "zh-HK": "生活割離術" },
|
||||
overviews: { "en-US": "Mark leads a team of office workers.", "zh-TW": "馬克帶領的辦公團隊成員全都接受手術。" },
|
||||
popularity: 95.1,
|
||||
alternative_titles: [
|
||||
{ iso_3166_1: "CN", title: "人生切割术", type: "" },
|
||||
{ iso_3166_1: "HK", title: "生活割離術", type: "" },
|
||||
],
|
||||
external_ids: { imdb_id: "tt11280740", tvdb_id: 371980 },
|
||||
extra_translations: [
|
||||
{ iso_639_1: "zh", iso_3166_1: "CN", title: "", overview: "故事发生在一家名为卢蒙的超级大公司内。" },
|
||||
{ iso_639_1: "zh", iso_3166_1: "SG", title: "", overview: "马克领导着一个办公室里的团队。" },
|
||||
],
|
||||
poster_path: "/severance.jpg",
|
||||
rich: {
|
||||
genres: ["剧情", "悬疑", "Sci-Fi & Fantasy"],
|
||||
vote_average: 8.395,
|
||||
vote_count: 2400,
|
||||
status: "Returning Series",
|
||||
episode_run_time: [],
|
||||
last_air_date: "2025-03-21",
|
||||
backdrop_path: "/severance-bg.jpg",
|
||||
networks: ["Apple TV"],
|
||||
cast: [
|
||||
{ id: 4495, name: "亚当·斯科特", character: "Mark Scout", profile_path: "/adam.jpg" },
|
||||
{ id: 1319469, name: "布丽特·洛薇尔", character: "Helly Riggs", profile_path: null },
|
||||
],
|
||||
created_by: [{ id: 3442409, name: "丹·埃里克森", profile_path: "/dan.jpg" }],
|
||||
},
|
||||
seasons: [
|
||||
{
|
||||
season_number: 1,
|
||||
air_date: "2022-02-17",
|
||||
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
|
||||
overviews: { "zh-CN": "在卢蒙工业公司,员工们接受了一种名为“分离”的手术。" },
|
||||
poster_path: "/sev-s1.jpg",
|
||||
episodes: [
|
||||
{
|
||||
episode_number: 1,
|
||||
air_date: "2022-02-17",
|
||||
names: { "zh-CN": "有关地狱的好消息", "en-US": "Good News About Hell" },
|
||||
overviews: { "zh-CN": "马克升职成为部门主管。", "en-US": "Mark is promoted." },
|
||||
runtime: 59,
|
||||
still_path: "/sev-101.jpg",
|
||||
vote_average: 8.2,
|
||||
vote_count: 171,
|
||||
},
|
||||
{
|
||||
episode_number: 2,
|
||||
air_date: "2022-02-17",
|
||||
names: { "zh-TW": "半循環", "en-US": "Half Loop" },
|
||||
overviews: { "zh-TW": "馬克帶領新員工參觀。", "en-US": "Mark gives Helly a tour." },
|
||||
runtime: 53,
|
||||
still_path: "/sev-102.jpg",
|
||||
vote_average: 8.0,
|
||||
vote_count: 150,
|
||||
},
|
||||
{
|
||||
episode_number: 3,
|
||||
air_date: "2022-02-25",
|
||||
names: { "en-US": "In Perpetuity" },
|
||||
overviews: {},
|
||||
runtime: 52,
|
||||
still_path: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
season_number: 2,
|
||||
air_date: "2099-01-17",
|
||||
names: { "zh-CN": "第 2 季", "en-US": "Season 2" },
|
||||
episodes: [{ episode_number: 1, air_date: "2099-01-17", names: {} }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -107,17 +107,27 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
|
||||
const f = find("tv", m[1]!);
|
||||
const s = f?.seasons?.find((x) => String(x.season_number) === m[2]);
|
||||
if (!f || !s) return notFound;
|
||||
const lang = q.language ?? "en-US";
|
||||
const zh = lang.startsWith("zh");
|
||||
return {
|
||||
json: {
|
||||
id: f.id * 100 + s.season_number,
|
||||
season_number: s.season_number,
|
||||
name: pick(s.names, q.language, `Season ${s.season_number}`),
|
||||
overview: s.overviews?.[lang] ?? "",
|
||||
poster_path: s.poster_path ?? null,
|
||||
air_date: s.air_date,
|
||||
episodes: s.episodes.map((e) => ({
|
||||
episode_number: e.episode_number,
|
||||
season_number: s.season_number,
|
||||
name: pick(e.names, q.language, ""),
|
||||
// TMDB 缺该语言译名时给占位名
|
||||
name: e.names[lang] ?? (zh ? `第 ${e.episode_number} 集` : `Episode ${e.episode_number}`),
|
||||
overview: e.overviews?.[lang] ?? "",
|
||||
air_date: e.air_date,
|
||||
runtime: e.runtime ?? null,
|
||||
still_path: e.still_path ?? null,
|
||||
vote_average: e.vote_average ?? 0,
|
||||
vote_count: e.vote_count ?? 0,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@@ -130,6 +140,27 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
|
||||
if (!f) return notFound;
|
||||
const append = new Set((q.append_to_response ?? "").split(",").filter(Boolean));
|
||||
const out: Record<string, unknown> = { ...brief(f, q.language), origin_country: f.origin_country };
|
||||
const r = f.rich;
|
||||
if (r) {
|
||||
out.genres = (r.genres ?? []).map((name, i) => ({ id: i + 1, name }));
|
||||
out.vote_average = r.vote_average ?? 0;
|
||||
out.vote_count = r.vote_count ?? 0;
|
||||
out.status = r.status ?? null;
|
||||
out.backdrop_path = r.backdrop_path ?? null;
|
||||
out.tagline = r.tagline?.[q.language ?? "en-US"] ?? "";
|
||||
if (f.type === "movie") out.runtime = r.runtime ?? null;
|
||||
else {
|
||||
out.episode_run_time = r.episode_run_time ?? [];
|
||||
out.last_air_date = r.last_air_date ?? null;
|
||||
out.networks = (r.networks ?? []).map((name, i) => ({ id: i + 1, name }));
|
||||
out.created_by = r.created_by ?? [];
|
||||
out.number_of_episodes = (f.seasons ?? [])
|
||||
.filter((x) => x.season_number > 0)
|
||||
.reduce((a, x) => a + x.episodes.length, 0);
|
||||
}
|
||||
if (append.has("credits"))
|
||||
out.credits = { cast: (r.cast ?? []).map((c, order) => ({ ...c, order })), crew: r.crew ?? [] };
|
||||
}
|
||||
if (f.type === "tv") {
|
||||
out.number_of_seasons = f.seasons?.filter((s) => s.season_number > 0).length ?? 0;
|
||||
out.seasons = (f.seasons ?? []).map((s) => ({
|
||||
@@ -137,6 +168,8 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
|
||||
episode_count: s.episodes.length,
|
||||
name: pick(s.names, q.language, ""),
|
||||
air_date: s.air_date,
|
||||
overview: s.overviews?.[q.language ?? "en-US"] ?? "",
|
||||
poster_path: s.poster_path ?? null,
|
||||
}));
|
||||
}
|
||||
if (append.has("alternative_titles")) {
|
||||
@@ -150,12 +183,16 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
|
||||
translations: [
|
||||
...Object.entries(f.titles).map(([lang, t]) => {
|
||||
const [l, r] = lang.split("-");
|
||||
return { iso_639_1: l, iso_3166_1: r ?? "", data: { [key]: t } };
|
||||
return {
|
||||
iso_639_1: l,
|
||||
iso_3166_1: r ?? "",
|
||||
data: { [key]: t, overview: f.overviews?.[lang] ?? "" },
|
||||
};
|
||||
}),
|
||||
...(f.extra_translations ?? []).map((x) => ({
|
||||
iso_639_1: x.iso_639_1,
|
||||
iso_3166_1: x.iso_3166_1,
|
||||
data: { [key]: x.title },
|
||||
data: { [key]: x.title, overview: x.overview ?? "" },
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user