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:
1 parent
fd2f7bc33d
commit
abc69886ea
26 files changed
+510
-134
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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]>;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -450,6 +450,8 @@ export const MediaServerInventory = z.object({
|
||||
observedAt: Timestamp,
|
||||
}),
|
||||
),
|
||||
/** 至少一个实例因查询失败 / 超时为 unknown(没有外部 ID 不算);旧服务端不返回 */
|
||||
partial: z.boolean().optional(),
|
||||
});
|
||||
export type MediaServerInventory = z.infer<typeof MediaServerInventory>;
|
||||
|
||||
|
||||
@@ -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: "オッペンハイマー" }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user