feat(fs): W4-E4 fsproxy hang isolation, partial sweep, replace-with-verify, TIMEOUT retry

- adapters/fs: FsProxy + fsworker run each file op in a killable subprocess;
  progress-stall watchdog (FS_HANG_TIMEOUT_MS) for copy/hash, total timeout for
  quick ops; SIGTERM -> SIGKILL -> FsError('TIMEOUT'); stat/checkRoot timeouts
  never read as missing
- copyWithVerify: onExisting conflict/skip/replace (hardlink backup + atomic
  rename, old file to .umt-trash or deleted), sweep stale partials before write,
  document why permissions/owner are not copied
- filesMatch/filesMatchSha256 throw FsError on read failure instead of
  match=false; crossDeviceMove skips redundant re-hash after sha256 copy
- FsErrorCode TIMEOUT + fsErrorClass; organize executeRecord routes fs ops
  through fsproxy, retries TIMEOUT/IO up to FS_TRANSFER_MAX_ATTEMPTS then opens
  a decision without deleting the record; reconcile errors no longer reported
  as EEXIST
- domain: subtitle .default marker (ORGANIZE_DEFAULT_SUBTITLE_LANG) and
  findOtherVersions (same season/episode/part, video only) as upgrade
  disposal candidates
- scripts/bench/fs-bench.ts and benchmark record in porting doc

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:15:05 +08:00
1 parent 36dc048e89
commit 2e2d56184c
21 files changed
+1970 -179

No files matched your search

+11 -2
View File
@@ -3,7 +3,14 @@ import { buildNamingContext } from "../path/template.ts";
import { type ParseOptions, parseReleasePath } from "../release-parser/index.ts";
import { fileKindOf } from "../release-parser/parse.ts";
import type { ParsedRelease } from "../release-parser/types.ts";
import { basename, dirname, sidecarLanguageSuffix, sidecarStem, splitExt } from "./sidecars.ts";
import {
basename,
type DefaultSubtitleLang,
dirname,
sidecarLanguageSuffix,
sidecarStem,
splitExt,
} from "./sidecars.ts";
export type OrganizeOperation = "copy" | "move" | "rename";
export type OrganizeFileKind = "video" | "subtitle" | "audio" | "nfo" | "other";
@@ -45,6 +52,8 @@ export interface OrganizeInput {
minVideoBytes?: number;
grants?: { replace?: boolean };
parse?: ParseOptions;
/** 随行字幕里此语言加 .default 标记(对照 MP DEFAULT_SUB);不设不加 */
defaultSubtitleLang?: DefaultSubtitleLang;
}
export type PlannedAction = "transfer" | "replace" | "skip" | "hold";
@@ -267,7 +276,7 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
const main = mains[0]!;
const [mainStem] = splitExt(main.dst);
const [, ext] = splitExt(basename(s.path));
const suffix = sidecarLanguageSuffix(tags);
const suffix = sidecarLanguageSuffix(tags, input.defaultSubtitleLang);
let dst = `${mainStem}${suffix}.${ext.toLowerCase()}`;
for (let n = 1; usedSidecarDst.has(dst); n++) dst = `${mainStem}${suffix}.${n}.${ext.toLowerCase()}`;
usedSidecarDst.add(dst);