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

@@ -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">