Files
ConfigData/rename-media/oldversion/media_rename.py
CrazyJasonwell 290ff8984f commit
2026-10-07 11:07:00 +08:00

277 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()