277 lines
11 KiB
Python
277 lines
11 KiB
Python
import os
|
|
import re
|
|
import argparse
|
|
import shutil
|
|
from datetime import datetime
|
|
|
|
# ====================== 【配置区】 ======================
|
|
# 建议日志放在【本机内置磁盘】,不要放在容易掉线的外接卷,防止盘断开日志写失败
|
|
LOG_FILE = "./rename_log.txt"
|
|
|
|
#需要完全跳过的目录名称(basename,大小写敏感)
|
|
SKIP_DIRS = [
|
|
"@eaDir", # 群晖隐含目录
|
|
]
|
|
|
|
# 需要跳过的隐含文件名称(完整文件名匹配)
|
|
SKIP_FILES = {
|
|
".DS_Store",
|
|
"Thumbs.db",
|
|
"desktop.ini",
|
|
}
|
|
|
|
# 文件清洗规则:依旧删除全部 []【】
|
|
RULES_FILE = [
|
|
(r"^[A-Za-z] ", ""),
|
|
(r"\[.*?\]", ""),
|
|
(r"【.*?】", ""),
|
|
(r"丨", ""),
|
|
(r"\b(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|中英双语|双语字幕)\b", "", re.IGNORECASE),
|
|
(r"\.+", ""),
|
|
(r"\s", ""),
|
|
(r"[^\u4e00-\u9fa5a-zA-Z0-9().:+-]", ""),
|
|
]
|
|
|
|
|
|
# 1.删除中文全角【】
|
|
# 2.剥最外层英文[]
|
|
# 3.清除碎片 ][
|
|
# 4.最后清理遗留空括号 () ()
|
|
RULES_DIR = [
|
|
(r"【.*?】", ""), # 删除中文全角【】以及内部内容
|
|
(r"^\[(.*)\]$", r"\1"), # 整串被英文[]包裹,只剥外层
|
|
(r"\]\[", ""), # 清除中间碎片 ][
|
|
(r"^[A-Za-z] ", ""),
|
|
(r"丨", ""),
|
|
(r"\b(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|中英|双语|国语|国粤|中字|超清|高清|清晰|无水印|HD|BD|字幕)\b", "", re.IGNORECASE),
|
|
(r"\.+", ""),
|
|
(r"\s", ""),
|
|
(r"\(\)", ""), # 清除英文空括号 ()
|
|
(r"()", ""), # 清除中文全角空括号 ()
|
|
(r"[^\u4e00-\u9fa5a-zA-Z0-9().:+\-[\]]", ""), # 允许文件夹内部保留[]
|
|
]
|
|
|
|
#完整全部媒体后缀(不带--strm‑mode时使用)
|
|
FULL_ALLOW_EXT = {".mkv", ".mp4", ".mov", ".srt", ".ass", ".sub", ".strm", ".flv", ".avi", ".wmv", ".ts", ".rmvb", ".webm", ".mp3", ".flac", ".aac", ".wav", ".ogg", ".m4a", ".wma", ".alac", ".rm", ".iso"}
|
|
# strm模式:仅strm+字幕
|
|
STRM_ONLY_EXT = {".strm", ".srt", ".ass", ".sub"}
|
|
|
|
MAX_FILENAME_LEN = 245 # mac文件名最大安全长度,预留扩展名位置
|
|
# =======================================================
|
|
|
|
def full2half(s):
|
|
result = []
|
|
for char in s:
|
|
code = ord(char)
|
|
if code == 12288:
|
|
char = chr(32)
|
|
elif 65281 <= code <= 65374:
|
|
char = chr(code - 65248)
|
|
result.append(char)
|
|
return "".join(result)
|
|
|
|
def clean_file_name(name: str) -> str:
|
|
name_no_ext, ext = os.path.splitext(name)
|
|
name_no_ext = full2half(name_no_ext)
|
|
for item in RULES_FILE:
|
|
if len(item) == 3:
|
|
pat, repl, flag = item
|
|
name_no_ext = re.sub(pat, repl, name_no_ext, flags=flag)
|
|
else:
|
|
pat, repl = item
|
|
name_no_ext = re.sub(pat, repl, name_no_ext)
|
|
name_no_ext = name_no_ext.strip()
|
|
if len(name_no_ext) > MAX_FILENAME_LEN:
|
|
name_no_ext = name_no_ext[:MAX_FILENAME_LEN]
|
|
return name_no_ext + ext
|
|
|
|
def clean_dir_name(name: str) -> str:
|
|
name = full2half(name)
|
|
year_match = re.search(r"^\[(\d{4})\]$", name)
|
|
year_str = ""
|
|
if year_match:
|
|
year_str = year_match.group(1)
|
|
|
|
for item in RULES_DIR:
|
|
if len(item) == 3:
|
|
pat, repl, flag = item
|
|
name = re.sub(pat, repl, name, flags=flag)
|
|
else:
|
|
pat, repl = item
|
|
name = re.sub(pat, repl, name)
|
|
name = name.strip()
|
|
if year_str and name:
|
|
name = f"{name}({year_str})"
|
|
if len(name) > MAX_FILENAME_LEN:
|
|
name = name[:MAX_FILENAME_LEN]
|
|
return name
|
|
|
|
def get_quality_tag(filename:str):
|
|
m = re.search(r"\b(2160p|1080p|720p)\b", filename, re.IGNORECASE)
|
|
if m:
|
|
return m.group(1).lower()
|
|
return None
|
|
|
|
def resolve_conflict(dst_dir:str, base_name:str, ext:str, orig_filename:str):
|
|
candidate = base_name + ext
|
|
dst_path = os.path.join(dst_dir, candidate)
|
|
if not os.path.exists(dst_path):
|
|
return candidate
|
|
qual = get_quality_tag(orig_filename)
|
|
if qual:
|
|
candidate = f"{base_name}({qual}){ext}"
|
|
dst_path = os.path.join(dst_dir, candidate)
|
|
if not os.path.exists(dst_path):
|
|
return candidate
|
|
idx = 1
|
|
while True:
|
|
candidate = f"{base_name}({idx}){ext}"
|
|
dst_path = os.path.join(dst_dir, candidate)
|
|
if not os.path.exists(dst_path):
|
|
return candidate
|
|
idx +=1
|
|
|
|
def write_log(msg: str, log_file: str):
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
line = f"[{now}] {msg}"
|
|
print(line)
|
|
try:
|
|
with open(log_file, "a", encoding="utf-8") as f:
|
|
f.write(line + "\n")
|
|
f.flush()
|
|
except Exception:
|
|
#日志写入失败降级控制台输出,不打断程序
|
|
print(f"[LOG-WRITE-FAILED] {line}")
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="媒体文件名镜像清洗工具,源目录只读,生成镜像副本")
|
|
parser.add_argument("--dir", required=True, help="原始源媒体根目录")
|
|
parser.add_argument("--output", required=True, help="输出镜像根目录")
|
|
parser.add_argument("--apply", action="store_true", help="加上此参数才执行真实复制生成镜像")
|
|
parser.add_argument("--dry-run", action="store_true", help="预览模式(默认就是预览,该参数为显式简写)")
|
|
parser.add_argument("--strm-mode", action="store_true", help="开启strm模式:仅处理 .strm .srt .ass .sub;过滤mkv/mp4等视频文件")
|
|
parser.add_argument("--log", default="./rename_log.txt", help="日志文件路径,默认 ./rename_log.txt")
|
|
args = parser.parse_args()
|
|
|
|
target_dir = args.dir
|
|
out_dir = args.output
|
|
log_path = args.log
|
|
|
|
DRY_RUN = True
|
|
if args.apply:
|
|
DRY_RUN = False
|
|
|
|
if args.strm_mode:
|
|
ALLOW_EXT = STRM_ONLY_EXT
|
|
mode_info = "STRM模式【仅strm+字幕】"
|
|
else:
|
|
ALLOW_EXT = FULL_ALLOW_EXT
|
|
mode_info = "完整媒体模式【全部媒体后缀】"
|
|
|
|
if not os.path.isdir(target_dir):
|
|
print(f"错误:源目录不存在 -> {target_dir}")
|
|
return
|
|
|
|
#初始化日志
|
|
try:
|
|
with open(log_path, "w", encoding="utf-8") as f:
|
|
f.flush()
|
|
except Exception as e:
|
|
print(f"警告:无法初始化日志文件 {log_path},后续仅控制台输出日志,异常:{e}")
|
|
|
|
write_log("===== 文件名+文件夹清洗【增加清除空括号()()】 =====", log_path)
|
|
write_log(f"源目录:{target_dir}", log_path)
|
|
write_log(f"输出镜像目录:{out_dir}", log_path)
|
|
write_log(f"运行模式:{mode_info}", log_path)
|
|
write_log(f"预览模式 DRY_RUN = {DRY_RUN}", log_path)
|
|
if SKIP_DIRS:
|
|
write_log(f"配置跳过目录名列表:{SKIP_DIRS}", log_path)
|
|
write_log(f"过滤隐含系统文件:{SKIP_FILES}", log_path)
|
|
|
|
stat = {
|
|
"scan_dir_total":0,
|
|
"scan_file_total":0,
|
|
"media_file_total":0,
|
|
"file_success":0,
|
|
"file_fail":0,
|
|
"dir_success":0,
|
|
"dir_fail":0
|
|
}
|
|
|
|
try:
|
|
for root, dirs, files in os.walk(target_dir, topdown=False):
|
|
#过滤跳过目录:@eaDir、配置SKIP_DIRS
|
|
remove_idx = []
|
|
for idx, dname in enumerate(dirs):
|
|
if dname in SKIP_DIRS:
|
|
remove_idx.append(idx)
|
|
for idx in reversed(remove_idx):
|
|
del dirs[idx]
|
|
|
|
stat["scan_dir_total"] += 1
|
|
rel = os.path.relpath(root, target_dir)
|
|
if rel == ".":
|
|
dst_current_dir = out_dir
|
|
else:
|
|
src_basename = os.path.basename(root)
|
|
new_dir_name = clean_dir_name(src_basename)
|
|
if len(new_dir_name) == 0:
|
|
write_log(f"【警告】文件夹清洗后名称为空,使用原名保留:{src_basename}", log_path)
|
|
new_dir_name = src_basename
|
|
dst_current_dir = os.path.join(out_dir, rel.replace(src_basename, new_dir_name))
|
|
|
|
#创建输出目录
|
|
if not DRY_RUN:
|
|
try:
|
|
os.makedirs(dst_current_dir, exist_ok=True)
|
|
stat["dir_success"] +=1
|
|
except OSError as e:
|
|
stat["dir_fail"] +=1
|
|
write_log(f"【目录创建失败】{dst_current_dir} | {e}", log_path)
|
|
|
|
#处理本目录全部文件
|
|
for fname in files:
|
|
if fname in SKIP_FILES:
|
|
continue
|
|
stat["scan_file_total"] +=1
|
|
ext = os.path.splitext(fname)[1].lower()
|
|
if ext in ALLOW_EXT:
|
|
stat["media_file_total"] += 1
|
|
new_name = clean_file_name(fname)
|
|
old_path = os.path.join(root, fname)
|
|
base, ext_out = os.path.splitext(new_name)
|
|
final_fname = resolve_conflict(dst_current_dir, base, ext_out, fname)
|
|
dst_file = os.path.join(dst_current_dir, final_fname)
|
|
|
|
if final_fname != fname:
|
|
write_log(f"待修改文件-> {fname} → {final_fname}", log_path)
|
|
|
|
if not DRY_RUN:
|
|
try:
|
|
shutil.copy2(old_path, dst_file)
|
|
stat["file_success"] +=1
|
|
except OSError as e:
|
|
stat["file_fail"] +=1
|
|
write_log(f"【文件复制失败】src:{old_path} dst:{dst_file} | {e}", log_path)
|
|
|
|
#处理子文件夹,只打印名称发生变更的文件夹
|
|
for dname in dirs:
|
|
new_name = clean_dir_name(dname)
|
|
if len(new_name) == 0:
|
|
write_log(f"【警告】文件夹清洗后名称为空,跳过改名:{dname}", log_path)
|
|
continue
|
|
if new_name != dname:
|
|
write_log(f"待修改文件夹-> {dname} → {new_name}", log_path)
|
|
|
|
except OSError as walk_err:
|
|
write_log(f"【严重】遍历目录发生IO异常,部分目录未扫描:{walk_err}", log_path)
|
|
|
|
write_log("----------任务统计汇总----------", log_path)
|
|
write_log(f"总扫描目录:{stat['scan_dir_total']}", log_path)
|
|
write_log(f"总扫描文件:{stat['scan_file_total']},媒体文件:{stat['media_file_total']}", log_path)
|
|
write_log(f"目录:成功创建 {stat['dir_success']},失败 {stat['dir_fail']}", log_path)
|
|
write_log(f"文件:成功复制 {stat['file_success']},失败 {stat['file_fail']}", log_path)
|
|
write_log("============================================================", log_path)
|
|
|
|
if __name__ == "__main__":
|
|
main() |