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
@@ -510,9 +510,9 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
observedAt: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
// partial:至少一个实例因查询失败/超时为 unknown(no_external_id 不算失败)。契约未列此字段,前端可按需读取
|
||||
// partial:至少一个实例因查询失败/超时为 unknown(no_external_id 不算失败)
|
||||
const partial = instances.some((x) => x.status === "unknown" && x.reason !== "no_external_id");
|
||||
return { mediaEntityId: e!.id, instances, partial } as MediaServerInventory;
|
||||
return { mediaEntityId: e!.id, instances, partial } satisfies MediaServerInventory;
|
||||
},
|
||||
{ tag: TAG },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { parseReleasePath } from "@umt/domain";
|
||||
|
||||
// G7:列表副行的轻量解析(纯字符串,不查库)。用文件名 + 父/祖父目录作上下文,与识别同一个解析器,
|
||||
// 输出 `标题 (年) · S01E02 · 1080p`;剧集不带年份,解析不出标题时为 null。
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export function parsedHintOf(rel: string): string | null {
|
||||
const parts = rel.split("/");
|
||||
const file = parts.pop();
|
||||
if (!file) return null;
|
||||
const parent = parts.pop();
|
||||
const grandParent = parts.pop();
|
||||
let p: ReturnType<typeof parseReleasePath>;
|
||||
try {
|
||||
p = parseReleasePath({ file, ...(parent ? { parent } : {}), ...(grandParent ? { grandParent } : {}) });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const title = p.title?.trim();
|
||||
if (!title) return null;
|
||||
const season = p.season ?? (p.episodes ? 1 : undefined);
|
||||
const out: string[] = [];
|
||||
let unit: string | null = null;
|
||||
if (season !== undefined) {
|
||||
const eps = p.episodes;
|
||||
unit = eps
|
||||
? `S${pad(season)}E${pad(eps.start)}${eps.end > eps.start ? `-E${pad(eps.end)}` : ""}`
|
||||
: `S${pad(season)}`;
|
||||
}
|
||||
out.push(!unit && p.year ? `${title} (${p.year})` : title);
|
||||
if (unit) out.push(unit);
|
||||
if (p.resolution) out.push(p.resolution);
|
||||
return out.join(" · ");
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
FileIdentifyCandidate,
|
||||
FileIdentifyItem,
|
||||
FilesIdentifyRequest,
|
||||
FilesIdentifyResult,
|
||||
OrganizationStatus,
|
||||
import {
|
||||
EventType,
|
||||
type FileIdentifyCandidate,
|
||||
type FileIdentifyItem,
|
||||
type FilesIdentifyRequest,
|
||||
type FilesIdentifyResult,
|
||||
type IdentifyCompletedPayload,
|
||||
type OrganizationStatus,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { type IdentityCandidate, type ParsedRelease, parseReleasePath, queryTitles, rankCandidates } from "@umt/domain";
|
||||
@@ -23,24 +25,16 @@ import { kindOfName } from "./list.ts";
|
||||
// 任务在 worker 里跑完整识别(本地 + 远端),完成后发 SSE `identify.completed`(见 IdentifyCompletedPayload)。
|
||||
// `?wait=true` 保留同步模式:请求内查远端,整体上限 IDENTIFY_WAIT_MAX_MS。
|
||||
|
||||
/** 远端结果推送事件(尚未进 contracts EventType;前端按 taskId 匹配) */
|
||||
export const IDENTIFY_COMPLETED_EVENT = "identify.completed";
|
||||
/** 远端结果推送事件(contracts EventType.identifyCompleted;前端按 taskId 匹配) */
|
||||
export const IDENTIFY_COMPLETED_EVENT = EventType.identifyCompleted;
|
||||
/** 持久任务名 */
|
||||
export const IDENTIFY_REMOTE_TASK = "identifyRemote";
|
||||
/** 同步模式(wait=true)整个请求的远端查询上限 */
|
||||
export const IDENTIFY_WAIT_MAX_MS = 15_000;
|
||||
|
||||
export interface IdentifyCompletedPayload {
|
||||
taskId: string;
|
||||
status: "succeeded" | "failed";
|
||||
/** 完整识别结果(本地 + 远端),按 root + path 替换 HTTP 响应里的同名项 */
|
||||
items: FileIdentifyItem[];
|
||||
truncated: boolean;
|
||||
excludedBySize: number;
|
||||
error: string | null;
|
||||
}
|
||||
export type { IdentifyCompletedPayload };
|
||||
|
||||
/** HTTP 响应 = 契约 FilesIdentifyResult + 两个待入契约的字段 */
|
||||
/** HTTP 响应 = 契约 FilesIdentifyResult,remoteTaskId / remotePending 必给 */
|
||||
export type FilesIdentifyResponse = FilesIdentifyResult & {
|
||||
/** 远端查询任务 id(引擎 runId);null = 无需远端或同步模式已查完 */
|
||||
remoteTaskId: string | null;
|
||||
@@ -146,11 +140,11 @@ export async function identify(
|
||||
} catch (e) {
|
||||
for (const it of r.result.items)
|
||||
if (r.pendingPaths.has(`${it.root}\u0000${it.path}`))
|
||||
it.warnings.push(`远端查询入队失败,只查了本地:${errMessage(e).slice(0, 200)}`);
|
||||
it.warnings.push(`远端查询入队失败,只查了本地:${errMessage(e).slice(0, 200)}`);
|
||||
return { ...r.result, remoteTaskId: null, remotePending: 0 };
|
||||
}
|
||||
for (const it of r.result.items)
|
||||
if (r.pendingPaths.has(`${it.root}\u0000${it.path}`)) it.warnings.push("远端候选在后台查询,查完自动补上");
|
||||
if (r.pendingPaths.has(`${it.root}\u0000${it.path}`)) it.warnings.push("远端候选在后台查询,查完自动补上");
|
||||
return { ...r.result, remoteTaskId: taskId, remotePending: r.remotePending };
|
||||
}
|
||||
|
||||
@@ -285,7 +279,7 @@ async function identifyCore(
|
||||
if (remoteQueries >= REMOTE_QUERY_MAX) {
|
||||
res = {
|
||||
list: [],
|
||||
warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,此项只查了本地。缩小范围后重新识别`,
|
||||
warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,此项只查了本地。缩小范围后重新识别`,
|
||||
};
|
||||
remoteCache.set(key, res);
|
||||
return res;
|
||||
@@ -298,7 +292,7 @@ async function identifyCore(
|
||||
return res;
|
||||
}
|
||||
if (opts.signal?.aborted) {
|
||||
res = { list: [], warning: `远端查询超时(${IDENTIFY_WAIT_MAX_MS / 1000}s),此项只查了本地` };
|
||||
res = { list: [], warning: `远端查询超时(${IDENTIFY_WAIT_MAX_MS / 1000}s),此项只查了本地` };
|
||||
remoteCache.set(key, res);
|
||||
return res;
|
||||
}
|
||||
@@ -341,7 +335,7 @@ async function identifyCore(
|
||||
}));
|
||||
res = { list, warning: null };
|
||||
} catch (e) {
|
||||
res = { list: [], warning: `远端查询失败,只查了本地:${errMessage(e).slice(0, 200)}` };
|
||||
res = { list: [], warning: `远端查询失败,只查了本地:${errMessage(e).slice(0, 200)}` };
|
||||
}
|
||||
remoteCache.set(key, res);
|
||||
return res;
|
||||
@@ -374,7 +368,7 @@ async function identifyCore(
|
||||
candidates: [],
|
||||
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
|
||||
existing: { assetId: null },
|
||||
warnings: [`低于最小体积 ${minSize} 字节,已跳过`],
|
||||
warnings: [`低于最小体积 ${minSize} 字节,已跳过`],
|
||||
size,
|
||||
skipped: "below_min_size",
|
||||
records,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from "@umt/db";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { actorOfP, FilesError, type FmDeps, hashJson, jsonb } from "./common.ts";
|
||||
import { invalidatePending } from "./list.ts";
|
||||
|
||||
// 计划冻结(artifact kind=plan, title=files_plan)、幂等键(artifact title=files_idem)、审计与 SSE。
|
||||
|
||||
@@ -44,12 +45,12 @@ export async function loadPlan<C>(d: FmDeps, planId: string, op: string, request
|
||||
.select(["id", "content", "content_hash", "expires_at", "title", "kind"])
|
||||
.where("id", "=", planId)
|
||||
.executeTakeFirst();
|
||||
if (row?.kind !== "plan" || row.title !== "files_plan") throw new FilesError("plan_stale", "planId 不存在,重新预览");
|
||||
if (row?.kind !== "plan" || row.title !== "files_plan") throw new FilesError("plan_stale", "planId 不存在,重新预览");
|
||||
const content = row.content as StoredPlan<C>["content"];
|
||||
if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`);
|
||||
if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`);
|
||||
if (!row.expires_at || row.expires_at.getTime() <= d.ao.ports.now().getTime())
|
||||
throw new FilesError("plan_stale", "计划已过期,重新预览");
|
||||
if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求与预览时不一致,重新预览");
|
||||
throw new FilesError("plan_stale", "计划已过期,重新预览");
|
||||
if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求与预览时不一致,重新预览");
|
||||
return { id: row.id, content, contentHash: row.content_hash, expiresAt: row.expires_at };
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ export async function idempotent<T>(
|
||||
if (!row) return undefined;
|
||||
const c = row.content as { reqHash: string; state: string; result?: T };
|
||||
if (c.reqHash !== reqHash) throw new FilesError("exists", "idempotencyKey 已用于不同的请求");
|
||||
if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试");
|
||||
if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试");
|
||||
return c.result as T;
|
||||
};
|
||||
const prev = await replay();
|
||||
@@ -115,7 +116,7 @@ export async function idempotent<T>(
|
||||
if (!id) {
|
||||
const again = await replay();
|
||||
if (again !== undefined) return again;
|
||||
throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试");
|
||||
throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试");
|
||||
}
|
||||
try {
|
||||
const result = await fn();
|
||||
@@ -154,5 +155,6 @@ export async function audit(
|
||||
|
||||
/** SSE `files.changed`:受影响的根与目录(相对路径),前端据此刷新 */
|
||||
export function filesChanged(d: FmDeps, rootId: string, dirs: string[]): void {
|
||||
invalidatePending(rootId);
|
||||
void d.ctx.events.publish("files.changed", { rootId, dirs: [...new Set(dirs)] });
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
resolveIn,
|
||||
within,
|
||||
} from "./common.ts";
|
||||
import { parsedHintOf } from "./hint.ts";
|
||||
|
||||
// 读接口:根列表、列目录(流式读取 + 上限截断 + 服务端排序/过滤/分页)、单项详情;行内业务状态批量关联。
|
||||
|
||||
@@ -58,12 +59,59 @@ export async function listRoots(d: FmDeps): Promise<{ items: FileRoot[] }> {
|
||||
checkedAt: h.checkedAt.toISOString(),
|
||||
healthReason: h.reason,
|
||||
usage,
|
||||
...(def.kind === "download" && h.real && h.health !== "offline"
|
||||
? await pendingOf(d, def.id, h.real)
|
||||
: { pendingCount: null, pendingPartial: false }),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
// ── G6:下载目录待识别计数(缓存 5 分钟;文件变更事件使对应根失效) ──
|
||||
|
||||
const PENDING_TTL_MS = 5 * 60_000;
|
||||
const pendingCache = new Map<string, { at: number; count: number; partial: boolean }>();
|
||||
|
||||
export function invalidatePending(rootId: string): void {
|
||||
pendingCache.delete(rootId);
|
||||
}
|
||||
|
||||
async function pendingOf(
|
||||
d: FmDeps,
|
||||
rootId: string,
|
||||
rootReal: string,
|
||||
): Promise<{ pendingCount: number; pendingPartial: boolean }> {
|
||||
const hit = pendingCache.get(rootId);
|
||||
if (hit && Date.now() - hit.at < PENDING_TTL_MS) return { pendingCount: hit.count, pendingPartial: hit.partial };
|
||||
const r = await countPending(d, rootReal);
|
||||
pendingCache.set(rootId, { at: Date.now(), ...r });
|
||||
return { pendingCount: r.count, pendingPartial: r.partial };
|
||||
}
|
||||
|
||||
/** 与列表 status=unidentified 同口径:视频、非临时名、不在纳管下载任务内容内;递归上限 listMaxEntries 项 */
|
||||
async function countPending(d: FmDeps, rootReal: string): Promise<{ count: number; partial: boolean }> {
|
||||
const dls = await managedDownloads(d).catch(() => [] as DownloadRef[]);
|
||||
let seen = 0;
|
||||
let count = 0;
|
||||
let partial = false;
|
||||
const stack = [rootReal];
|
||||
while (stack.length > 0 && !partial) {
|
||||
const dir = stack.pop()!;
|
||||
for (const n of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
||||
if (++seen > d.options.listMaxEntries) {
|
||||
partial = true;
|
||||
break;
|
||||
}
|
||||
if (n.name.startsWith(".") || isTempName(n.name)) continue;
|
||||
const abs = join(dir, n.name);
|
||||
if (n.isDirectory()) stack.push(abs);
|
||||
else if (n.isFile() && kindOfName(n.name) === "video" && downloadsFor(dls, abs).length === 0) count++;
|
||||
}
|
||||
}
|
||||
return { count, partial };
|
||||
}
|
||||
|
||||
/** 进程对目录有 W_OK(access 语义) */
|
||||
async function canWrite(p: string): Promise<boolean> {
|
||||
return access(p, constants.W_OK).then(
|
||||
@@ -331,6 +379,7 @@ export async function annotate(
|
||||
download: dl ? { taskId: dl.taskId, status: dl.status, progress: dl.progress, hnr: dl.hnr } : null,
|
||||
media: a?.media ?? null,
|
||||
dirSummary: null,
|
||||
parsedHint: status === "unidentified" ? parsedHintOf(r.rel) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -388,7 +437,7 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListRe
|
||||
const r = await resolveIn(d, q.root, q.path);
|
||||
if (r.isSymlink || !r.lst?.isDirectory()) {
|
||||
const st = await stat(r.abs).catch(() => null);
|
||||
if (!st?.isDirectory()) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
|
||||
if (!st?.isDirectory()) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
|
||||
}
|
||||
const dirReal = await realpath(r.abs);
|
||||
const dirMtime = Number((await stat(dirReal)).mtimeMs);
|
||||
@@ -409,8 +458,8 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListRe
|
||||
names.push(ent.name);
|
||||
}
|
||||
} catch (e) {
|
||||
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取目录失败(${errnoOf(e)}),根可能已离线`);
|
||||
throw new FilesError("bad_request", `读取目录失败: ${errnoOf(e) ?? String(e)}`);
|
||||
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取目录失败(${errnoOf(e)}),根可能已离线`);
|
||||
throw new FilesError("bad_request", `读取目录失败:${errnoOf(e) ?? String(e)}`);
|
||||
}
|
||||
|
||||
let pool = names;
|
||||
@@ -548,7 +597,7 @@ export async function bindingsAt(d: FmDeps, def: RootDef, rel: string, prefix =
|
||||
export async function statEntry(d: FmDeps, root: string, path: string): Promise<FileStat> {
|
||||
const r = await resolveIn(d, root, path);
|
||||
const raw = await rawOf(r.abs, r.rel, r.rootReal);
|
||||
if (!raw) throw new FilesError("not_found", `不存在: ${path}`);
|
||||
if (!raw) throw new FilesError("not_found", `不存在:${path}`);
|
||||
const [entry] = await annotate(d, r.def, [raw]);
|
||||
if (entry!.type === "dir") await fillDirSummaries(d, r.def, r.rootReal, [entry!]);
|
||||
const st = r.lst!;
|
||||
|
||||
@@ -54,6 +54,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
|
||||
externalId: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
posterPath?: string | null;
|
||||
}[] = [];
|
||||
const tmdb = await rt().tmdb();
|
||||
if (input.includeRemote && input.query && tmdb) {
|
||||
@@ -66,6 +67,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
|
||||
externalId: m.externalId,
|
||||
title: m.title,
|
||||
year: m.year,
|
||||
posterPath: m.posterPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { parsedHintOf } from "../../src/modules/file-manager/hint.ts";
|
||||
import { posterUrlOf } from "../../src/modules/file-manager/identify.ts";
|
||||
import { createFmHarness, type FmHarness, pgAvailable, READ_KEY, seedDownload } from "./harness.ts";
|
||||
|
||||
// C1-C 契约收尾:G6 下载目录待识别计数 / G7 列表轻量解析 parsedHint / G8 候选海报 posterUrl。
|
||||
|
||||
describe("G7 parsedHintOf / G8 posterUrlOf(纯函数)", () => {
|
||||
it("剧集:标题 · 季集 · 分辨率;父目录补标题", () => {
|
||||
expect(parsedHintOf("The.Last.of.Us.S01E02.1080p.WEB-DL.mkv")).toBe("The Last of Us · S01E02 · 1080p");
|
||||
expect(parsedHintOf("The.Bear.S01.1080p/The.Bear.S01E01.1080p.WEB-DL-GRP.mkv")).toMatch(
|
||||
/^The Bear · S01E01 · 1080p$/,
|
||||
);
|
||||
});
|
||||
it("电影带年份;解析不出标题为 null", () => {
|
||||
expect(parsedHintOf("Oppenheimer.2023.2160p.BluRay.x265.mkv")).toBe("Oppenheimer (2023) · 2160p");
|
||||
expect(parsedHintOf("")).toBeNull();
|
||||
});
|
||||
it("posterUrl:相对 poster_path 拼 w154,完整 URL 原样,空为 null", () => {
|
||||
expect(posterUrlOf("/abc.jpg")).toBe("https://image.tmdb.org/t/p/w154/abc.jpg");
|
||||
expect(posterUrlOf("https://img.example/x.jpg")).toBe("https://img.example/x.jpg");
|
||||
expect(posterUrlOf(null)).toBeNull();
|
||||
expect(posterUrlOf("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应 JSON
|
||||
type Loose = any;
|
||||
|
||||
describe.skipIf(skip)("files G6/G7(列表与根)", () => {
|
||||
let h: FmHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createFmHarness("g678");
|
||||
await writeFile(join(h.dl, "The.Last.of.Us.S01E02.1080p.WEB-DL.mkv"), "v");
|
||||
await writeFile(join(h.dl, "notes.txt"), "t");
|
||||
await writeFile(join(h.dl, "Half.Done.2024.1080p.mkv.!qB"), "p");
|
||||
await mkdir(join(h.dl, "Show.S02"), { recursive: true });
|
||||
await writeFile(join(h.dl, "Show.S02", "Show.S02E01.720p.mkv"), "v");
|
||||
// 纳管下载任务内容(做种)不算待识别
|
||||
await mkdir(join(h.dl, "Seeding.Film.2020.1080p"), { recursive: true });
|
||||
await writeFile(join(h.dl, "Seeding.Film.2020.1080p", "Seeding.Film.2020.1080p.mkv"), "v");
|
||||
await seedDownload(h.db, h, { name: "Seeding.Film.2020.1080p", status: "completed" });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("G6:下载目录带 pendingCount(递归、排除临时与做种),库根为 null", async () => {
|
||||
const r = await h.api("GET", "/files/roots", undefined, READ_KEY);
|
||||
expect(r.status).toBe(200);
|
||||
const dl = r.body.data.items.find((x: Loose) => x.id === h.dlRoot);
|
||||
const lib = r.body.data.items.find((x: Loose) => x.id === h.libRoot);
|
||||
expect(dl).toMatchObject({ pendingCount: 2, pendingPartial: false });
|
||||
expect(lib).toMatchObject({ pendingCount: null, pendingPartial: false });
|
||||
});
|
||||
|
||||
it("G7:待识别视频行带 parsedHint,其它行为 null", async () => {
|
||||
const r = await h.api("GET", `/files?root=${encodeURIComponent(h.dlRoot)}`, undefined, READ_KEY);
|
||||
expect(r.status).toBe(200);
|
||||
const by = (n: string) => r.body.data.entries.find((x: Loose) => x.name === n);
|
||||
expect(by("The.Last.of.Us.S01E02.1080p.WEB-DL.mkv")).toMatchObject({
|
||||
status: "unidentified",
|
||||
parsedHint: "The Last of Us · S01E02 · 1080p",
|
||||
});
|
||||
expect(by("notes.txt").parsedHint).toBeNull();
|
||||
expect(by("Show.S02").parsedHint).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ export type {
|
||||
FileRef,
|
||||
FileRoot,
|
||||
FileStatus,
|
||||
FilesIdentifyResult,
|
||||
FilesOnExisting,
|
||||
FilesOrganizeGroup,
|
||||
FilesOrganizeItem,
|
||||
@@ -64,6 +65,7 @@ export type {
|
||||
FilesSuggestNameResult,
|
||||
FilesTransferResult,
|
||||
FilesTreeResult,
|
||||
IdentifyCompletedPayload,
|
||||
NamingTemplateSource,
|
||||
TmdbCandidate,
|
||||
TrashItem,
|
||||
|
||||
@@ -123,7 +123,7 @@ function RootNode({
|
||||
if (!open.has(k)) toggle(k);
|
||||
}}
|
||||
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
|
||||
title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
|
||||
title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -146,6 +146,12 @@ function RootNode({
|
||||
正常
|
||||
</span>
|
||||
)}
|
||||
{root.pendingCount ? (
|
||||
<span className="shrink-0 text-warning">
|
||||
待识别 {root.pendingCount}
|
||||
{root.pendingPartial ? "+" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
|
||||
{!root.writable && root.kind === "library" ? <span>只读</span> : null}
|
||||
</span>
|
||||
@@ -258,7 +264,7 @@ function Children({
|
||||
})}
|
||||
{q.data?.truncated ? (
|
||||
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||
子目录过多,只列出一部分
|
||||
子目录过多,只列出一部分
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LoaderCircle, Sparkles, Wand2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { type FileEntry, type FileRoot, filesErrorText, useIdentify } from "~/api/files.ts";
|
||||
import { type FileEntry, type FileRoot, filesErrorText } from "~/api/files.ts";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
@@ -10,6 +10,7 @@ import { candidateEntity, LEVEL, reasonsText } from "~/components/organize-wizar
|
||||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { contentUnitLabel } from "./model.ts";
|
||||
import { useIdentifyLive } from "./use-identify-live.ts";
|
||||
|
||||
const KIND: Record<string, string> = { movie: "电影", series: "剧集", variety: "综艺" };
|
||||
|
||||
@@ -28,7 +29,7 @@ export function IdentifyPanel({
|
||||
autoRun?: boolean;
|
||||
onOrganize?: (preset: WizardEntity | null) => void;
|
||||
}) {
|
||||
const identify = useIdentify();
|
||||
const identify = useIdentifyLive();
|
||||
const mutate = identify.mutate;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 换文件或 autoRun 时重跑一次
|
||||
useEffect(() => {
|
||||
@@ -56,7 +57,7 @@ export function IdentifyPanel({
|
||||
) : identify.error ? (
|
||||
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
|
||||
) : !identify.data ? (
|
||||
<p className="text-caption text-muted-foreground">按文件名解析,匹配本地作品和 TMDB。只读,不改文件。</p>
|
||||
<p className="text-caption text-muted-foreground">按文件名解析,匹配本地作品和 TMDB。只读,不改文件。</p>
|
||||
) : !it ? (
|
||||
<p className="text-caption text-muted-foreground">{isDir ? "目录里没有视频文件。" : "没有识别结果。"}</p>
|
||||
) : (
|
||||
@@ -64,7 +65,7 @@ export function IdentifyPanel({
|
||||
{isDir && (identify.data.items.length > 1 || identify.data.truncated) ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
目录里有 {identify.data.items.length}
|
||||
{identify.data.truncated ? "+" : ""} 个视频,这里只显示第一个。整理时逐项确认。
|
||||
{identify.data.truncated ? "+" : ""} 个视频,这里只显示第一个。整理时逐项确认。
|
||||
</p>
|
||||
) : null}
|
||||
<KvList
|
||||
@@ -86,7 +87,7 @@ export function IdentifyPanel({
|
||||
这个源文件有 {it.records.count} 条整理记录
|
||||
{it.records.lastStatus ? (
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[it.records.lastStatus]}>
|
||||
最近:{RECORD_STATUS_LABEL[it.records.lastStatus]}
|
||||
最近:{RECORD_STATUS_LABEL[it.records.lastStatus]}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
{it.records.lastRecordId ? (
|
||||
@@ -97,7 +98,7 @@ export function IdentifyPanel({
|
||||
</p>
|
||||
) : null}
|
||||
{it.skipped === "below_min_size" ? (
|
||||
<p className="text-caption text-muted-foreground">低于最小体积,没有查候选。</p>
|
||||
<p className="text-caption text-muted-foreground">低于最小体积,没有查候选。</p>
|
||||
) : null}
|
||||
{it.warnings.map((w) => (
|
||||
<p key={w} className="text-caption text-warning">
|
||||
@@ -113,6 +114,17 @@ export function IdentifyPanel({
|
||||
key={`${c.provider}-${c.mediaEntityId ?? c.externalId}`}
|
||||
className="flex items-start gap-2 px-3 py-2"
|
||||
>
|
||||
{c.posterUrl ? (
|
||||
<img
|
||||
src={c.posterUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-12 w-8 shrink-0 rounded-sm bg-muted object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="h-12 w-8 shrink-0 rounded-sm bg-muted" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-caption text-foreground">
|
||||
<span className="truncate">
|
||||
|
||||
@@ -58,7 +58,7 @@ const dune = (cu: string | null = null) => ({
|
||||
assetId: "a-dune",
|
||||
locationId: "l-dune",
|
||||
mediaEntityId: "e-dune",
|
||||
title: "沙丘2",
|
||||
title: "沙丘 2",
|
||||
year: 2024,
|
||||
contentUnit: cu,
|
||||
slotId: "s-dune-uhd",
|
||||
@@ -97,6 +97,8 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
|
||||
checkedAt: ago(0.01),
|
||||
healthReason: null,
|
||||
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
|
||||
pendingCount: null,
|
||||
pendingPartial: false,
|
||||
},
|
||||
{
|
||||
id: "lib:tv",
|
||||
@@ -109,11 +111,13 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
|
||||
checkedAt: ago(0.01),
|
||||
healthReason: null,
|
||||
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
|
||||
pendingCount: null,
|
||||
pendingPartial: false,
|
||||
},
|
||||
{
|
||||
id: "lib:nas-old",
|
||||
kind: "library",
|
||||
name: "旧 NAS(只读登记)",
|
||||
name: "旧 NAS(只读登记)",
|
||||
hostPath: "/mnt/nas-old",
|
||||
mediaKind: "mixed",
|
||||
writable: false,
|
||||
@@ -121,6 +125,8 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
|
||||
checkedAt: ago(3),
|
||||
healthReason: "挂载点无响应(ENOTCONN)",
|
||||
usage: null,
|
||||
pendingCount: null,
|
||||
pendingPartial: false,
|
||||
},
|
||||
{
|
||||
id: "dl:qb-main:0",
|
||||
@@ -133,6 +139,8 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
|
||||
checkedAt: ago(0.01),
|
||||
healthReason: null,
|
||||
usage: { totalBytes: 8_000 * GB, freeBytes: 1_210 * GB },
|
||||
pendingCount: 12,
|
||||
pendingPartial: false,
|
||||
},
|
||||
];
|
||||
const tlouEps = [1, 2, 3, 4, 5, 6, 7].map((ep) =>
|
||||
@@ -302,7 +310,7 @@ function lazyChildren(n: Node): Node[] {
|
||||
|
||||
function rootOf(root: string): FileRoot {
|
||||
const r = state.roots.find((x) => x.id === root);
|
||||
if (!r) throw new ApiRequestError("not_found", "not_found: 根不存在或未登记");
|
||||
if (!r) throw new ApiRequestError("not_found", "not_found:根不存在或未登记");
|
||||
if (r.health === "offline")
|
||||
throw new ApiRequestError("upstream_unavailable", `root_unavailable: ${r.healthReason ?? "挂载点无响应"}`);
|
||||
return r;
|
||||
@@ -311,12 +319,12 @@ function rootOf(root: string): FileRoot {
|
||||
function find(root: string, path: string): { node: Node; parent: Node | null } {
|
||||
rootOf(root);
|
||||
let node = state.trees[root];
|
||||
if (!node) throw new ApiRequestError("not_found", "not_found: 根不存在");
|
||||
if (!node) throw new ApiRequestError("not_found", "not_found:根不存在");
|
||||
let parent: Node | null = null;
|
||||
for (const seg of path.split("/").filter(Boolean)) {
|
||||
if (seg === "..") throw new ApiRequestError("forbidden", "path_escape: 路径越界");
|
||||
if (seg === "..") throw new ApiRequestError("forbidden", "path_escape:路径越界");
|
||||
const next = lazyChildren(node).find((c) => c.name === seg);
|
||||
if (!next) throw new ApiRequestError("not_found", `not_found: 不存在 ${path}`);
|
||||
if (!next) throw new ApiRequestError("not_found", `not_found:不存在 ${path}`);
|
||||
parent = node;
|
||||
node = next;
|
||||
}
|
||||
@@ -356,9 +364,22 @@ function toEntry(n: Node, path: string): FileEntry {
|
||||
download: n.meta?.download ?? null,
|
||||
media: n.meta?.media ?? null,
|
||||
dirSummary,
|
||||
parsedHint: status === "unidentified" ? mockHint(n.name) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** mock 版 G7 parsedHint(真实后端用 parseReleasePath) */
|
||||
function mockHint(name: string): string | null {
|
||||
const p = parseName(name);
|
||||
if (!p.title) return null;
|
||||
const pad = (x: number) => String(x).padStart(2, "0");
|
||||
const unit =
|
||||
p.season != null
|
||||
? `S${pad(p.season)}${p.episodes.length ? `E${pad(p.episodes[0] as number)}${p.episodes.length > 1 ? `-E${pad(p.episodes[p.episodes.length - 1] as number)}` : ""}` : ""}`
|
||||
: null;
|
||||
return [!unit && p.year ? `${p.title} (${p.year})` : p.title, unit, p.resolution].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
temp: 0,
|
||||
downloading: 1,
|
||||
@@ -560,7 +581,7 @@ function parseName(name: string) {
|
||||
|
||||
const KNOWN: { match: RegExp; id: string; title: string; year: number; kind: "movie" | "series" }[] = [
|
||||
{ match: /oppenheimer/i, id: "e-opp", title: "奥本海默", year: 2023, kind: "movie" },
|
||||
{ match: /dune/i, id: "e-dune", title: "沙丘2", year: 2024, kind: "movie" },
|
||||
{ match: /dune/i, id: "e-dune", title: "沙丘 2", year: 2024, kind: "movie" },
|
||||
{ match: /last of us/i, id: "e-tlou", title: "最后生还者", year: 2023, kind: "series" },
|
||||
];
|
||||
|
||||
@@ -624,6 +645,7 @@ function identify(
|
||||
level: "high" as const,
|
||||
score: 0.9,
|
||||
reasons: ["TMDB 标题命中", "季集结构一致"],
|
||||
posterUrl: null,
|
||||
},
|
||||
]
|
||||
: hit
|
||||
@@ -638,17 +660,19 @@ function identify(
|
||||
level: "high" as const,
|
||||
score: 0.94,
|
||||
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
|
||||
posterUrl: null,
|
||||
},
|
||||
{
|
||||
mediaEntityId: null,
|
||||
provider: "tmdb",
|
||||
externalId: "99999",
|
||||
title: `${parsed.title}(同名旧作)`,
|
||||
title: `${parsed.title}(同名旧作)`,
|
||||
year: (parsed.year ?? hit.year) - 30,
|
||||
kind: hit.kind,
|
||||
level: "low" as const,
|
||||
score: 0.31,
|
||||
reasons: ["仅标题相似"],
|
||||
posterUrl: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -683,7 +707,7 @@ function doMove(body: {
|
||||
const tRoot = rootOf(body.target.root);
|
||||
const target = find(body.target.root, body.target.path).node;
|
||||
if (!target.dir || !tRoot.writable)
|
||||
throw new ApiRequestError("bad_request", "not_writable: 目标必须是可写库根下的目录");
|
||||
throw new ApiRequestError("bad_request", "not_writable:目标必须是可写库根下的目录");
|
||||
const pairs = body.items.map((it) => {
|
||||
const { node } = find(it.root, it.path);
|
||||
const name = node.name;
|
||||
@@ -727,12 +751,12 @@ function doMove(body: {
|
||||
executed: null,
|
||||
};
|
||||
if (body.dryRun) return res;
|
||||
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active: 含 H&R 未满足的做种文件,不能移动");
|
||||
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active:含 H&R 未满足的做种文件,不能移动");
|
||||
const succeeded: string[] = [];
|
||||
const failed: { path: string; code: string; message: string }[] = [];
|
||||
for (const p of pairs) {
|
||||
if (p.conflict) {
|
||||
failed.push({ path: p.source.path, code: "exists", message: "目标已存在同名项,不覆盖" });
|
||||
failed.push({ path: p.source.path, code: "exists", message: "目标已存在同名项,不覆盖" });
|
||||
continue;
|
||||
}
|
||||
const n = body.mode === "move" ? detach(p.source) : clone(find(p.source.root, p.source.path).node);
|
||||
@@ -837,7 +861,7 @@ function organize(body: {
|
||||
recordId: body.dryRun || conflict ? null : `rec-${++seq}`,
|
||||
taskId: body.dryRun || conflict ? null : `task-${seq}`,
|
||||
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
|
||||
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按“交给待处理”整组不执行" } : null,
|
||||
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按「交给待处理」整组不执行" } : null,
|
||||
action: body.action,
|
||||
entity: items.some((x) => x.external)
|
||||
? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" }
|
||||
@@ -901,16 +925,16 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
|
||||
case "/files/mkdir": {
|
||||
const { node } = find(b.root, b.path);
|
||||
if ((node.children ?? []).some((c) => c.name === b.name))
|
||||
throw new ApiRequestError("conflict", "exists: 同名目录已存在");
|
||||
throw new ApiRequestError("conflict", "exists:同名目录已存在");
|
||||
node.children = [...(node.children ?? []), d(b.name, 0, [])];
|
||||
return { root: b.root, path: join(b.path, b.name) };
|
||||
}
|
||||
case "/files/rename": {
|
||||
const { node, parent } = find(b.root, b.path);
|
||||
if (node.meta?.download)
|
||||
throw new ApiRequestError("conflict", "download_active: 做种中,改名会让下载器找不到文件");
|
||||
throw new ApiRequestError("conflict", "download_active:做种中,改名会让下载器找不到文件");
|
||||
if ((parent?.children ?? []).some((c) => c.name === b.newName && c !== node))
|
||||
throw new ApiRequestError("conflict", "exists: 目标名已存在,不覆盖");
|
||||
throw new ApiRequestError("conflict", "exists:目标名已存在,不覆盖");
|
||||
const stem = node.name.replace(/\.[^.]+$/, "");
|
||||
const newStem = String(b.newName).replace(/\.[^.]+$/, "");
|
||||
const dirPath = String(b.path).split("/").slice(0, -1).join("/");
|
||||
@@ -964,7 +988,7 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
|
||||
recordId: null,
|
||||
};
|
||||
if (b.dryRun) return base;
|
||||
if (checks.some((c) => c.blocked)) throw new ApiRequestError("conflict", "download_active: 有被引用项,不能删除");
|
||||
if (checks.some((c) => c.blocked)) throw new ApiRequestError("conflict", "download_active:有被引用项,不能删除");
|
||||
const trashed: TrashItem[] = [];
|
||||
for (const it of b.items as ItemRef[]) {
|
||||
const n = detach(it);
|
||||
@@ -1000,7 +1024,7 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
|
||||
try {
|
||||
const { node } = find(rootId, parentPath);
|
||||
if ((node.children ?? []).some((c) => c.name === name)) {
|
||||
failed.push({ id: t.id, code: "exists", message: `原位置已被占用:${t.originalPath}` });
|
||||
failed.push({ id: t.id, code: "exists", message: `原位置已被占用:${t.originalPath}` });
|
||||
continue;
|
||||
}
|
||||
node.children = [...(node.children ?? []), t.isDir ? d(name, 1, []) : f(name, t.size, 1)];
|
||||
@@ -1113,7 +1137,7 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
|
||||
})),
|
||||
};
|
||||
default:
|
||||
throw new ApiRequestError("not_found", `mock 未实现:${path}`);
|
||||
throw new ApiRequestError("not_found", `mock 未实现:${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1146,8 +1170,8 @@ function mockRecord(id: string) {
|
||||
export async function mockPreview(root: string, path: string): Promise<string> {
|
||||
await delay(100);
|
||||
if (path.endsWith(".nfo"))
|
||||
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘2</title>\n <year>2024</year>\n</movie>`;
|
||||
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘 2</title>\n <year>2024</year>\n</movie>`;
|
||||
if (/\.(srt|ass)$/.test(path))
|
||||
return "1\n00:00:01,000 --> 00:00:04,000\n厄拉科斯……\n\n2\n00:00:05,000 --> 00:00:07,500\n一切都从这里开始。";
|
||||
return "1\n00:00:01,000 --> 00:00:04,000\n 厄拉科斯…\n\n2\n00:00:05,000 --> 00:00:07,500\n 一切都从这里开始。";
|
||||
return `(${root}:${path})`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFilesMock } from "~/api/files.ts";
|
||||
import {
|
||||
applyIdentifyCompleted,
|
||||
clickSelect,
|
||||
collapseCrumbs,
|
||||
deleteBlockText,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
opApplies,
|
||||
opDisabledReason,
|
||||
parseEpisodes,
|
||||
parseHint,
|
||||
pruneSelection,
|
||||
selectAll,
|
||||
shiftEpisodes,
|
||||
@@ -84,6 +84,7 @@ const base = {
|
||||
download: null,
|
||||
media: null,
|
||||
dirSummary: null,
|
||||
parsedHint: null,
|
||||
};
|
||||
|
||||
describe("状态与禁用原因", () => {
|
||||
@@ -173,6 +174,7 @@ describe("v2 菜单与副行", () => {
|
||||
name: "The.Last.of.Us.S01E02.1080p.WEB-DL.mkv",
|
||||
fileKind: "video" as const,
|
||||
status: "unidentified" as const,
|
||||
parsedHint: "The Last of Us · S01E02 · 1080p",
|
||||
};
|
||||
const asset = {
|
||||
assetId: "a",
|
||||
@@ -227,8 +229,8 @@ describe("v2 菜单与副行", () => {
|
||||
dirSummary: { videoCount: 13, registeredCount: 0, partial: false },
|
||||
})?.text,
|
||||
).toBe("13 视频 · 0 已登记");
|
||||
expect(parseHint("Oppenheimer.2023.2160p.BluRay.x265.mkv")).toBe("Oppenheimer (2023) · 2160p");
|
||||
expect(parseHint("readme.txt")).toBeNull();
|
||||
// 没有 parsedHint(服务端解析不出标题)时不显示副行,前端不自己解析
|
||||
expect(entrySubline({ ...video, parsedHint: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("初始位置:上次位置 > 下载目录 > 库根;离线根跳过", () => {
|
||||
@@ -275,3 +277,55 @@ describe("transferTargetProblem(U13)", () => {
|
||||
expect(transferTargetProblem(items, { root: "dl", path: "packs" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("识别远端回填(S-7)", () => {
|
||||
const item = (path: string, over: Record<string, unknown> = {}) => ({
|
||||
root: "dl:x:0",
|
||||
path,
|
||||
parsed: {
|
||||
title: "T",
|
||||
year: null,
|
||||
season: null,
|
||||
episodes: [],
|
||||
resolution: null,
|
||||
source: null,
|
||||
releaseGroup: null,
|
||||
mediaKind: null,
|
||||
},
|
||||
candidates: [],
|
||||
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
|
||||
existing: { assetId: null },
|
||||
warnings: ["远端候选在后台查询,查完自动补上"],
|
||||
...over,
|
||||
});
|
||||
const base = { items: [item("a.mkv"), item("b.mkv")], truncated: false, remoteTaskId: "run-1", remotePending: 1 };
|
||||
|
||||
it("同一 taskId 按 root + path 替换条目并清掉待查标记", () => {
|
||||
const done = item("b.mkv", { warnings: [], candidates: [{ title: "B" }] });
|
||||
const r = applyIdentifyCompleted(
|
||||
base as never,
|
||||
{
|
||||
taskId: "run-1",
|
||||
status: "succeeded",
|
||||
items: [done],
|
||||
truncated: false,
|
||||
excludedBySize: 0,
|
||||
error: null,
|
||||
} as never,
|
||||
);
|
||||
expect(r.remoteTaskId).toBeNull();
|
||||
expect(r.remotePending).toBe(0);
|
||||
expect(r.items[0]).toBe(base.items[0]);
|
||||
expect(r.items[1]).toBe(done);
|
||||
});
|
||||
it("别的 taskId 原样返回;失败时保留本地结果并写明原因", () => {
|
||||
const other = { taskId: "run-2", status: "succeeded", items: [], truncated: false, excludedBySize: 0, error: null };
|
||||
expect(applyIdentifyCompleted(base as never, other as never)).toBe(base);
|
||||
const f = applyIdentifyCompleted(
|
||||
base as never,
|
||||
{ ...other, taskId: "run-1", status: "failed", error: "超时" } as never,
|
||||
);
|
||||
expect(f.remoteTaskId).toBeNull();
|
||||
expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。
|
||||
import type { FileEntry, FileRoot, FileStatus } from "~/api/files.ts";
|
||||
import type {
|
||||
FileEntry,
|
||||
FileIdentifyItem,
|
||||
FileRoot,
|
||||
FileStatus,
|
||||
FilesIdentifyResult,
|
||||
IdentifyCompletedPayload,
|
||||
} from "~/api/files.ts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// ─── 状态徽标 ────────────────────────────────────────────────────
|
||||
@@ -8,7 +15,7 @@ export type StatusView = { label: string; tone: StatusTone; hint?: string };
|
||||
|
||||
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
|
||||
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
|
||||
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" };
|
||||
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" };
|
||||
const s: FileStatus = e.status;
|
||||
switch (s) {
|
||||
case "temp":
|
||||
@@ -72,17 +79,17 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
|
||||
const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading");
|
||||
switch (op) {
|
||||
case "rename":
|
||||
if (!rootWritable) return "只读目录,不能改名";
|
||||
if (seeding) return "做种中,H&R 满足前不能改名";
|
||||
if (!rootWritable) return "只读目录,不能改名";
|
||||
if (seeding) return "做种中,H&R 满足前不能改名";
|
||||
if (e.status === "temp") return "临时文件不能改名";
|
||||
return null;
|
||||
case "move":
|
||||
if (!rootWritable) return "只读目录,不能移出。可以复制";
|
||||
if (hnrBlocked) return "做种中,H&R 满足前不能移动";
|
||||
if (!rootWritable) return "只读目录,不能移出。可以复制";
|
||||
if (hnrBlocked) return "做种中,H&R 满足前不能移动";
|
||||
return null;
|
||||
case "delete":
|
||||
if (!rootWritable) return "只读目录,不能删除";
|
||||
if (seeding) return "下载任务仍在使用,不能删除";
|
||||
if (!rootWritable) return "只读目录,不能删除";
|
||||
if (seeding) return "下载任务仍在使用,不能删除";
|
||||
return null;
|
||||
case "organize":
|
||||
case "identify":
|
||||
@@ -90,7 +97,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
|
||||
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
|
||||
return null;
|
||||
case "scrape":
|
||||
return e.asset ? null : "未登记,先整理入库";
|
||||
return e.asset ? null : "未登记,先整理入库";
|
||||
case "probe":
|
||||
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
|
||||
case "copy":
|
||||
@@ -189,8 +196,8 @@ export function transferTargetProblem(
|
||||
for (const it of items) {
|
||||
if (it.root !== target.root) continue;
|
||||
if (target.path === it.path || target.path.startsWith(`${it.path}/`))
|
||||
return `目标在源目录之内(${baseName(it.path)}),换一个目录`;
|
||||
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
|
||||
return `目标在源目录之内(${baseName(it.path)}),换一个目录`;
|
||||
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -302,11 +309,11 @@ export function deleteBlockText(b: string | null): { text: string; soft: boolean
|
||||
case "is_root":
|
||||
return { text: "不能删除登记目录本身", soft: false };
|
||||
case "needs_retire":
|
||||
return { text: "含已登记资产,勾选“同时退役资产”后才能删除", soft: true };
|
||||
return { text: "含已登记资产,勾选「同时退役资产」后才能删除", soft: true };
|
||||
case "needs_unbind":
|
||||
return { text: "是作品或季的绑定目录,勾选“解除路径绑定”后才能删除", soft: true };
|
||||
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
|
||||
case "root_unavailable":
|
||||
return { text: "目录离线,不能确认文件状态", soft: false };
|
||||
return { text: "目录离线,不能确认文件状态", soft: false };
|
||||
case "not_found":
|
||||
return { text: "文件已不存在", soft: false };
|
||||
case "escape":
|
||||
@@ -433,34 +440,7 @@ export const OP_LABEL: Record<MenuKey, string> = {
|
||||
delete: "删除",
|
||||
};
|
||||
|
||||
/** 从文件名做轻量解析(只用于待识别行的灰字副行,不查库):标题 · S01E01 · 1080p */
|
||||
export function parseHint(name: string): string | null {
|
||||
const base = name.replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
||||
const se = /\bS(\d{1,2})[ ._-]?E(\d{1,3})(?:-?E?(\d{1,3}))?/i.exec(base);
|
||||
const season = /\bS(\d{1,2})\b/i.exec(base);
|
||||
const yr = /[.( [](19\d{2}|20\d{2})(?=[.) \]]|$)/.exec(base);
|
||||
const res = /\b(2160p|1080p|1080i|720p|4K)\b/i.exec(base);
|
||||
const cuts = [se?.index, season?.index, yr?.index, res?.index].filter((x): x is number => x != null);
|
||||
const cut = cuts.length ? Math.min(...cuts) : -1;
|
||||
if (cut <= 0) return null;
|
||||
const title = base
|
||||
.slice(0, cut)
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/[-([ ]+$/, "")
|
||||
.trim();
|
||||
if (!title) return null;
|
||||
const parts = [title];
|
||||
if (yr && !se) parts[0] = `${title} (${yr[1]})`;
|
||||
if (se) {
|
||||
const s = `S${String(Number(se[1])).padStart(2, "0")}`;
|
||||
const e1 = `E${String(Number(se[2])).padStart(2, "0")}`;
|
||||
parts.push(se[3] ? `${s}${e1}-E${String(Number(se[3])).padStart(2, "0")}` : `${s}${e1}`);
|
||||
} else if (season) parts.push(`S${String(Number(season[1])).padStart(2, "0")}`);
|
||||
if (res) parts.push((res[1] as string).toLowerCase() === "4k" ? "2160p" : (res[1] as string).toLowerCase());
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 名称列副行(§4.2):已登记 → 作品 (年) · 季集;待识别视频 → 解析灰字;目录 → n 视频 · m 已登记 */
|
||||
/** 名称列副行(§4.2):已登记 → 作品 (年) · 季集;待识别视频 → 服务端轻量解析 parsedHint(G7);目录 → n 视频 · m 已登记 */
|
||||
export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint" | "meta" } | null {
|
||||
if (e.asset) {
|
||||
const t = `${e.asset.title}${e.asset.year ? ` (${e.asset.year})` : ""}`;
|
||||
@@ -471,10 +451,7 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint
|
||||
if (!s.videoCount && !s.registeredCount) return null;
|
||||
return { text: `${s.videoCount}${s.partial ? "+" : ""} 视频 · ${s.registeredCount} 已登记`, tone: "meta" };
|
||||
}
|
||||
if (e.type === "file" && e.fileKind === "video" && e.status === "unidentified") {
|
||||
const h = parseHint(e.name);
|
||||
return h ? { text: h, tone: "hint" } : null;
|
||||
}
|
||||
if (e.parsedHint) return { text: e.parsedHint, tone: "hint" };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -505,3 +482,37 @@ export function nameTaken(name: string, self: string, siblings: string[]): boole
|
||||
if (!n || n === self) return false;
|
||||
return siblings.some((s) => s !== self && s === n);
|
||||
}
|
||||
|
||||
// ─── 识别:远端结果回填(S-7) ────────────────────────────────────
|
||||
|
||||
const identifyKey = (it: Pick<FileIdentifyItem, "root" | "path">) => `${it.root}\u0000${it.path}`;
|
||||
/** 服务端给"远端在后台查"项加的提示;结果回来后去掉 */
|
||||
const REMOTE_PENDING_WARNING = /^远端候选在后台查询/;
|
||||
|
||||
/**
|
||||
* SSE identify.completed 回填:taskId 与响应的 remoteTaskId 一致时,按 root + path 用完整结果替换条目;
|
||||
* 失败时保留本地结果,把"后台查询中"提示换成失败原因。不相关的事件原样返回。
|
||||
*/
|
||||
export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyCompletedPayload): FilesIdentifyResult {
|
||||
if (!r.remoteTaskId || p.taskId !== r.remoteTaskId) return r;
|
||||
if (p.status === "failed") {
|
||||
const note = `远端查询失败,只有本地结果${p.error ? `:${p.error.slice(0, 200)}` : ""}`;
|
||||
return {
|
||||
...r,
|
||||
remoteTaskId: null,
|
||||
remotePending: 0,
|
||||
items: r.items.map((it) =>
|
||||
it.warnings.some((w) => REMOTE_PENDING_WARNING.test(w))
|
||||
? { ...it, warnings: [...it.warnings.filter((w) => !REMOTE_PENDING_WARNING.test(w)), note] }
|
||||
: it,
|
||||
),
|
||||
};
|
||||
}
|
||||
const by = new Map(p.items.map((it) => [identifyKey(it), it]));
|
||||
return {
|
||||
...r,
|
||||
remoteTaskId: null,
|
||||
remotePending: 0,
|
||||
items: r.items.map((it) => by.get(identifyKey(it)) ?? it),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { type FilesIdentifyResult, type IdentifyCompletedPayload, useIdentify } from "~/api/files.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { applyIdentifyCompleted } from "./model.ts";
|
||||
|
||||
/**
|
||||
* useIdentify + 远端结果回填(S-7):响应带 remoteTaskId 时,用 SSE identify.completed 里同一 taskId 的完整结果
|
||||
* 按 root + path 替换条目。事件可能比 HTTP 响应先到,所以挂载期间一直订阅,并缓存最近几条。
|
||||
* data 是回填后的结果;remotePending 表示还在等远端。
|
||||
*/
|
||||
export function useIdentifyLive() {
|
||||
const m = useIdentify();
|
||||
const [data, setData] = useState<FilesIdentifyResult | undefined>(undefined);
|
||||
const early = useRef(new Map<string, IdentifyCompletedPayload>());
|
||||
useEffect(() => {
|
||||
const d = m.data;
|
||||
const hit = d?.remoteTaskId ? early.current.get(d.remoteTaskId) : undefined;
|
||||
setData(d && hit ? applyIdentifyCompleted(d, hit) : d);
|
||||
}, [m.data]);
|
||||
useSse([EventType.identifyCompleted], (ev) => {
|
||||
if (ev.type !== EventType.identifyCompleted) return;
|
||||
const p = ev.payload as IdentifyCompletedPayload;
|
||||
early.current.set(p.taskId, p);
|
||||
if (early.current.size > 20) early.current.delete(early.current.keys().next().value as string);
|
||||
setData((d) => (d ? applyIdentifyCompleted(d, p) : d));
|
||||
});
|
||||
return { ...m, data, remotePending: !!data?.remoteTaskId };
|
||||
}
|
||||
@@ -51,6 +51,7 @@ const item = (
|
||||
level: over.level ?? "high",
|
||||
score: 1,
|
||||
reasons: [],
|
||||
posterUrl: null,
|
||||
},
|
||||
],
|
||||
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { CursorPage, PathMapping } from "@umt/contracts";
|
||||
import { type CursorPage, EventType, type IdentifyCompletedPayload, type PathMapping } from "@umt/contracts";
|
||||
import { ArrowLeft, ArrowRight, Check, FileStack, LoaderCircle, RotateCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -24,6 +24,7 @@ import { useIsMobile } from "~/components/files/use-mobile.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { apiData } from "~/lib/api.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import {
|
||||
applyEntityTo,
|
||||
@@ -109,7 +110,7 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined
|
||||
...base,
|
||||
items: ref ? [ref] : [],
|
||||
hnrPaths: ref && source.hnrBlocked ? [ref.path] : [],
|
||||
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
|
||||
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
|
||||
label: `下载任务 · ${source.name}`,
|
||||
};
|
||||
}
|
||||
@@ -159,6 +160,29 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
const exec = useOrganizeFiles();
|
||||
const records = useFileRecords({ ids: recordIds }, recordIds.length > 0);
|
||||
|
||||
// S-7:识别响应带 remoteTaskId 时,远端候选由 SSE identify.completed 补上(保留已选的作品 / 季集)。
|
||||
// 事件可能比响应先到:先到的暂存,响应回来再对账。
|
||||
const remote = useRef({ waiting: new Set<string>(), early: new Map<string, IdentifyCompletedPayload>() });
|
||||
const applyRemote = useCallback((p: IdentifyCompletedPayload) => {
|
||||
if (p.status === "succeeded") setRows((rs) => mergeIdentified(rs, p.items));
|
||||
else toast.error(`远端候选查询失败,只有本地结果${p.error ? `:${p.error.slice(0, 120)}` : ""}`);
|
||||
}, []);
|
||||
const expectRemote = useCallback(
|
||||
(taskId: string | null | undefined) => {
|
||||
if (!taskId) return;
|
||||
const early = remote.current.early.get(taskId);
|
||||
if (early) applyRemote(early);
|
||||
else remote.current.waiting.add(taskId);
|
||||
},
|
||||
[applyRemote],
|
||||
);
|
||||
useSse([EventType.identifyCompleted], (ev) => {
|
||||
const p = ev.payload as IdentifyCompletedPayload;
|
||||
if (remote.current.waiting.delete(p.taskId)) return applyRemote(p);
|
||||
remote.current.early.set(p.taskId, p);
|
||||
if (remote.current.early.size > 20) remote.current.early.delete(remote.current.early.keys().next().value as string);
|
||||
});
|
||||
|
||||
// 设置里的"整理默认"(方式 / 最小体积);读不到用默认值
|
||||
const ready = settings.isFetched || settings.isError || filesMockEnabled();
|
||||
const defaults = useMemo(() => {
|
||||
@@ -189,10 +213,11 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
}
|
||||
setRows(next);
|
||||
setTruncated(r.truncated);
|
||||
expectRemote(r.remoteTaskId);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [started, ready, src, defaults, identifyMutate]);
|
||||
}, [started, ready, src, defaults, identifyMutate, expectRemote]);
|
||||
|
||||
const active = rows.filter((r) => !r.excluded);
|
||||
const stats = wizardStats(rows);
|
||||
@@ -218,7 +243,10 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
reidentify.mutate(
|
||||
{ items: r.reidentify.map((x) => ({ root: x.root, path: x.path })), minSizeBytes: bytes },
|
||||
{
|
||||
onSuccess: (res) => setRows((rs) => mergeIdentified(rs, res.items)),
|
||||
onSuccess: (res) => {
|
||||
setRows((rs) => mergeIdentified(rs, res.items));
|
||||
expectRemote(res.remoteTaskId);
|
||||
},
|
||||
onError: (e) => toast.error(filesErrorText(e, "补充识别失败")),
|
||||
},
|
||||
);
|
||||
@@ -267,13 +295,13 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
(x) =>
|
||||
(x.entity?.id && ids.has(x.entity.id)) || (x.entity?.external && ext.has(x.entity.external.externalId)),
|
||||
);
|
||||
runPreview(again.length ? again : preview.rows, `${stale.length} 组源文件在预览后有变化,已重新预览`);
|
||||
runPreview(again.length ? again : preview.rows, `${stale.length} 组源文件在预览后有变化,已重新预览`);
|
||||
} else setPreview(null);
|
||||
},
|
||||
onError: (e) => {
|
||||
if (filesErrorReason(e) === "plan_stale") {
|
||||
runPreview(preview.rows, "源文件在预览后有变化,已重新预览");
|
||||
toast.warning("源文件在预览后有变化,已重新预览");
|
||||
runPreview(preview.rows, "源文件在预览后有变化,已重新预览");
|
||||
toast.warning("源文件在预览后有变化,已重新预览");
|
||||
} else toast.error(filesErrorText(e, "执行失败"));
|
||||
},
|
||||
});
|
||||
@@ -291,14 +319,14 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
body = (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
{src.pending ? "定位源文件…" : "识别中:匹配本地作品与 TMDB…"}
|
||||
{src.pending ? "定位源文件…" : "识别中:匹配本地作品与 TMDB…"}
|
||||
</p>
|
||||
);
|
||||
} else if (src.error || (!src.items.length && src.unresolved.length) || !src.items.length) {
|
||||
body = (
|
||||
<div className="space-y-2 text-caption">
|
||||
<p className="text-destructive">
|
||||
{src.error ? filesErrorText(src.error, "读取整理记录失败") : "源文件不在任何登记目录里,或已经不存在。"}
|
||||
{src.error ? filesErrorText(src.error, "读取整理记录失败") : "源文件不在任何登记目录里,或已经不存在。"}
|
||||
</p>
|
||||
{src.unresolved.length ? (
|
||||
<ul className="space-y-0.5 font-mono text-meta text-muted-foreground">
|
||||
@@ -310,7 +338,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="text-muted-foreground">
|
||||
在设置里检查下载器路径映射,或到
|
||||
在设置里检查下载器路径映射,或到
|
||||
<Link to="/console/files" className="mx-1 underline underline-offset-2" onClick={onClose}>
|
||||
文件管理
|
||||
</Link>
|
||||
@@ -335,7 +363,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
<div className="space-y-3">
|
||||
{src.unresolved.length ? (
|
||||
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
|
||||
{src.unresolved.length} 个源路径不在登记目录里或已不存在,已跳过。
|
||||
{src.unresolved.length} 个源路径不在登记目录里或已不存在,已跳过。
|
||||
</p>
|
||||
) : null}
|
||||
<StepFiles
|
||||
@@ -351,7 +379,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
onBulkOffset={(n) => {
|
||||
const r = applyOffsetTo(rows, selected, n);
|
||||
setRows(r.rows);
|
||||
if (r.failed) toast.warning(`${r.failed} 项偏移后集号小于 1,没有改`);
|
||||
if (r.failed) toast.warning(`${r.failed} 项偏移后集号小于 1,没有改`);
|
||||
}}
|
||||
minSizeMb={minSizeMb}
|
||||
onMinSizeMb={onMinSizeMb}
|
||||
@@ -390,7 +418,9 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
/>
|
||||
))}
|
||||
{executed ? (
|
||||
<p className="text-caption text-muted-foreground">整理在后台执行,状态每 3 秒刷新,文件列表会自动更新。</p>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
整理在后台执行,状态每 3 秒刷新,文件列表会自动更新。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
@@ -472,7 +502,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
<>
|
||||
{rows.length && !canNext ? (
|
||||
<span className="text-meta text-warning">
|
||||
{stats.pending ? `${stats.pending} 项待定:选作品或排除` : "没有要整理的项"}
|
||||
{stats.pending ? `${stats.pending} 项待定:选作品或排除` : "没有要整理的项"}
|
||||
</span>
|
||||
) : null}
|
||||
<Button onClick={() => setStep(1)} disabled={!canNext}>
|
||||
|
||||
@@ -280,9 +280,9 @@
|
||||
| G3 | 目标已存在策略 | `FilesOrganizeRequest` 加 `onConflict: "skip"\|"decision"\|"replace_if_better"`;替换走现有 `replace_with_verify`,旧文件进回收站 | 是 |
|
||||
| G4 | 命名方式 + 来源 | 请求加 `naming: "template"\|"keep"`;响应每组加 `templateSource: "entity"\|"root"\|"global"` 与目标路径 `segments:[{text, role, bound}]` | 是 |
|
||||
| G5 | 入库后刷新元数据 | 请求加 `refreshMetadata: boolean`,执行成功后对每个作品排一次 refresh run | 否(前端可在完成后调 `/files/scrape`) |
|
||||
| G6 | 下载目录待识别计数 | `FileRoot` 加 `pendingCount`(缓存 5 分钟) | 否 |
|
||||
| G7 | 列表轻量解析 | 待识别视频的 entry 加 `parsedHint`(标题·季集·分辨率,纯字符串解析,不查库) | 否 |
|
||||
| G8 | 候选海报 | `FileIdentifyCandidate` 加 `posterUrl` | 否 |
|
||||
| G6 | 下载目录待识别计数 | `FileRoot` 加 `pendingCount`(缓存 5 分钟) | 否(C1-C 已做,另有 `pendingPartial`) |
|
||||
| G7 | 列表轻量解析 | 待识别视频的 entry 加 `parsedHint`(标题·季集·分辨率,纯字符串解析,不查库) | 否(C1-C 已做) |
|
||||
| G8 | 候选海报 | `FileIdentifyCandidate` 加 `posterUrl` | 否(C1-C 已做) |
|
||||
| G9 | 推荐名 | `POST /files/suggest-name {root,path}` → 命名引擎渲染(依赖 `naming-template.md` §8 的引擎);不可信时返回 `null` + 原因 | 是(依赖命名引擎) |
|
||||
|
||||
## 16. 实施拆分与估算
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
| G3 | organize `onExisting: skip\|conflict\|replace_with_verify`(缺省 conflict) | 透传 `planImport.onExisting`;选 replace 即视为人工授权(`grantReplace`),执行走现有 replace 分支(新文件核验通过后旧文件进回收站)。预览逐文件 `action`(transfer/replace/skip/hold)+ `existing`(same 同一文件已在库 / skip / replace / conflict / null)+ `skipReason`;conflict 时该组 `error.code=plan_blocked`。策略进请求指纹,换策略须重新预览 |
|
||||
| G4 | organize `naming: template\|keep`(缺省 template) | keep = 保留源文件名(目录仍按模板/绑定/targetDir;改名后撞名 → hold + duplicate_target)。`planImport` 新参数 `namingMode` / `namingSource`,冻结计划 `organization_record.plan.naming = {mode, templateSource: entity\|root\|global\|override, templateVersion}`(模板来源按 作品 > 库根 > 全局 回落;keep = override)。组响应同名 `naming` + `targetSegments[{text, role: root\|season\|dir, bound}]`(bound = 沿用已有路径绑定) |
|
||||
| G5 | identify 项 `records{count,lastRecordId,lastStatus}`;`GET /files/record-sources?recordId=`;organize `refreshMetadata`;`POST /files/scrape-batch` | 整理记录按 `organization_file_result.source_path` 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 `unresolved`。`refreshMetadata=true`:执行成功的组按作品去重各排一次 `refreshMetadata`(组里 `refreshTaskId`)。scrape-batch:`items`(文件/目录 → 其下已登记资产的作品)∪ `mediaEntityIds` 去重,≤200 个作品;无 active 资产 `not_registered`、不存在 `not_found`、文件不属于任何作品 `no_entity`;带幂等键 |
|
||||
| G6 | `GET /files/roots` 每项 `pendingCount` / `pendingPartial` | 仅下载目录:递归数待识别视频(与列表 `unidentified` 同口径:视频、非临时名、不在纳管下载任务内容内),上限 `FILES_LIST_MAX_ENTRIES` 项,超出 `pendingPartial=true`(计数为下限)。按根缓存 5 分钟,该根的 `files.changed` 事件使缓存失效;库根与离线根为 `null` |
|
||||
| G7 | 列表项 `parsedHint` | 待识别视频行:`parseReleasePath`(文件名 + 父/祖父目录)的轻量结果 `标题 (年) · S01E02 · 1080p`(剧集不带年);不查库,解析不出标题或非待识别行为 `null`。前端副行直接显示,不再自己解析 |
|
||||
| G8(海报) | identify 候选 `posterUrl` | TMDB `poster_path` 拼 `https://image.tmdb.org/t/p/w154…`(TMDB 适配器候选带 `posterPath`,`catalog.lookup` 远端项透传);本地作品取 `metadata.posterPath`,没有时用同一请求里其已关联 TMDB 候选的海报;都没有为 `null`。前端按 `no-referrer` 懒加载 |
|
||||
| S-7 | identify 响应 `remoteTaskId` / `remotePending`;SSE `identify.completed` | 缺省请求只做本地召回,需要 TMDB 时入队 `identifyRemote` 并返回 `remoteTaskId`(引擎 runId);任务结束发 `identify.completed {taskId, status, items, truncated, excludedBySize, error}`(契约 `IdentifyCompletedPayload`,失败也发)。前端识别节(`useIdentifyLive`)与整理向导按 taskId 对账,按 root + path 替换条目(向导保留已选作品 / 季集);事件先于响应到达时暂存再对账。`?wait=true` 同步查远端(整体 ≤15 s) |
|
||||
| G8 | `GET /files/tree?root=&path=` | 目录摘要:`parent`(根为 null)、子目录 `hasChildren`(隐藏点目录与临时文件)、本目录递归视频数(上限 `dirSummaryMax`,超出 `partial`)与已登记位置数 |
|
||||
| G9 | `POST /files/suggest-name {root, path, mediaEntityId?}` | 与整理同一命名引擎与变量来源(`effectiveNaming` 作品 > 库根 > 全局 + `loadNamingEntity`),按解析结果渲染整条模板:文件给文件名段,目录给作品目录段(只有季号时给季目录段),同时回 `relativePath`、`segments[{text, role}]`、`templateSource`。作品取 请求指定 > 已登记资产所属 > 解析标题在本地别名里唯一精确命中(同年);都没有 → `name=null, reason=identity_uncertain`;模板渲染失败 → `reason=naming_failed: …` |
|
||||
|
||||
|
||||
@@ -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