Files
UHD_Media_Tool/packages/domain/src/organize/sidecars.ts
T
rootandClaude Opus 5.5 2e2d56184c 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]>
2026-09-27 23:15:05 +08:00

97 lines
2.9 KiB
TypeScript

/** 附属文件(字幕/外挂音轨)的语言标签,按主干名剥离后与主视频配对 */
const LANG_TAG: Record<string, "zh-Hans" | "zh-Hant" | "en" | "ja" | "ko" | "flag"> = {
chs: "zh-Hans",
sc: "zh-Hans",
gb: "zh-Hans",
chi: "zh-Hans",
zho: "zh-Hans",
zh: "zh-Hans",
"zh-cn": "zh-Hans",
"zh-hans": "zh-Hans",
"zh-sg": "zh-Hans",
简体: "zh-Hans",
简中: "zh-Hans",
简: "zh-Hans",
cht: "zh-Hant",
tc: "zh-Hant",
big5: "zh-Hant",
"zh-tw": "zh-Hant",
"zh-hk": "zh-Hant",
"zh-hant": "zh-Hant",
繁体: "zh-Hant",
繁體: "zh-Hant",
繁中: "zh-Hant",
繁: "zh-Hant",
eng: "en",
en: "en",
english: "en",
jpn: "ja",
ja: "ja",
jp: "ja",
kor: "ko",
ko: "ko",
default: "flag",
forced: "flag",
sdh: "flag",
cc: "flag",
简英: "zh-Hans",
繁英: "zh-Hant",
中英: "zh-Hans",
双语: "zh-Hans",
简繁: "zh-Hans",
};
export const basename = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p;
export const dirname = (p: string) => {
const s = p.replace(/\\/g, "/");
const i = s.lastIndexOf("/");
return i <= 0 ? (i === 0 ? "/" : "") : s.slice(0, i);
};
export const splitExt = (name: string): [string, string] => {
const i = name.lastIndexOf(".");
return i > 0 ? [name.slice(0, i), name.slice(i + 1)] : [name, ""];
};
/** 字幕主干名:去扩展名后逐级剥掉 .chs/.eng/.default 等语言标签,返回小写主干与剥下的标签 */
export function sidecarStem(name: string): { stem: string; tags: string[] } {
let [stem] = splitExt(basename(name));
const tags: string[] = [];
for (;;) {
const i = stem.lastIndexOf(".");
if (i <= 0) break;
const tag = stem.slice(i + 1).toLowerCase();
if (!(tag in LANG_TAG)) break;
tags.unshift(tag);
stem = stem.slice(0, i);
}
return { stem: stem.toLowerCase(), tags };
}
/** 默认字幕语言(对照 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";
else if (langs.includes("zh-Hans")) s = ".chi.zh-cn";
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") || (defaultLang && s !== "" && s === DEFAULT_LANG_SUFFIX[defaultLang])) s += ".default";
if (tags.includes("forced")) s += ".forced";
return s;
}