feat(contracts,server,web): files G6–G8 fields and identify S-7 contract

- G6: FileRoot.pendingCount/pendingPartial for download roots (same rule as the
  unidentified filter, recursive, cached 5 min, invalidated by files.changed)
- G7: FileEntry.parsedHint from parseReleasePath (file + parent dirs); the web
  subline uses it and no longer parses names itself
- G8: FileIdentifyCandidate.posterUrl (TMDB poster_path via adapter and
  catalog.lookup, local entities from metadata.posterPath); thumbnails in the
  identify panel
- S-7 contract: FilesIdentifyResult.remoteTaskId/remotePending,
  MediaServerInventory.partial, EventType identify.completed with
  IdentifyCompletedPayload; identify panel (useIdentifyLive) and organize wizard
  merge remote results by root + path, buffering events that beat the response

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
1 parent fd2f7bc33d
commit abc69886ea
26 files changed
+510 -134

No files matched your search

+3
View File
@@ -57,6 +57,8 @@ export interface MetadataCandidate {
originCountry: string[];
popularity: number | null;
overview: string | null;
/** TMDB poster_path(如 `/abc.jpg`),拼图片 CDN 用;没有为 null */
posterPath: string | null;
seasons?: CandidateSeason[];
externalIds?: { imdb?: string; tvdb?: string };
/** 是否已拉过详情(别名/季信息只在详情中完整) */
@@ -266,6 +268,7 @@ export class TmdbClient {
originCountry: Array.isArray(r.origin_country) ? (r.origin_country as string[]) : [],
popularity: n(r.popularity),
overview: s(r.overview),
posterPath: s(r.poster_path),
detailed: false,
};
}
+7 -1
View File
@@ -34,7 +34,13 @@ describe("TmdbClient 搜索与详情", () => {
const { tmdb } = await setup();
const r = await tmdb.search("奥本海默", { type: "movie" });
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({ provider: "tmdb", entityType: "movie", externalId: "872585", year: 2023 });
expect(r[0]).toMatchObject({
provider: "tmdb",
entityType: "movie",
externalId: "872585",
year: 2023,
posterPath: "/oppenheimer.jpg",
});
expect(r[0]!.title).toBe("奥本海默");
expect(r[0]!.titles.map((t) => `${t.kind}:${t.lang}:${t.text}`)).toEqual([
"localized:zh-CN:奥本海默",
@@ -51,6 +51,8 @@ export const CatalogLookupOutput = z.object({
externalId: z.string(),
title: z.string(),
year: z.number().int().nullable(),
/** TMDB poster_path;文件管理识别候选拼海报缩略图 */
posterPath: z.string().nullable().optional(),
}),
),
});
+4
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { AgentRunStatus, DecisionKind, DecisionStatus, Severity } from "./agent.ts";
import { Id, NonNegInt, Timestamp } from "./common.ts";
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution.ts";
import { IdentifyCompletedPayload } from "./files.ts";
import { EvidenceLevel } from "./identity.ts";
import { OrganizationStatus } from "./organization.ts";
import { FeedDuePayload, FeedGapSuspectedPayload, FeedHealth, ReleaseObservedPayload } from "./sources.ts";
@@ -56,6 +57,8 @@ export const EventType = {
decisionCreated: "decision.created",
decisionUpdated: "decision.updated",
identifyCompleted: "identify.completed",
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
@@ -191,6 +194,7 @@ export const EVENT_PAYLOADS = {
"release.resolved": ReleaseResolvedPayload,
"decision.created": DecisionCreatedPayload,
"decision.updated": DecisionUpdatedPayload,
"identify.completed": IdentifyCompletedPayload,
} as const satisfies Record<EventType, z.ZodType>;
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
+29 -2
View File
@@ -21,7 +21,7 @@ export const FileRelPath = z
.string()
.max(4096)
.refine((p) => !p.startsWith("/") && !p.includes("\0") && !p.split("/").some((s) => s === ".." || s === "."), {
message: "路径必须是根内相对路径,不能含 .. 或 .",
message: "路径必须是根内相对路径,不能含 .. 或 .",
})
.default("");
export type FileRelPath = z.infer<typeof FileRelPath>;
@@ -58,6 +58,10 @@ export const FileRoot = z.object({
/** 健康探测失败原因(离线时) */
healthReason: z.string().nullable(),
usage: z.object({ totalBytes: NonNegInt, freeBytes: NonNegInt }).nullable(),
/** G6:下载目录下待识别视频数(与列表"待识别"筛选同口径,递归,缓存 5 分钟);库根与离线根为 null */
pendingCount: NonNegInt.nullable(),
/** G6:递归超过扫描上限时为 true,pendingCount 是下限 */
pendingPartial: z.boolean(),
});
export type FileRoot = z.infer<typeof FileRoot>;
export const FileRootList = z.object({ items: z.array(FileRoot) });
@@ -124,6 +128,8 @@ export const FileEntry = z.object({
media: FileMediaSummary.nullable(),
/** 仅目录且在当前页:{ videoCount, registeredCount, partial } */
dirSummary: z.object({ videoCount: NonNegInt, registeredCount: NonNegInt, partial: z.boolean() }).nullable(),
/** G7:待识别视频的轻量解析(文件名 + 父/祖父目录,纯字符串,不查库),如 `The Last of Us · S01E01 · 1080p`;其它行为 null */
parsedHint: z.string().nullable(),
});
export type FileEntry = z.infer<typeof FileEntry>;
@@ -618,6 +624,8 @@ export const FileIdentifyCandidate = z.object({
level: z.enum(["high", "medium", "low"]),
score: z.number(),
reasons: z.array(z.string()),
/** G8:海报缩略图 URL(TMDB w154);没有海报为 null */
posterUrl: z.string().url().nullable(),
});
export type FileIdentifyCandidate = z.infer<typeof FileIdentifyCandidate>;
@@ -650,9 +658,28 @@ export const FilesIdentifyResult = z.object({
truncated: z.boolean(),
/** W9-H(G2):因低于最小体积被跳过的项数 */
excludedBySize: NonNegInt.optional(),
/**
* S-7:远端(TMDB)查询任务 id(= 引擎 runId)。缺省请求只做本地召回并入队远端任务,查完发 SSE
* `identify.completed`(同 taskId),前端按 root + path 替换条目;null = 无需远端或 `?wait=true` 已同步查完
*/
remoteTaskId: z.string().nullable().optional(),
/** S-7:待远端查询的去重标题数(0 = 无) */
remotePending: NonNegInt.optional(),
});
export type FilesIdentifyResult = z.infer<typeof FilesIdentifyResult>;
/** SSE `identify.completed`:远端识别任务结束(失败也发,status=failed、items 为空) */
export const IdentifyCompletedPayload = z.object({
taskId: z.string(),
status: z.enum(["succeeded", "failed"]),
/** 完整识别结果(本地 + 远端),按 root + path 替换 HTTP 响应里的同名项 */
items: z.array(FileIdentifyItem),
truncated: z.boolean(),
excludedBySize: NonNegInt,
error: z.string().nullable(),
});
export type IdentifyCompletedPayload = z.infer<typeof IdentifyCompletedPayload>;
// 刮削(仅已登记作品)
export const FilesScrapeRequest = z.object({
@@ -699,7 +726,7 @@ export type FilesProbeResult = z.infer<typeof FilesProbeResult>;
export const FilesDownloadQuery = z.object({ root: FileRootId, path: FileRelPath, inline: boolQuery.default(false) });
export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
/** OpenAPI 描述用:实际响应为二进制流 */
export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)");
export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)");
// 整理记录回查(W5-A2,只读):整理页 `/console/organize?ids=` 预填;planIds 为 /files/move|copy|organize 的 planId
+2
View File
@@ -450,6 +450,8 @@ export const MediaServerInventory = z.object({
observedAt: Timestamp,
}),
),
/** 至少一个实例因查询失败 / 超时为 unknown(没有外部 ID 不算);旧服务端不返回 */
partial: z.boolean().optional(),
});
export type MediaServerInventory = z.infer<typeof MediaServerInventory>;
+3
View File
@@ -28,6 +28,8 @@ export interface TmdbFixture {
/** 额外译名(titles 中的语言会自动生成 translations) */
extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string }[];
seasons?: TmdbFixtureSeason[];
/** 海报路径(拼 image.tmdb.org);缺省 null */
poster_path?: string | null;
}
function eps(n: number, start: string, zh: (i: number) => string, en: (i: number) => string): TmdbFixtureEpisode[] {
@@ -55,6 +57,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
{ iso_3166_1: "CN", title: "奥本海默传", type: "" },
],
external_ids: { imdb_id: "tt15398776" },
poster_path: "/oppenheimer.jpg",
extra_translations: [{ iso_639_1: "ja", iso_3166_1: "JP", title: "オッペンハイマー" }],
},
{
+2
View File
@@ -41,6 +41,7 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
original_language: f.original_language,
overview,
popularity: f.popularity,
poster_path: f.poster_path ?? null,
}
: {
id: f.id,
@@ -51,6 +52,7 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
origin_country: f.origin_country,
overview,
popularity: f.popularity,
poster_path: f.poster_path ?? null,
};
};