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) });