- 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]>
97 lines
2.9 KiB
TypeScript
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;
|
|
}
|