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:
1 parent
36dc048e89
commit
2e2d56184c
21 files changed
+1970
-179
No files matched your search
@@ -13,4 +13,5 @@ export {
|
||||
planOrganize,
|
||||
type SourceFile,
|
||||
} from "./plan.ts";
|
||||
export { sidecarLanguageSuffix, sidecarStem } from "./sidecars.ts";
|
||||
export { type DefaultSubtitleLang, sidecarLanguageSuffix, sidecarStem } from "./sidecars.ts";
|
||||
export { findOtherVersions } from "./versions.ts";
|
||||
@@ -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);
|
||||
|
||||
@@ -67,8 +67,22 @@ export function sidecarStem(name: string): { stem: string; tags: string[] } {
|
||||
return { stem: stem.toLowerCase(), tags };
|
||||
}
|
||||
|
||||
/** 目标字幕后缀(对照 MP:繁体优先判定 → .zh-tw;简体 → .chi.zh-cn;英 → .eng;日 → .ja) */
|
||||
export function sidecarLanguageSuffix(tags: readonly string[]): string {
|
||||
/** 默认字幕语言(对照 MP DEFAULT_SUB):命中的随行字幕加 .default,Jellyfin/Plex/Emby 都认 */
|
||||
export type DefaultSubtitleLang = "zh-cn" | "zh-tw" | "en" | "ja" | "ko";
|
||||
|
||||
const DEFAULT_LANG_SUFFIX: Record<DefaultSubtitleLang, string> = {
|
||||
"zh-cn": ".chi.zh-cn",
|
||||
"zh-tw": ".zh-tw",
|
||||
en: ".eng",
|
||||
ja: ".ja",
|
||||
ko: ".ko",
|
||||
};
|
||||
|
||||
/**
|
||||
* 目标字幕后缀(对照 MP:繁体优先判定 → .zh-tw;简体 → .chi.zh-cn;英 → .eng;日 → .ja)。
|
||||
* 源文件自带 .default,或语言与 defaultLang 一致时加 .default。
|
||||
*/
|
||||
export function sidecarLanguageSuffix(tags: readonly string[], defaultLang?: DefaultSubtitleLang): string {
|
||||
const langs = tags.map((t) => LANG_TAG[t]);
|
||||
let s = "";
|
||||
if (langs.includes("zh-Hant")) s = ".zh-tw";
|
||||
@@ -76,7 +90,7 @@ export function sidecarLanguageSuffix(tags: readonly string[]): string {
|
||||
else if (langs.includes("en")) s = ".eng";
|
||||
else if (langs.includes("ja")) s = ".ja";
|
||||
else if (langs.includes("ko")) s = ".ko";
|
||||
if (tags.includes("default")) s += ".default";
|
||||
if (tags.includes("default") || (defaultLang && s !== "" && s === DEFAULT_LANG_SUFFIX[defaultLang])) s += ".default";
|
||||
if (tags.includes("forced")) s += ".forced";
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 迁移来源:MoviePilot transhandler.py __delete_version_files(latest 覆盖模式,e6820a8,issue 5449/5862)的判定口径;
|
||||
// 调整:只用于识别"同一位置的其它版本"作为处置候选,不直接删除(旧版本走保留策略/待处理)。
|
||||
import { fileKindOf } from "../release-parser/parse.ts";
|
||||
import { parseReleasePath } from "../release-parser/path.ts";
|
||||
import { basename } from "./sidecars.ts";
|
||||
|
||||
function versionKey(name: string): string | null {
|
||||
if (fileKindOf(name) !== "video") return null;
|
||||
const p = parseReleasePath(name);
|
||||
const ep = p.episodes ? `${p.episodes.start}-${p.episodes.end}` : "";
|
||||
return `${p.season ?? ""}|${ep}|${(p.part ?? "").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一目录下与 newFile 属于"同季 + 同集 + 同 Part"的其它视频版本(只看视频扩展名,字幕/音轨/nfo 一律不算)。
|
||||
* 电影(无季无集)按 Part 区分。newFile 自身与非视频文件不返回。
|
||||
*/
|
||||
export function findOtherVersions(newFile: string, siblings: readonly string[]): string[] {
|
||||
const self = basename(newFile);
|
||||
const key = versionKey(self);
|
||||
if (key === null) return [];
|
||||
return siblings.filter((s) => basename(s) !== self && versionKey(basename(s)) === key);
|
||||
}
|
||||
Reference in new issue
Block a user