feat(organize,web): sidecars follow selected videos; /console/organize?ids= prefill (FE-4 follow-ups)

- planImport (sourcePaths mode): when a selected source is a video file,
  same-directory subtitles / external audio whose stem (language tags
  stripped) matches it join the source list, so the organize wizard, which
  expands a directory into individual videos, no longer drops them.
  Unrelated sidecars stay out; pathGuard applies to the added files.
- Organize records page honours ?ids= (wizard multi-group redirect): shows
  just those records via /files/records, refreshes while running, clear chip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:52:33 +08:00
1 parent 363f85b1a1
commit 2ae780d2a1
5 files changed
+310 -7

No files matched your search

@@ -22,6 +22,7 @@ import {
type PathBinding as DomainBinding,
type ExistingPolicy,
type ExistingTarget,
fileKindOf,
findOtherVersions,
type NewBinding,
type OrganizePlan,
@@ -32,6 +33,7 @@ import {
type QualityFacts,
resolveTargetDirectory,
type SourceFile,
sidecarStem,
} from "@umt/domain";
import { organizationStatusFromFiles, organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping";
import { withAttemptToken } from "@umt/tasks";
@@ -192,6 +194,48 @@ async function walk(
}
}
/**
* 选中的是单个视频文件时,把同目录里主干名(剥掉 .chs/.eng 等语言标签后)相同的字幕 / 外挂音轨补进源文件列表;
* 配对规则与 planOrganize 的附属文件一致(同目录、主干唯一匹配),这里只负责把它们带进来。
* 目录源已由 walk 递归带上附属文件;已在列表里的不重复加。
*/
async function addSidecarSiblings(
sources: string[],
out: string[],
limit: number,
guard?: ((p: string) => Promise<string>) | undefined,
): Promise<void> {
const have = new Set(out);
const byDir = new Map<string, Set<string>>();
for (const src of sources) {
if (fileKindOf(src) !== "video" || !have.has(src)) continue;
const dir = dirname(src);
const stems = byDir.get(dir) ?? new Set<string>();
stems.add(
basename(src)
.replace(/\.[^.]+$/, "")
.toLowerCase(),
);
byDir.set(dir, stems);
}
for (const [dir, stems] of byDir) {
const names = (await readdir(dir).catch(() => [] as string[])).sort();
for (const n of names) {
if (out.length >= limit) return;
if (n.startsWith(".")) continue;
const kind = fileKindOf(n);
if (kind !== "subtitle" && kind !== "audio") continue;
const p = join(dir, n);
if (have.has(p) || !stems.has(sidecarStem(n).stem)) continue;
const st = await statSafe(p);
if (st.kind !== "ok" || !st.facts.isFile) continue;
if (guard) await guard(p);
out.push(p);
have.add(p);
}
}
}
function libraryKinds(kind: MediaEntityRow["kind"]): string[] {
if (kind === "movie") return ["movie", "mixed"];
if (kind === "variety") return ["variety", "series", "mixed"];
@@ -302,6 +346,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
try {
// 多走一个用来判断是否超限
for (const p of req.sourcePaths) await walk(p, paths, max + 1, req.pathGuard);
// 直接选中的视频文件(整理向导把目录展开成逐个视频):同目录同主干的字幕 / 外挂音轨随行进计划
await addSidecarSiblings(req.sourcePaths, paths, max + 1, req.pathGuard);
} catch (err) {
if (err instanceof PathGuardError) throw err;
return fail("source_missing", { message: errMessage(err) });
@@ -176,6 +176,38 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", grant.id).execute();
});
it("organize:选中的视频带上同目录同主干的字幕 / 外挂音轨,无关字幕不带", async () => {
const dir = join(h.dl, "Some.Show.S01.1080p.WEB-DL");
await writeFile(join(dir, "Some.Show.S01E02.1080p.WEB-DL-GRP.chs.ass"), "sub-ep2");
await writeFile(join(dir, "Some.Show.S01E03.1080p.WEB-DL-GRP.mka"), "audio-ep3");
await writeFile(join(dir, "unrelated.srt"), "noise");
// 整理向导把目录展开成逐个视频后提交
const items = ["Some.Show.S01E02.1080p.WEB-DL-GRP.mkv", "Some.Show.S01E03.1080p.WEB-DL-GRP.mkv"].map((n) => ({
root: h.dlRoot,
path: `Some.Show.S01.1080p.WEB-DL/${n}`,
mediaEntityId: show,
season: 1,
}));
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action: "copy",
dryRun: true,
minSizeBytes: 0,
});
expect(dry.status).toBe(200);
const g = dry.body.data.groups[0];
expect(g.error).toBeNull();
const byKind = (k: string) =>
g.files.filter((f: { kind: string }) => f.kind === k).map((f: { source: string; target: string }) => f);
expect(byKind("subtitle")).toHaveLength(1);
expect(byKind("subtitle")[0].source).toBe(join(dir, "Some.Show.S01E02.1080p.WEB-DL-GRP.chs.ass"));
expect(byKind("subtitle")[0].target).toMatch(/S01E02.*\.chi\.zh-cn\.ass$/);
expect(byKind("audio")).toHaveLength(1);
expect(byKind("audio")[0].target).toMatch(/S01E03.*\.mka$/);
expect(JSON.stringify(g)).not.toContain("unrelated.srt");
});
it("organize:路径逃逸同样拒绝", async () => {
const r = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
+24 -7
View File
@@ -4,6 +4,7 @@ import { EventType } from "@umt/contracts";
import { FileStack, FolderInput, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useFileRecords } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecords } from "~/api/records.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
@@ -89,6 +90,12 @@ export default function OrganizePage() {
const [qInput, setQInput] = useState("");
const [q, setQ] = useState("");
const entityId = params.get("mediaEntityId") ?? undefined;
// 整理向导执行多组后跳来:?ids=<记录 id,…> 只看这几条(执行中每 3 秒刷新)
const ids = (params.get("ids") ?? "")
.split(",")
.map((x) => x.trim())
.filter(Boolean);
const byIds = useFileRecords({ ids }, ids.length > 0);
const navigate = useNavigate();
const qc = useQueryClient();
useEffect(() => {
@@ -101,9 +108,13 @@ export default function OrganizePage() {
mediaEntityId: entityId,
q: q || undefined,
});
useSse([EventType.organizationChanged], () => void qc.invalidateQueries({ queryKey: ["records"] }));
const rows = records.data?.pages.flatMap((p) => p.items);
const filtered = !!(status || trigger || q || entityId);
useSse([EventType.organizationChanged], () => {
void qc.invalidateQueries({ queryKey: ["records"] });
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
});
const pinned = ids.length > 0;
const rows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items);
const filtered = !!(status || trigger || q || entityId || pinned);
return (
<>
@@ -148,16 +159,22 @@ export default function OrganizePage() {
<X />
</Button>
) : null}
{pinned ? (
<Button variant="outline" size="sm" onClick={() => setParams({})}>
本次整理的 {ids.length} 条
<X />
</Button>
) : null}
</FilterBar>
<DataTable
columns={COLUMNS}
rows={rows}
rowKey={(r) => r.id}
loading={records.isPending}
error={records.error}
onRetry={() => void records.refetch()}
loading={pinned ? byIds.isPending : records.isPending}
error={pinned ? byIds.error : records.error}
onRetry={() => void (pinned ? byIds.refetch() : records.refetch())}
onRowClick={(r) => navigate(`/console/organize/${r.id}`)}
hasMore={records.hasNextPage}
hasMore={!pinned && records.hasNextPage}
onLoadMore={() => void records.fetchNextPage()}
loadingMore={records.isFetchingNextPage}
empty={{
+1
View File
@@ -28,6 +28,7 @@ const STATIC_ROUTES = [
"/console/library?filter=unidentified",
"/console/library?view=identities",
"/console/organize",
"/console/organize?ids=00000000-0000-4000-8000-000000000000",
"/console/files",
// 文件管理 v2(FE-4):筛选/排序组合与演示数据(?mock=1 只在开发构建生效,随后 ?mock=0 关掉,免得影响后面的页)
"/console/files?f=video&sort=size&order=desc&hidden=1",