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:
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(),
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user