Merge branch 'md1'

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

No files matched your search

+79 -2
View File
@@ -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",
+27
View File
@@ -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);
});
});