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

+59 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { type OrganizeInput, planOrganize, sidecarLanguageSuffix, sidecarStem } from "../src/index.ts";
import {
findOtherVersions,
type OrganizeInput,
planOrganize,
sidecarLanguageSuffix,
sidecarStem,
} from "../src/index.ts";
const MiB = 1024 ** 2;
const GiB = 1024 ** 3;
@@ -236,3 +242,55 @@ describe("附属文件主干名", () => {
expect(sidecarLanguageSuffix([])).toBe("");
});
});
describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", () => {
it("defaultLang 命中的随行字幕加 .default;源自带 .default 保留;不命中不加", () => {
expect(sidecarLanguageSuffix(["chs"], "zh-cn")).toBe(".chi.zh-cn.default");
expect(sidecarLanguageSuffix(["cht"], "zh-cn")).toBe(".zh-tw");
expect(sidecarLanguageSuffix(["eng"], "en")).toBe(".eng.default");
expect(sidecarLanguageSuffix(["eng", "default"])).toBe(".eng.default");
expect(sidecarLanguageSuffix([], "zh-cn")).toBe("");
});
it("planOrganize 透传 defaultSubtitleLang", () => {
const plan = planOrganize({
operation: "copy",
mediaType: "movie",
files: [
{ path: "/dl/Movie.2020.1080p.BluRay.x264-GRP.mkv", size: 2e9 },
{ path: "/dl/Movie.2020.1080p.BluRay.x264-GRP.chs.srt", size: 1e4 },
{ path: "/dl/Movie.2020.1080p.BluRay.x264-GRP.eng.srt", size: 1e4 },
],
entity: { title: "Movie", year: 2020 },
target: { movieDir: "/lib/Movie (2020)" },
defaultSubtitleLang: "zh-cn",
});
const subs = plan.files.filter((f) => f.role === "sidecar").map((f) => f.dst.split("/").pop());
expect(subs.some((n) => n?.endsWith(".chi.zh-cn.default.srt"))).toBe(true);
expect(subs.some((n) => n?.endsWith(".eng.srt"))).toBe(true);
});
it("findOtherVersions:同季同集同 Part、只看视频扩展名", () => {
const sib = [
"/lib/S01/Show - S01E01 - 1080p.mkv",
"/lib/S01/Show - S01E01 - 2160p.mkv",
"/lib/S01/Show - S01E01 - 2160p.chi.zh-cn.srt",
"/lib/S01/Show - S01E01 - 720p.mp4",
"/lib/S01/Show - S01E02 - 1080p.mkv",
"/lib/S01/Show - S01E01 - 1080p.nfo",
];
expect(findOtherVersions("/lib/S01/Show - S01E01 - 2160p.mkv", sib).sort()).toEqual([
"/lib/S01/Show - S01E01 - 1080p.mkv",
"/lib/S01/Show - S01E01 - 720p.mp4",
]);
expect(findOtherVersions("/lib/S01/Show - S01E01 - 2160p.chi.zh-cn.srt", sib)).toEqual([]);
const movie = [
"/lib/M (2020)/M (2020) - 1080p.mkv",
"/lib/M (2020)/M (2020) - 2160p.mkv",
"/lib/M (2020)/M (2020) - 1080p.srt",
];
expect(findOtherVersions("/lib/M (2020)/M (2020) - 2160p.mkv", movie)).toEqual([
"/lib/M (2020)/M (2020) - 1080p.mkv",
]);
});
});