commit
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"file_rules": [
|
||||
{
|
||||
"rule_id": "F001",
|
||||
"order": 1,
|
||||
"kind": "bracket_pair",
|
||||
"open": "\\[",
|
||||
"close": "\\]",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "F002",
|
||||
"order": 2,
|
||||
"kind": "bracket_pair",
|
||||
"open": "【",
|
||||
"close": "】",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "F003",
|
||||
"order": 3,
|
||||
"kind": "string",
|
||||
"pattern": "2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB\\-DL|HD|4K|REMUX|RMVB|HDR10Plus|10bit|60fps",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "F004",
|
||||
"order": 4,
|
||||
"kind": "string",
|
||||
"pattern": "双语字幕|双语|中英双语|中英双字|双语中字|官方中字|特效中字",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "F005",
|
||||
"order": 5,
|
||||
"kind": "string",
|
||||
"pattern": "CMCT|WiKi|CHD|SiluHD|PublicHD|RARBG|TGx|‑HDHome|52pt|HHWEB|ZmWeb|ADWeb|BT‑PT|‑CHD|‑WiKi|‑RARBG|‑HHWEB|‑ZmWeb|‑PublicHD|‑52pt|‑CiNEFiLE",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "F006",
|
||||
"order": 6,
|
||||
"kind": "string",
|
||||
"pattern": "收藏不迷路|地址发布页|九洲客|tdw9430|6v电影|电影港|www\\.6v123\\.net|www\\.dygang\\.me|www\\.dream2008\\.cn|www\\.6v123\\.com",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "F007",
|
||||
"order": 7,
|
||||
"kind": "string",
|
||||
"pattern": "\\d+\\.5G|\\d+G|MKV|TS",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
}
|
||||
],
|
||||
"dir_rules": [
|
||||
{
|
||||
"rule_id": "D001",
|
||||
"order": 1,
|
||||
"kind": "string",
|
||||
"pattern": "2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB\\-DL|HD|4K",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "D002",
|
||||
"order": 2,
|
||||
"kind": "string",
|
||||
"pattern": "双语字幕|双语|中英双语|中英双字|双语中字|官方中字|特效中字",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "D003",
|
||||
"order": 3,
|
||||
"kind": "string",
|
||||
"pattern": "收藏不迷路|地址发布页|九洲客|tdw9430|6v电影|电影港|www\\.6v123\\.net|www\\.dygang\\.me|www\\.dream2008\\.cn|www\\.6v123\\.com",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"file_rules": [
|
||||
{
|
||||
"rule_id": "F001",
|
||||
"order": 1,
|
||||
"kind": "bracket_pair",
|
||||
"open": "\\[",
|
||||
"close": "\\]",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "F002",
|
||||
"order": 2,
|
||||
"kind": "bracket_pair",
|
||||
"open": "【",
|
||||
"close": "】",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "F003",
|
||||
"order": 3,
|
||||
"kind": "string",
|
||||
"pattern": "2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB\\-DL|HD|4K|REMUX|RMVB|HDR10Plus|10bit|60fps",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "F004",
|
||||
"order": 4,
|
||||
"kind": "string",
|
||||
"pattern": "双语字幕|双语|中英双语|中英双字|双语中字|官方中字|特效中字",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "F005",
|
||||
"order": 5,
|
||||
"kind": "string",
|
||||
"pattern": "CMCT|WiKi|CHD|SiluHD|PublicHD|RARBG|TGx|‑HDHome|52pt|HHWEB|ZmWeb|ADWeb|BT‑PT|‑CHD|‑WiKi|‑RARBG|‑HHWEB|‑ZmWeb|‑PublicHD|‑52pt|‑CiNEFiLE",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "F006",
|
||||
"order": 6,
|
||||
"kind": "string",
|
||||
"pattern": "收藏不迷路|地址发布页|九洲客|tdw9430|6v电影|电影港|www\\.6v123\\.net|www\\.dygang\\.me|www\\.dream2008\\.cn|www\\.6v123\\.com",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "F007",
|
||||
"order": 7,
|
||||
"kind": "string",
|
||||
"pattern": "\\d+\\.5G|\\d+G|MKV|TS",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "F013",
|
||||
"order": 13,
|
||||
"kind": "string",
|
||||
"pattern": "【[^】]*】",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "F014",
|
||||
"order": 14,
|
||||
"kind": "string",
|
||||
"pattern": "\\[\\]",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "F015",
|
||||
"order": 15,
|
||||
"kind": "string",
|
||||
"pattern": "【】",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
}
|
||||
],
|
||||
"dir_rules": [
|
||||
{
|
||||
"rule_id": "D001",
|
||||
"order": 1,
|
||||
"kind": "string",
|
||||
"pattern": "2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB\\-DL|HD|4K",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "D002",
|
||||
"order": 2,
|
||||
"kind": "string",
|
||||
"pattern": "双语字幕|双语|中英双语|中英双字|双语中字|官方中字|特效中字",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true,
|
||||
"flags": "IGNORECASE"
|
||||
},
|
||||
{
|
||||
"rule_id": "D003",
|
||||
"order": 3,
|
||||
"kind": "string",
|
||||
"pattern": "收藏不迷路|地址发布页|九洲客|tdw9430|6v电影|电影港|www\\.6v123\\.net|www\\.dygang\\.me|www\\.dream2008\\.cn|www\\.6v123\\.com",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "D006",
|
||||
"order": 6,
|
||||
"kind": "string",
|
||||
"pattern": "【[^】]*】",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "D007",
|
||||
"order": 7,
|
||||
"kind": "string",
|
||||
"pattern": "\\[\\]",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
},
|
||||
{
|
||||
"rule_id": "D008",
|
||||
"order": 8,
|
||||
"kind": "string",
|
||||
"pattern": "【】",
|
||||
"repl": "",
|
||||
"safe_guard_empty": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"source": "/vol4/1000/USBD2/MediaLibrary/output-two",
|
||||
"dest": "/vol4/1000/USBD2/MediaLibrary/out-final",
|
||||
"tmdb_api_key": "",
|
||||
"mock": false,
|
||||
"settle": 6,
|
||||
"language": "zh-CN"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Self Media Scraper Watcher (TMDB)
|
||||
# 用途:开机自启,监听清理目录,新媒体出现自动刮削并复制落库
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# ---- 下面 3 行把 /你的项目绝对路径 替换成飞牛上放本项目的真实路径 ----
|
||||
ExecStart=/你的项目绝对路径/.venv/bin/python /你的项目绝对路径/watcher.py --config /你的项目绝对路径/config.json
|
||||
WorkingDirectory=/你的项目绝对路径
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:/你的项目绝对路径/scraper.out
|
||||
StandardError=append:/你的项目绝对路径/scraper.err
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+449
@@ -0,0 +1,449 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import argparse
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
LOG_FILE = "./rename_log.txt"
|
||||
ERR_REPORT_JSON = "./report_err_only.json"
|
||||
|
||||
SKIP_DIRS = [
|
||||
"@eaDir",
|
||||
]
|
||||
SKIP_FILES = {
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"desktop.ini",
|
||||
}
|
||||
|
||||
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_ONLY_EXT = {".strm", ".srt", ".ass", ".sub"}
|
||||
MAX_FILENAME_LEN = 245
|
||||
|
||||
def full2half(s: str) -> str:
|
||||
res = []
|
||||
for ch in s:
|
||||
oc = ord(ch)
|
||||
if oc == 12288:
|
||||
res.append(chr(32))
|
||||
elif 65281 <= oc <= 65374:
|
||||
res.append(chr(oc - 65248))
|
||||
else:
|
||||
res.append(ch)
|
||||
return "".join(res)
|
||||
|
||||
def remove_emoji(s: str) -> str:
|
||||
emoji_pattern = re.compile("["
|
||||
u"\U0001F600-\U0001F64F"
|
||||
u"\U0001F300-\U0001F5FF"
|
||||
u"\U0001F680-\U0001F6FF"
|
||||
u"\U0001F1E0-\U0001F1FF"
|
||||
u"\U00002700-\U000027BF"
|
||||
u"\U0001F900-\U0001F9FF"
|
||||
"]+", flags=re.UNICODE)
|
||||
return emoji_pattern.sub(r'', s)
|
||||
|
||||
def post_clean_dot(name: str, is_dir: bool=False) -> str:
|
||||
"""后置净化:清除连续点、首尾点、首尾下划线;区分目录/文件,保护扩展名"""
|
||||
if is_dir:
|
||||
body = name
|
||||
ext = ""
|
||||
else:
|
||||
body, ext = os.path.splitext(name)
|
||||
body = re.sub(r"\.+", ".", body)
|
||||
body = body.strip("._")
|
||||
return body + ext
|
||||
|
||||
def stage1_light_clean(name: str, is_dir: bool=False) -> str:
|
||||
"""阶段一:轻清洗"""
|
||||
n = name
|
||||
n = remove_emoji(n)
|
||||
n = full2half(n)
|
||||
n = re.sub(r"^[A-Za-z] +", "", n)
|
||||
n = re.sub(r"\s+", "", n)
|
||||
n = post_clean_dot(n, is_dir=is_dir)
|
||||
return n.strip()
|
||||
|
||||
def run_rule_list(orig_name: str, rules: list, is_dir: bool=False):
|
||||
hit_ids = []
|
||||
cur = orig_name
|
||||
safeguard_trigger = False
|
||||
|
||||
for r in sorted(rules, key=lambda x: x.get("order", 9999)):
|
||||
rid = r["rule_id"]
|
||||
kind = r["kind"]
|
||||
repl = r["repl"]
|
||||
safe_guard = r.get("safe_guard_empty", False)
|
||||
rule_flags = r.get("flags", "")
|
||||
prev = cur
|
||||
|
||||
re_flags = 0
|
||||
if rule_flags == "IGNORECASE":
|
||||
re_flags = re.IGNORECASE
|
||||
|
||||
if kind == "string":
|
||||
cur = re.sub(r["pattern"], repl, cur, flags=re_flags)
|
||||
elif kind == "bracket_pair":
|
||||
o_pat = r["open"]
|
||||
c_pat = r["close"]
|
||||
# bracket_pair额外开启DOTALL,.可匹配中文、各类符号
|
||||
while True:
|
||||
new_val = re.sub(o_pat + ".*?" + c_pat, repl, cur, flags=re_flags | re.DOTALL)
|
||||
if new_val == cur:
|
||||
break
|
||||
cur = new_val
|
||||
if safe_guard:
|
||||
body, ext = (cur, "") if is_dir else os.path.splitext(cur)
|
||||
if len(body.strip()) == 0:
|
||||
cur = prev
|
||||
continue
|
||||
|
||||
cur = cur.strip()
|
||||
if safe_guard:
|
||||
body, ext = (cur, "") if is_dir else os.path.splitext(cur)
|
||||
if len(body.strip()) == 0:
|
||||
cur = prev
|
||||
continue
|
||||
|
||||
if cur != prev:
|
||||
hit_ids.append(rid)
|
||||
|
||||
# 全部规则跑完,全局兜底保护
|
||||
body_final, ext_final = (cur, "") if is_dir else os.path.splitext(cur)
|
||||
if len(body_final.strip()) == 0:
|
||||
safeguard_trigger = True
|
||||
cur = orig_name
|
||||
|
||||
# 符号净化
|
||||
cur = post_clean_dot(cur, is_dir=is_dir)
|
||||
|
||||
# 净化之后再次兜底
|
||||
body2, ext2 = (cur, "") if is_dir else os.path.splitext(cur)
|
||||
if len(body2.strip()) == 0:
|
||||
safeguard_trigger = True
|
||||
cur = orig_name
|
||||
|
||||
return cur, hit_ids, safeguard_trigger
|
||||
|
||||
def assign_unique_name_group(item_list):
|
||||
counter = dict()
|
||||
out = []
|
||||
for it in item_list:
|
||||
cand = it["candidate"]
|
||||
cnt = counter.get(cand, 0)
|
||||
final = cand if cnt == 0 else f"{cand}({cnt})"
|
||||
counter[cand] = cnt + 1
|
||||
ni = dict(it)
|
||||
ni["final_step1"] = final
|
||||
out.append(ni)
|
||||
return out
|
||||
|
||||
def assign_dup_suffix(item_list, name_key: str):
|
||||
counter = dict()
|
||||
for it in item_list:
|
||||
nm = it[name_key]
|
||||
c = counter.get(nm, 0)
|
||||
if c > 0:
|
||||
new_nm = f"{nm}_dup({c})"
|
||||
it[name_key] = new_nm
|
||||
it["step2_dup_warn"] = True
|
||||
else:
|
||||
it["step2_dup_warn"] = False
|
||||
counter[nm] = c + 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 scan_full_tree(root_src, skip_dirs, skip_files, allow_ext):
|
||||
dir_entries = []
|
||||
file_entries = []
|
||||
root_abs = os.path.abspath(root_src)
|
||||
for src_root, dirs, files in os.walk(root_src, topdown=True):
|
||||
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]
|
||||
dirs[:] = dirs
|
||||
src_root_abs = os.path.abspath(src_root)
|
||||
if src_root_abs != root_abs:
|
||||
bn = os.path.basename(src_root_abs)
|
||||
c1 = stage1_light_clean(bn, is_dir=True)
|
||||
dir_entries.append({
|
||||
"src_abs": src_root_abs,
|
||||
"src_parent_abs": os.path.dirname(src_root_abs),
|
||||
"depth": src_root_abs.count(os.sep),
|
||||
"orig_basename": bn,
|
||||
"candidate": c1
|
||||
})
|
||||
for fname in files:
|
||||
if fname in skip_files:
|
||||
continue
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext not in allow_ext:
|
||||
continue
|
||||
c1 = stage1_light_clean(fname, is_dir=False)
|
||||
file_entries.append({
|
||||
"src_parent_abs": src_root_abs,
|
||||
"orig_basename": fname,
|
||||
"candidate": c1
|
||||
})
|
||||
return root_abs, dir_entries, file_entries
|
||||
|
||||
def group_by_parent_abs(item_list, key_name):
|
||||
groups = dict()
|
||||
for it in item_list:
|
||||
k = it[key_name]
|
||||
if k not in groups:
|
||||
groups[k] = []
|
||||
groups[k].append(it)
|
||||
return groups
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="两阶段媒体清洗【修复括号匹配,DOTALL开启】")
|
||||
parser.add_argument("--dir", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--strm-mode", action="store_true")
|
||||
parser.add_argument("--rules", default=None, help="外部json规则文件路径")
|
||||
parser.add_argument("--log", default=LOG_FILE)
|
||||
args = parser.parse_args()
|
||||
|
||||
target_dir = args.dir
|
||||
out_dir = args.output
|
||||
log_path = args.log
|
||||
rules_path = args.rules
|
||||
|
||||
DRY_RUN = True
|
||||
if args.apply:
|
||||
DRY_RUN = False
|
||||
if args.dry_run:
|
||||
DRY_RUN = True
|
||||
|
||||
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"日志初始化警告 {e}")
|
||||
|
||||
file_rules = []
|
||||
dir_rules = []
|
||||
if rules_path is not None and os.path.exists(rules_path):
|
||||
try:
|
||||
with open(rules_path, "r", encoding="utf-8") as f:
|
||||
jdata = json.load(f)
|
||||
file_rules = jdata.get("file_rules", [])
|
||||
dir_rules = jdata.get("dir_rules", [])
|
||||
write_log(f"成功加载外部规则文件 {rules_path},file_rules={len(file_rules)},dir_rules={len(dir_rules)}", log_path)
|
||||
except json.JSONDecodeError as e:
|
||||
write_log(f"规则文件JSON格式错误(第{e.lineno}行第{e.colno}列):{e.msg}", log_path)
|
||||
write_log("提示:键和字符串值必须用英文双引号,正则中的反斜杠必须写成双反斜杠\\\\", log_path)
|
||||
return
|
||||
except Exception as e:
|
||||
write_log(f"加载规则文件失败:{e}", log_path)
|
||||
return
|
||||
else:
|
||||
write_log("未提供--rules或文件不存在,跳过第二阶段深度清洗", log_path)
|
||||
|
||||
write_log("===== 两阶段媒体清洗【修复括号匹配DOTALL】 =====", log_path)
|
||||
write_log(f"源目录:{target_dir}", log_path)
|
||||
write_log(f"输出:{out_dir}", log_path)
|
||||
write_log(f"模式:{mode_info} DRY_RUN={DRY_RUN}", log_path)
|
||||
|
||||
stat = {
|
||||
"src_dir_total":0,
|
||||
"src_file_total":0,
|
||||
"dir_success":0,
|
||||
"dir_fail":0,
|
||||
"file_success":0,
|
||||
"file_fail":0
|
||||
}
|
||||
report_entries = []
|
||||
|
||||
root_src_abs, dir_entries, file_entries = scan_full_tree(target_dir, SKIP_DIRS, SKIP_FILES, ALLOW_EXT)
|
||||
stat["src_dir_total"] = len(dir_entries)
|
||||
stat["src_file_total"] = len(file_entries)
|
||||
write_log(f"采集统计:目录节点 {stat['src_dir_total']},媒体文件 {stat['src_file_total']}", log_path)
|
||||
|
||||
dir_groups = group_by_parent_abs(dir_entries, "src_parent_abs")
|
||||
dir_final = []
|
||||
for p_abs, items in dir_groups.items():
|
||||
assigned = assign_unique_name_group(items)
|
||||
for it in assigned:
|
||||
step1_name = it["final_step1"]
|
||||
if len(dir_rules) > 0:
|
||||
step2_name, hit_rids, safe_trig = run_rule_list(step1_name, dir_rules, is_dir=True)
|
||||
else:
|
||||
step2_name = step1_name
|
||||
hit_rids = []
|
||||
safe_trig = False
|
||||
it["step2_name"] = step2_name
|
||||
it["hit_rule_ids"] = hit_rids
|
||||
it["safe_guard_trigger"] = safe_trig
|
||||
dir_final.append(it)
|
||||
dir_g2_groups = group_by_parent_abs(dir_final, "src_parent_abs")
|
||||
for _, items in dir_g2_groups.items():
|
||||
assign_dup_suffix(items, "step2_name")
|
||||
|
||||
file_groups = group_by_parent_abs(file_entries, "src_parent_abs")
|
||||
file_final = []
|
||||
for p_abs, items in file_groups.items():
|
||||
assigned = assign_unique_name_group(items)
|
||||
for it in assigned:
|
||||
step1_name = it["final_step1"]
|
||||
if len(file_rules) > 0:
|
||||
step2_name, hit_rids, safe_trig = run_rule_list(step1_name, file_rules, is_dir=False)
|
||||
else:
|
||||
step2_name = step1_name
|
||||
hit_rids = []
|
||||
safe_trig = False
|
||||
it["step2_name"] = step2_name
|
||||
it["hit_rule_ids"] = hit_rids
|
||||
it["safe_guard_trigger"] = safe_trig
|
||||
file_final.append(it)
|
||||
file_g2_groups = group_by_parent_abs(file_final, "src_parent_abs")
|
||||
for _, items in file_g2_groups.items():
|
||||
assign_dup_suffix(items, "step2_name")
|
||||
|
||||
src_to_out = dict()
|
||||
src_to_out[root_src_abs] = out_dir
|
||||
dir_final_sorted = sorted(dir_final, key=lambda x: x["depth"])
|
||||
for d in dir_final_sorted:
|
||||
p_abs = d["src_parent_abs"]
|
||||
parent_out = src_to_out.get(p_abs)
|
||||
if parent_out is None:
|
||||
write_log(f"【警告】父目录映射缺失:{p_abs}", log_path)
|
||||
continue
|
||||
child_out = os.path.join(parent_out, d["step2_name"])
|
||||
src_to_out[d["src_abs"]] = child_out
|
||||
|
||||
for d in dir_final:
|
||||
report_entries.append({
|
||||
"type":"dir",
|
||||
"src_abs":d["src_abs"],
|
||||
"orig_basename":d["orig_basename"],
|
||||
"step1_name":d["final_step1"],
|
||||
"step2_name":d["step2_name"],
|
||||
"hit_rule_ids":d["hit_rule_ids"],
|
||||
"safe_guard_trigger":d["safe_guard_trigger"],
|
||||
"step2_dup_warn":d.get("step2_dup_warn", False),
|
||||
"final_out_path": src_to_out.get(d["src_abs"], "")
|
||||
})
|
||||
for f in file_final:
|
||||
src_file = os.path.join(f["src_parent_abs"], f["orig_basename"])
|
||||
parent_out = src_to_out.get(f["src_parent_abs"])
|
||||
dst_file = os.path.join(parent_out, f["step2_name"]) if parent_out else ""
|
||||
report_entries.append({
|
||||
"type":"file",
|
||||
"src_abs":src_file,
|
||||
"orig_basename":f["orig_basename"],
|
||||
"step1_name":f["final_step1"],
|
||||
"step2_name":f["step2_name"],
|
||||
"hit_rule_ids":f["hit_rule_ids"],
|
||||
"safe_guard_trigger":f["safe_guard_trigger"],
|
||||
"step2_dup_warn":f.get("step2_dup_warn", False),
|
||||
"final_out_path": dst_file
|
||||
})
|
||||
|
||||
# 仅输出异常报告,丢弃完整大JSON
|
||||
err_list = []
|
||||
for entry in report_entries:
|
||||
dup = entry.get("step2_dup_warn", False)
|
||||
safe = entry.get("safe_guard_trigger", False)
|
||||
if dup or safe:
|
||||
err_list.append({
|
||||
"type": entry.get("type"),
|
||||
"orig_basename": entry.get("orig_basename"),
|
||||
"step1_name": entry.get("step1_name"),
|
||||
"step2_name": entry.get("step2_name"),
|
||||
"step2_dup_warn": dup,
|
||||
"safe_guard_trigger": safe,
|
||||
"hit_rule_ids": entry.get("hit_rule_ids", [])
|
||||
})
|
||||
try:
|
||||
with open(ERR_REPORT_JSON, "w", encoding="utf-8") as fw:
|
||||
json.dump(err_list, fw, ensure_ascii=False, indent=2)
|
||||
write_log(f"异常报告已输出:{ERR_REPORT_JSON},异常条目数量:{len(err_list)}", log_path)
|
||||
except Exception as e:
|
||||
write_log(f"异常报告写入失败 {e}", log_path)
|
||||
|
||||
for ent in report_entries:
|
||||
ot = ent["orig_basename"]
|
||||
s1 = ent["step1_name"]
|
||||
s2 = ent["step2_name"]
|
||||
parts = []
|
||||
if ot != s1:
|
||||
parts.append(f"原始→step1:{ot} → {s1}")
|
||||
if s1 != s2:
|
||||
parts.append(f"step1→step2:{s1} → {s2}")
|
||||
if ent["step2_dup_warn"]:
|
||||
parts.append("[告警:step2重名追加_dup标记]")
|
||||
if ent["safe_guard_trigger"]:
|
||||
parts.append("[安全保护触发,放弃部分规则]")
|
||||
if len(parts) > 0:
|
||||
write_log(" | ".join(parts), log_path)
|
||||
|
||||
del report_entries
|
||||
|
||||
if DRY_RUN:
|
||||
write_log("===== DRY-RUN预览模式结束,未执行磁盘IO =====", log_path)
|
||||
return
|
||||
|
||||
for d in dir_final_sorted:
|
||||
full_out = src_to_out.get(d["src_abs"])
|
||||
if not full_out:
|
||||
continue
|
||||
try:
|
||||
os.makedirs(full_out, exist_ok=True)
|
||||
stat["dir_success"] +=1
|
||||
except OSError as e:
|
||||
stat["dir_fail"] +=1
|
||||
write_log(f"【目录创建失败】{full_out} | {e}", log_path)
|
||||
|
||||
for f in file_final:
|
||||
src_fp = os.path.join(f["src_parent_abs"], f["orig_basename"])
|
||||
parent_out = src_to_out.get(f["src_parent_abs"])
|
||||
if not parent_out:
|
||||
stat["file_fail"] +=1
|
||||
write_log(f"【文件复制失败】父目录映射缺失:{src_fp}", log_path)
|
||||
continue
|
||||
dst_fp = os.path.join(parent_out, f["step2_name"])
|
||||
try:
|
||||
shutil.copy2(src_fp, dst_fp)
|
||||
stat["file_success"] +=1
|
||||
except OSError as e:
|
||||
stat["file_fail"] +=1
|
||||
write_log(f"【文件复制失败】src:{src_fp} dst:{dst_fp} | {e}", log_path)
|
||||
|
||||
write_log("----------任务统计汇总----------", log_path)
|
||||
write_log(f"源采集目录节点总数量:{stat['src_dir_total']},源媒体文件总数量:{stat['src_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(f"【校验】预期目录输出={stat['src_dir_total']},实际成功={stat['dir_success']}", log_path)
|
||||
write_log(f"【校验】预期文件输出={stat['src_file_total']},实际成功={stat['file_success']}", log_path)
|
||||
write_log("============================================================", log_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
media_scraper.py —— 自研媒体刮削器(核心库 + 命令行)
|
||||
|
||||
流程:接收一个媒体单元(单个电影文件 / 电影目录 / 剧集目录)
|
||||
1. 从文件名/目录名解析标题、年份、类型(电影/剧集)、季/集
|
||||
2. 查询 TMDB(电影 search/movie,剧集 search/tv)
|
||||
3. 把媒体文件「复制」到成品目录(落库)
|
||||
4. 在成品目录就地生成 Kodi 规范的 nfo + 下载海报/背景图
|
||||
|
||||
命令行用法:
|
||||
./media_scraper.py --source <路径> --dest <成品目录> [--config config.json] [--mock] [--dry-run] [--recurse]
|
||||
|
||||
依赖:requests(TMDB 调用)。TMDB API Key 通过 config.json 或环境变量 TMDB_API_KEY 提供。
|
||||
未配置 Key 时可用 --mock 模式跑通整条管线(用模拟元数据,验证 nfo 生成 + 复制落库)。
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 常量与配置
|
||||
# ---------------------------------------------------------------------------
|
||||
VIDEO_EXTS = {".mkv", ".mp4", ".mov", ".avi", ".wmv", ".flv", ".ts", ".webm",
|
||||
".rmvb", ".iso", ".m2ts", ".mts"}
|
||||
SUB_EXTS = {".srt", ".ass", ".sub", ".ssa"}
|
||||
|
||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
|
||||
TMDB_IMAGE_ORIG = "https://image.tmdb.org/t/p/original"
|
||||
DEFAULT_LANGUAGE = "zh-CN" # 刮削文案语言,可按需改
|
||||
FALLBACK_LANGUAGE = "en-US"
|
||||
|
||||
|
||||
class Config:
|
||||
"""从 config.json / 环境变量读取配置。"""
|
||||
|
||||
def __init__(self, path=None):
|
||||
self.tmdb_api_key = os.environ.get("TMDB_API_KEY", "")
|
||||
self.language = DEFAULT_LANGUAGE
|
||||
self.mock = False
|
||||
self.timeout = 20
|
||||
if path and os.path.isfile(path):
|
||||
data = json.load(open(path, encoding="utf-8"))
|
||||
self.tmdb_api_key = data.get("tmdb_api_key", self.tmdb_api_key)
|
||||
self.language = data.get("language", self.language)
|
||||
self.mock = data.get("mock", self.mock)
|
||||
self.timeout = data.get("timeout", self.timeout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 文件名解析
|
||||
# ---------------------------------------------------------------------------
|
||||
def clean_ext(name):
|
||||
return os.path.splitext(name)[1].lower()
|
||||
|
||||
|
||||
def parse_media_name(name):
|
||||
"""从单个文件名或目录名解析媒体信息。
|
||||
|
||||
返回 dict:
|
||||
kind: "movie" | "tv"
|
||||
title: 标题(尽量去除年份/季集标记)
|
||||
year: 年份(int) 或 None
|
||||
season/episode: 剧集时存在
|
||||
"""
|
||||
base = os.path.basename(name)
|
||||
base = os.path.splitext(base)[0]
|
||||
info = {"kind": "movie", "title": base, "year": None,
|
||||
"season": None, "episode": None}
|
||||
|
||||
# 剧集标记:S01E02 / s1e2 / 第01集 / E01 / 第 1 季 第 2 集
|
||||
m = re.search(r"[Ss](\d{1,2})[Ee](\d{1,3})", base) # SxxExx
|
||||
if m:
|
||||
info["kind"] = "tv"
|
||||
info["season"] = int(m.group(1))
|
||||
info["episode"] = int(m.group(2))
|
||||
else:
|
||||
m = re.search(r"第\s*(\d{1,2})\s*[集話话]", base) # 第01集
|
||||
if m:
|
||||
info["kind"] = "tv"
|
||||
info["season"] = 1
|
||||
info["episode"] = int(m.group(1))
|
||||
else:
|
||||
m2 = re.search(r"[Ee](\d{1,3})", base) # E01
|
||||
if m2:
|
||||
info["kind"] = "tv"
|
||||
info["season"] = 1
|
||||
info["episode"] = int(m2.group(1))
|
||||
|
||||
# 年份
|
||||
m = re.search(r"\((\d{4})\)", base) or re.search(r"(\b19\d{2}\b|\b20\d{2}\b)", base)
|
||||
if m:
|
||||
info["year"] = int(m.group(1))
|
||||
base = base.replace(f"({m.group(1)})", "")
|
||||
|
||||
# 去除季集标记,得到标题
|
||||
base = re.sub(r"[Ss]\d{1,2}[Ee]\d{1,3}", "", base)
|
||||
base = re.sub(r"第\s*\d{1,2}\s*[集話话]", "", base)
|
||||
base = re.sub(r"[Ee]\d{1,3}", "", base)
|
||||
base = re.sub(r"\((\d{4})\)", "", base)
|
||||
base = re.sub(r"(\b19\d{2}\b|\b20\d{2}\b)", "", base)
|
||||
base = re.sub(r"[\s._\-]+", " ", base).strip()
|
||||
info["title"] = base if base else info["title"]
|
||||
return info
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TMDB 客户端
|
||||
# ---------------------------------------------------------------------------
|
||||
class TMDBApi:
|
||||
def __init__(self, config):
|
||||
self.cfg = config
|
||||
self.sess = requests.Session()
|
||||
self.sess.headers.update({
|
||||
"Authorization": f"Bearer {config.tmdb_api_key}",
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
})
|
||||
self.base = "https://api.themoviedb.org/3"
|
||||
|
||||
def _get(self, path, params):
|
||||
if not self.cfg.tmdb_api_key:
|
||||
raise RuntimeError("未配置 TMDB API Key(config.json 或环境变量 TMDB_API_KEY)")
|
||||
r = self.sess.get(f"{self.base}/{path}", params=params, timeout=self.cfg.timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def search(self, kind, title, year=None):
|
||||
path = "search/movie" if kind == "movie" else "search/tv"
|
||||
params = {"query": title, "language": self.cfg.language}
|
||||
if year:
|
||||
params["year" if kind == "movie" else "first_air_date_year"] = year
|
||||
data = self._get(path, params)
|
||||
results = data.get("results") or []
|
||||
# 优先标题精确匹配 + 年份匹配
|
||||
exact = [r for r in results
|
||||
if r.get("title" if kind == "movie" else "name", "").lower()
|
||||
== title.lower()]
|
||||
if year and exact:
|
||||
exact = [r for r in exact
|
||||
if str(r.get("release_date" if kind == "movie" else "first_air_date", "") or "")
|
||||
.startswith(str(year))]
|
||||
if exact:
|
||||
return exact[0]
|
||||
if results:
|
||||
return results[0]
|
||||
return None
|
||||
|
||||
def get_details(self, kind, tmdb_id):
|
||||
path = f"movie/{tmdb_id}" if kind == "movie" else f"tv/{tmdb_id}"
|
||||
return self._get(path, {"language": self.cfg.language})
|
||||
|
||||
def get_credits(self, kind, tmdb_id):
|
||||
"""电影 cast/crew;剧集 cast。用于 nfo 的演员、导演、制片厂字段。"""
|
||||
path = f"movie/{tmdb_id}/credits" if kind == "movie" else f"tv/{tmdb_id}/credits"
|
||||
return self._get(path, {"language": self.cfg.language})
|
||||
|
||||
def image_url(self, path, size="w500"):
|
||||
if not path:
|
||||
return None
|
||||
base = TMDB_IMAGE_BASE if size == "w500" else TMDB_IMAGE_ORIG
|
||||
return f"{base}{path}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nfo 生成
|
||||
# ---------------------------------------------------------------------------
|
||||
def _esc(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def _txt(node, tag, value):
|
||||
if value is not None and str(value).strip():
|
||||
ET.SubElement(node, tag).text = str(value).strip()
|
||||
|
||||
|
||||
def build_movie_nfo(meta):
|
||||
"""meta: TMDB movie details 的整理字段。返回 movie.nfo XML 字符串。"""
|
||||
root = ET.Element("movie")
|
||||
_txt(root, "title", meta.get("title"))
|
||||
_txt(root, "originaltitle", meta.get("original_title"))
|
||||
_txt(root, "year", meta.get("year"))
|
||||
_txt(root, "rating", meta.get("vote_average"))
|
||||
_txt(root, "votes", meta.get("vote_count"))
|
||||
_txt(root, "runtime", meta.get("runtime"))
|
||||
_txt(root, "plot", meta.get("overview"))
|
||||
_txt(root, "tagline", meta.get("tagline"))
|
||||
_txt(root, "premiered", meta.get("release_date"))
|
||||
_txt(root, "release", meta.get("release_date"))
|
||||
for g in meta.get("genres", []) or []:
|
||||
_txt(root, "genre", g)
|
||||
for d in meta.get("directors", []) or []:
|
||||
_txt(root, "director", d)
|
||||
for s in meta.get("studios", []) or []:
|
||||
_txt(root, "studio", s)
|
||||
_txt(root, "mpaa", meta.get("certification"))
|
||||
if meta.get("tmdb_id"):
|
||||
ET.SubElement(root, "uniqueid", attrib={"type": "tmdb"}).text = str(meta["tmdb_id"])
|
||||
if meta.get("imdb_id"):
|
||||
ET.SubElement(root, "uniqueid", attrib={"type": "imdb"}).text = str(meta["imdb_id"])
|
||||
if meta.get("poster_path"):
|
||||
ET.SubElement(root, "thumb").text = TMDB_IMAGE_BASE + meta["poster_path"]
|
||||
if meta.get("fanart_path"):
|
||||
f = ET.SubElement(root, "fanart")
|
||||
ET.SubElement(f, "thumb").text = TMDB_IMAGE_ORIG + meta["fanart_path"]
|
||||
for a in meta.get("cast", []) or []:
|
||||
ac = ET.SubElement(root, "actor")
|
||||
_txt(ac, "name", a.get("name"))
|
||||
_txt(ac, "role", a.get("role"))
|
||||
if a.get("thumb"):
|
||||
_txt(ac, "thumb", TMDB_IMAGE_BASE + a["thumb"])
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def build_tvshow_nfo(meta):
|
||||
root = ET.Element("tvshow")
|
||||
_txt(root, "title", meta.get("name"))
|
||||
_txt(root, "originaltitle", meta.get("original_name"))
|
||||
_txt(root, "year", meta.get("year"))
|
||||
_txt(root, "rating", meta.get("vote_average"))
|
||||
_txt(root, "votes", meta.get("vote_count"))
|
||||
_txt(root, "plot", meta.get("overview"))
|
||||
_txt(root, "premiered", meta.get("first_air_date"))
|
||||
_txt(root, "status", meta.get("status"))
|
||||
for g in meta.get("genres", []) or []:
|
||||
_txt(root, "genre", g)
|
||||
for s in meta.get("studios", []) or []:
|
||||
_txt(root, "studio", s)
|
||||
if meta.get("tmdb_id"):
|
||||
ET.SubElement(root, "uniqueid", attrib={"type": "tmdb"}).text = str(meta["tmdb_id"])
|
||||
if meta.get("imdb_id"):
|
||||
ET.SubElement(root, "uniqueid", attrib={"type": "imdb"}).text = str(meta["imdb_id"])
|
||||
if meta.get("poster_path"):
|
||||
ET.SubElement(root, "thumb").text = TMDB_IMAGE_BASE + meta["poster_path"]
|
||||
for a in meta.get("cast", []) or []:
|
||||
ac = ET.SubElement(root, "actor")
|
||||
_txt(ac, "name", a.get("name"))
|
||||
_txt(ac, "role", a.get("role"))
|
||||
if a.get("thumb"):
|
||||
_txt(ac, "thumb", TMDB_IMAGE_BASE + a["thumb"])
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def build_episode_nfo(meta):
|
||||
root = ET.Element("episodedetails")
|
||||
_txt(root, "title", meta.get("name"))
|
||||
_txt(root, "season", meta.get("season_number"))
|
||||
_txt(root, "episode", meta.get("episode_number"))
|
||||
_txt(root, "plot", meta.get("overview"))
|
||||
_txt(root, "aired", meta.get("air_date"))
|
||||
_txt(root, "rating", meta.get("vote_average"))
|
||||
if meta.get("still_path"):
|
||||
ET.SubElement(root, "thumb").text = TMDB_IMAGE_BASE + meta["still_path"]
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 刮削器
|
||||
# ---------------------------------------------------------------------------
|
||||
class Scraper:
|
||||
def __init__(self, config, dest, source_root=None, dry_run=False, log=None):
|
||||
self.cfg = config
|
||||
self.dest = os.path.abspath(dest)
|
||||
self.source_root = os.path.abspath(source_root) if source_root else None
|
||||
self.dry_run = dry_run
|
||||
self.log = log or (lambda msg: print(msg))
|
||||
self.api = None if config.mock else TMDBApi(config)
|
||||
|
||||
def logi(self, msg):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.log(f"[{ts}] {msg}")
|
||||
|
||||
# ---- 复制媒体到成品目录 ----
|
||||
def _relative(self, path):
|
||||
"""以源根为基准计算相对路径;不在源根下则退回 basename。"""
|
||||
path = os.path.abspath(path)
|
||||
if self.source_root and os.path.commonpath([path, self.source_root]) == self.source_root:
|
||||
return os.path.relpath(path, self.source_root)
|
||||
return os.path.basename(path)
|
||||
|
||||
def _copy_media(self, src_path, rel_src):
|
||||
"""把源媒体文件/目录复制到成品目录,返回成品里的完整路径。
|
||||
|
||||
安全保护:目标路径绝不允许等于源路径,否则直接报错,绝不误删源。
|
||||
"""
|
||||
dst = os.path.join(self.dest, rel_src)
|
||||
if os.path.abspath(dst) == os.path.abspath(src_path):
|
||||
raise RuntimeError(f"目标路径与源路径相同,已终止以防误删: {src_path}")
|
||||
if self.dry_run:
|
||||
self.logi(f"[DRY] 将复制 {src_path} -> {dst}")
|
||||
return dst
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
if os.path.isdir(src_path):
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src_path, dst)
|
||||
else:
|
||||
shutil.copy2(src_path, dst)
|
||||
return dst
|
||||
|
||||
# ---- 电影刮削(单文件或电影目录)----
|
||||
def scrape_movie(self, src):
|
||||
info = parse_media_name(src)
|
||||
rel = self._relative(src)
|
||||
dst = self._copy_media(src, rel)
|
||||
base = os.path.basename(dst)
|
||||
base_noext = os.path.splitext(base)[0]
|
||||
|
||||
if self.cfg.mock:
|
||||
meta = self._mock_movie_meta(info)
|
||||
else:
|
||||
found = self.api.search("movie", info["title"], info.get("year"))
|
||||
if not found:
|
||||
raise RuntimeError(f"TMDB 未匹配到电影: {info['title']}")
|
||||
details = self.api.get_details("movie", found["id"])
|
||||
try:
|
||||
credits = self.api.get_credits("movie", found["id"])
|
||||
except Exception:
|
||||
credits = {}
|
||||
meta = self._shape_movie_meta(details, credits)
|
||||
|
||||
if self.dry_run:
|
||||
self.logi(f"[DRY] 电影 {base} 匹配 TMDB id={meta.get('tmdb_id')} "
|
||||
f"《{meta.get('title')}》({meta.get('year')})")
|
||||
return
|
||||
|
||||
media_file = self._find_media_file(dst)
|
||||
out_dir = os.path.dirname(media_file) if media_file else dst
|
||||
nfo_name = os.path.splitext(os.path.basename(media_file))[0] if media_file else base_noext
|
||||
nfo_path = os.path.join(out_dir, nfo_name + ".nfo")
|
||||
with open(nfo_path, "w", encoding="utf-8") as f:
|
||||
f.write(build_movie_nfo(meta))
|
||||
self.logi(f"生成 {nfo_path}")
|
||||
|
||||
self._download_art(meta, out_dir, nfo_name, base_noext)
|
||||
|
||||
# ---- 剧集刮削(剧集目录)----
|
||||
def scrape_tv(self, src):
|
||||
info = parse_media_name(src)
|
||||
rel = self._relative(src)
|
||||
dst = self._copy_media(src, rel)
|
||||
|
||||
if self.cfg.mock:
|
||||
meta = self._mock_tv_meta(info)
|
||||
else:
|
||||
found = self.api.search("tv", info["title"], info.get("year"))
|
||||
if not found:
|
||||
raise RuntimeError(f"TMDB 未匹配到剧集: {info['title']}")
|
||||
details = self.api.get_details("tv", found["id"])
|
||||
try:
|
||||
credits = self.api.get_credits("tv", found["id"])
|
||||
except Exception:
|
||||
credits = {}
|
||||
meta = self._shape_tv_meta(details, credits)
|
||||
|
||||
if self.dry_run:
|
||||
self.logi(f"[DRY] 剧集 {os.path.basename(src)} 匹配 TMDB id={meta.get('tmdb_id')} "
|
||||
f"《{meta.get('name')}》")
|
||||
return
|
||||
|
||||
# 剧集目录:在目录根生成 tvshow.nfo + poster;对每个视频文件生成 {文件}.nfo
|
||||
root_dir = dst if os.path.isdir(dst) else os.path.dirname(dst)
|
||||
with open(os.path.join(root_dir, "tvshow.nfo"), "w", encoding="utf-8") as f:
|
||||
f.write(build_tvshow_nfo(meta))
|
||||
self.logi(f"生成 {root_dir}/tvshow.nfo")
|
||||
self._download_art(meta, root_dir, "poster", "poster")
|
||||
|
||||
for vf in self._find_video_files(root_dir):
|
||||
if info.get("season") is not None and info.get("episode") is not None:
|
||||
ep_meta = self._episode_meta(meta, info["season"], info["episode"])
|
||||
else:
|
||||
ep_meta = self._episode_meta_from_filename(vf)
|
||||
vbase = os.path.splitext(os.path.basename(vf))[0]
|
||||
with open(os.path.join(root_dir, vbase + ".nfo"), "w", encoding="utf-8") as f:
|
||||
f.write(build_episode_nfo(ep_meta))
|
||||
self.logi(f"生成 {os.path.join(root_dir, vbase + '.nfo')}")
|
||||
|
||||
# ---- 工具方法 ----
|
||||
def _find_media_file(self, path):
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
for root, _, files in os.walk(path):
|
||||
for fn in sorted(files):
|
||||
if clean_ext(fn) in VIDEO_EXTS:
|
||||
return os.path.join(root, fn)
|
||||
return None
|
||||
|
||||
def _find_video_files(self, root):
|
||||
out = []
|
||||
for root2, _, files in os.walk(root):
|
||||
for fn in sorted(files):
|
||||
if clean_ext(fn) in VIDEO_EXTS:
|
||||
out.append(os.path.join(root2, fn))
|
||||
return out
|
||||
|
||||
def _download_art(self, meta, out_dir, prefix, poster_prefix=None):
|
||||
"""下载海报/背景图(Kodi/Emby 兼容命名)。"""
|
||||
poster_prefix = poster_prefix or prefix
|
||||
poster = meta.get("poster_path")
|
||||
fanart = meta.get("fanart_path")
|
||||
if poster:
|
||||
url = TMDB_IMAGE_BASE + poster
|
||||
self._dl(url, os.path.join(out_dir, f"{poster_prefix}-poster.jpg"))
|
||||
if fanart:
|
||||
url = TMDB_IMAGE_ORIG + fanart
|
||||
self._dl(url, os.path.join(out_dir, f"{prefix}-fanart.jpg"))
|
||||
|
||||
def _dl(self, url, out_path):
|
||||
if self.dry_run or self.cfg.mock:
|
||||
self.logi(f"[DRY/MOCK] 下载图片 {url} -> {out_path}")
|
||||
return
|
||||
try:
|
||||
r = requests.get(url, timeout=self.cfg.timeout)
|
||||
r.raise_for_status()
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(r.content)
|
||||
self.logi(f"下载图片 {out_path}")
|
||||
except Exception as e:
|
||||
self.logi(f"下载图片失败 {url}: {e}")
|
||||
|
||||
# ---- meta 整形 ----
|
||||
def _shape_movie_meta(self, d, credits=None):
|
||||
credits = credits or {}
|
||||
return {
|
||||
"title": d.get("title") or d.get("original_title"),
|
||||
"original_title": d.get("original_title"),
|
||||
"year": (d.get("release_date") or "")[:4],
|
||||
"vote_average": d.get("vote_average"),
|
||||
"vote_count": d.get("vote_count"),
|
||||
"runtime": d.get("runtime"),
|
||||
"overview": d.get("overview"),
|
||||
"tagline": d.get("tagline"),
|
||||
"release_date": d.get("release_date"),
|
||||
"genres": [g.get("name") for g in (d.get("genres") or [])],
|
||||
"certification": None,
|
||||
"tmdb_id": d.get("id"),
|
||||
"imdb_id": d.get("imdb_id"),
|
||||
"poster_path": d.get("poster_path"),
|
||||
"fanart_path": (d.get("backdrop_path")),
|
||||
"directors": [c.get("name") for c in (credits.get("crew") or [])
|
||||
if c.get("job") == "Director"][:3],
|
||||
"studios": [c.get("name") for c in (d.get("production_companies") or [])],
|
||||
"cast": [{"name": c.get("name"), "role": c.get("character"),
|
||||
"thumb": c.get("profile_path")}
|
||||
for c in (credits.get("cast") or [])[:12]],
|
||||
}
|
||||
|
||||
def _shape_tv_meta(self, d, credits=None):
|
||||
credits = credits or {}
|
||||
return {
|
||||
"name": d.get("name") or d.get("original_name"),
|
||||
"original_name": d.get("original_name"),
|
||||
"year": (d.get("first_air_date") or "")[:4],
|
||||
"vote_average": d.get("vote_average"),
|
||||
"vote_count": d.get("vote_count"),
|
||||
"overview": d.get("overview"),
|
||||
"first_air_date": d.get("first_air_date"),
|
||||
"status": d.get("status"),
|
||||
"genres": [g.get("name") for g in (d.get("genres") or [])],
|
||||
"tmdb_id": d.get("id"),
|
||||
"imdb_id": None,
|
||||
"poster_path": d.get("poster_path"),
|
||||
"fanart_path": (d.get("backdrop_path")),
|
||||
"studios": [c.get("name") for c in (d.get("production_companies") or [])],
|
||||
"cast": [{"name": c.get("name"), "role": c.get("character"),
|
||||
"thumb": c.get("profile_path")}
|
||||
for c in (credits.get("cast") or [])[:12]],
|
||||
}
|
||||
|
||||
def _episode_meta(self, tv_meta, season, episode):
|
||||
return {
|
||||
"name": f"第 {episode} 集",
|
||||
"season_number": season,
|
||||
"episode_number": episode,
|
||||
"overview": None,
|
||||
"air_date": None,
|
||||
"vote_average": None,
|
||||
"still_path": None,
|
||||
}
|
||||
|
||||
def _episode_meta_from_filename(self, vf):
|
||||
info = parse_media_name(vf)
|
||||
return self._episode_meta({}, info.get("season") or 1, info.get("episode") or 1)
|
||||
|
||||
# ---- mock 元数据(无 Key 时验证管线用)----
|
||||
def _mock_movie_meta(self, info):
|
||||
return {
|
||||
"title": info["title"], "original_title": info["title"],
|
||||
"year": info.get("year") or 2024, "vote_average": 8.5,
|
||||
"vote_count": 100, "runtime": 120, "overview": f"(MOCK)电影《{info['title']}》简介",
|
||||
"tagline": "", "release_date": f"{info.get('year') or 2024}-01-01",
|
||||
"genres": ["剧情"], "certification": None,
|
||||
"tmdb_id": 1000, "imdb_id": "tt0000000",
|
||||
"poster_path": None, "fanart_path": None,
|
||||
}
|
||||
|
||||
def _mock_tv_meta(self, info):
|
||||
return {
|
||||
"name": info["title"], "original_name": info["title"],
|
||||
"year": info.get("year") or 2024, "vote_average": 8.8,
|
||||
"vote_count": 200, "overview": f"(MOCK)剧集《{info['title']}》简介",
|
||||
"first_air_date": f"{info.get('year') or 2024}-01-01",
|
||||
"status": "Returning Series", "genres": ["剧情"],
|
||||
"tmdb_id": 2000, "imdb_id": None,
|
||||
"poster_path": None, "fanart_path": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 命令行入口
|
||||
# ---------------------------------------------------------------------------
|
||||
def scan_and_scrape(source, dest, scraper):
|
||||
"""处理一个源路径:文件直接刮,目录则识别为电影/剧集目录。"""
|
||||
source = os.path.abspath(source)
|
||||
if os.path.isfile(source):
|
||||
info = parse_media_name(source)
|
||||
if info["kind"] == "tv":
|
||||
scraper.scrape_tv(source)
|
||||
else:
|
||||
scraper.scrape_movie(source)
|
||||
elif os.path.isdir(source):
|
||||
# 判断是剧集目录(含 SxxExx / 季结构)还是电影目录
|
||||
vids = [f for f in _walk_files(source) if clean_ext(f) in VIDEO_EXTS]
|
||||
tv_mark = any(re.search(r"[Ss]\d{1,2}[Ee]\d{1,3}|第\s*\d{1,2}\s*[集話话]", f)
|
||||
for f in _walk_files(source))
|
||||
subdir_seasons = any(re.match(r"[Ss]eason\s*\d+|第\s*\d+\s*季", d)
|
||||
for d in os.listdir(source))
|
||||
if tv_mark or subdir_seasons or len(vids) > 1:
|
||||
scraper.scrape_tv(source)
|
||||
else:
|
||||
scraper.scrape_movie(source)
|
||||
else:
|
||||
raise RuntimeError(f"源路径不存在: {source}")
|
||||
|
||||
|
||||
def _walk_files(root):
|
||||
out = []
|
||||
for r, _, files in os.walk(root):
|
||||
for fn in files:
|
||||
out.append(os.path.join(r, fn))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="自研媒体刮削器(TMDB)")
|
||||
ap.add_argument("--source", required=True, help="媒体文件/目录(可重复,或 --recurse 扫整目录)")
|
||||
ap.add_argument("--dest", required=True, help="成品落库目录")
|
||||
ap.add_argument("--config", default=None, help="config.json 路径")
|
||||
ap.add_argument("--mock", action="store_true", help="无 Key 时用模拟元数据跑通管线")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只打印计划不落盘")
|
||||
ap.add_argument("--recurse", action="store_true", help="递归处理 --source 下所有新媒体单元")
|
||||
ap.add_argument("--log", default=None, help="日志文件路径(默认 stdout)")
|
||||
args = ap.parse_args()
|
||||
|
||||
cfg = Config(args.config)
|
||||
if args.mock:
|
||||
cfg.mock = True
|
||||
|
||||
log = (lambda msg: open(args.log, "a", encoding="utf-8").write(msg + "\n")
|
||||
and print(msg)) if args.log else print
|
||||
if args.recurse:
|
||||
source_root = os.path.abspath(args.source)
|
||||
else:
|
||||
source_root = os.path.abspath(os.path.dirname(args.source))
|
||||
scraper = Scraper(cfg, args.dest, source_root=source_root,
|
||||
dry_run=args.dry_run, log=log)
|
||||
os.makedirs(args.dest, exist_ok=True)
|
||||
|
||||
if args.recurse:
|
||||
targets = _walk_files(args.source)
|
||||
# 只处理视频文件,交给 parse 判定
|
||||
targets = [t for t in targets if clean_ext(t) in VIDEO_EXTS]
|
||||
else:
|
||||
targets = [args.source]
|
||||
|
||||
for t in targets:
|
||||
try:
|
||||
scan_and_scrape(t, args.dest, scraper)
|
||||
except Exception as e:
|
||||
scraper.logi(f"跳过 {t}: {e}")
|
||||
|
||||
scraper.logi("全部处理完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
# =====================================================================
|
||||
# nas-tools (NAStool) 部署配置 —— 飞牛 fnOS Docker
|
||||
# 用途:监听「清理后媒体目录」→ 自动识别并刮削 → 复制落库到「成品媒体库」
|
||||
# 用法:飞牛 → 应用中心/Docker → 添加项目(Compose) → 粘贴本文件 → 启动
|
||||
# 说明:把下面三个 /vol1/... 路径改成你飞牛上的真实路径再粘贴
|
||||
# =====================================================================
|
||||
services:
|
||||
nas-tools:
|
||||
image: jxxghp/nas-tools:latest # 也可换 hsuyelin/nas-tools:latest 或 diluka/nas-tools:2.9.1
|
||||
container_name: nas-tools
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000" # Web 管理界面
|
||||
volumes:
|
||||
- /vol1/docker/nas-tools/config:/config # 配置持久化(必须)
|
||||
- /vol1/media/cleaned:/media/source # 【你的清理后媒体目录】(media_final.py 的输出)
|
||||
- /vol1/media/library:/media/library # 【成品媒体库】(刮削+复制后的落库目录)
|
||||
environment:
|
||||
- PUID=0
|
||||
- PGID=0
|
||||
- TZ=Asia/Shanghai
|
||||
- NASTOOL_AUTO_UPDATE=false # 关闭自动更新,避免拉不动 github
|
||||
- NASTOOL_CN_UPDATE=true # 使用国内源加速依赖安装
|
||||
@@ -0,0 +1,37 @@
|
||||
# nas-tools 部署与配置说明(飞牛 fnOS)
|
||||
|
||||
目标流程:`media_final.py` 清理后的媒体目录 → nas-tools 自动监听 → 刮削 → **复制**落库到成品媒体库。
|
||||
|
||||
## 一、部署(飞牛 Docker)
|
||||
|
||||
1. 飞牛后台 → **应用中心 → Docker**(或直接 Docker 应用)→ 新建项目 / Compose。
|
||||
2. 粘贴 `nastools-docker-compose.yml` 内容,把 `/vol1/media/cleaned`、`/vol1/media/library`、`/vol1/docker/nas-tools/config` 三处改成你飞牛上的真实路径。
|
||||
3. 保存并启动,等待镜像拉取完成。
|
||||
4. 浏览器打开 `http://<飞牛IP>:3000`,默认账号 `admin` / 密码 `password`(首次登录后改掉)。
|
||||
|
||||
> 镜像拉不动时:确认环境变量 `NASTOOL_AUTO_UPDATE=false`、`NASTOOL_CN_UPDATE=true` 已设置。
|
||||
|
||||
## 二、Web 里必须配的点
|
||||
|
||||
| 配置项 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| **TMDB API Key** | 设置 → 媒体库 | 必填,TMDB 官网免费申请(themoviedb.org/settings/api)。没有它无法刮削海报/简介 |
|
||||
| **目录同步:复制模式** | 设置 → 目录同步 | 添加「源目录 = /media/source、目的目录 = /media/library」,传输方式选**复制**(对应你定的落库方式) |
|
||||
| **媒体库识别** | 设置 → 媒体库 | 电影/剧集命名规则保持默认即可,识别率高 |
|
||||
| **联动 Jellyfin/Emby** | 设置 → 媒体服务器 | 可选。填入 Jellyfin/Emby 地址和 API Key,nas-tools 刮削完会通知它刷新海报墙 |
|
||||
|
||||
## 三、关键行为确认
|
||||
|
||||
- nas-tools **默认会监控源目录**,文件新增/变更后自动识别媒体真实名称 → 刮削 → 复制到成品目录。
|
||||
- 「复制」= 源目录保留,成品目录得到一份完整的刮削后副本(含 nfo、海报、背景图)。
|
||||
- 如果你想省空间、源和成品在同一文件系统,可把「复制」换成「硬链接」——效果等同但几乎不占额外空间(自愿切换)。
|
||||
|
||||
## 四、验证
|
||||
|
||||
- 往 `/vol1/media/cleaned` 丢一个清理后的影片,几秒后到 `/vol1/media/library` 检查是否出现了刮削好的副本(含 `.nfo`、`-poster.jpg`)。
|
||||
- 若配合 Jellyfin/Emby,刷新后应出现海报墙。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **海报刮不出来**:多半是 TMDB Key 没配,或无法访问 TMDB(国内网络需在媒体库设置里配代理/TMDB 备用源)。
|
||||
- **没自动触发**:确认源目录路径和目录同步里的监控路径一致;看容器日志 `docker logs nas-tools`。
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,124 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
# ====================== 【配置区】 ======================
|
||||
MEDIA_FOLDER = "/Volumes/JoyDisk/myMedia/olstrms/电影/未分类/2024"
|
||||
DRY_RUN = True # True=预览不修改;False=实际重命名
|
||||
SKIP_PROCESSED = False
|
||||
LOG_FILE = "./rename_log.txt"
|
||||
|
||||
# 清洗规则
|
||||
RULES = [
|
||||
(r"^[A-Za-z] ", ""), #1.移除开头单个字母+空格
|
||||
(r"\[.*?\]", ""), #2.移除英文方括号及内部内容
|
||||
(r"【.*?】", ""), #3.移除中文【】及内部内容
|
||||
(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().:+-]", ""),
|
||||
]
|
||||
# 加入strm
|
||||
ALLOW_EXT = {".mkv", ".mp4", ".mov", ".srt", ".ass", ".sub", ".strm"}
|
||||
# =======================================================
|
||||
|
||||
def clean_filename(name: str) -> str:
|
||||
name_no_ext, ext = os.path.splitext(name)
|
||||
# 全角转半角
|
||||
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)
|
||||
name_no_ext = full2half(name_no_ext)
|
||||
|
||||
for item in RULES:
|
||||
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()
|
||||
return name_no_ext + ext
|
||||
|
||||
def write_log(msg: str):
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{now}] {msg}"
|
||||
print(line)
|
||||
# 增加flush,强制写入磁盘,解决日志文件不更新
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
f.flush()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 每次运行前清空日志文件
|
||||
with open(LOG_FILE, "w", encoding="utf-8") as f:
|
||||
f.flush()
|
||||
|
||||
write_log("===== 文件名+文件夹清洗(移除开头A 中文片名前缀) =====")
|
||||
write_log(f"根目录:{MEDIA_FOLDER}")
|
||||
write_log(f"预览模式 DRY_RUN = {DRY_RUN}")
|
||||
|
||||
file_rename_list = []
|
||||
dir_rename_list = []
|
||||
total_scan_dirs = 0
|
||||
total_all_files = 0 # 全部扫描到的文件(所有后缀)
|
||||
total_media_files = 0 # 媒体文件(ALLOW_EXT后缀)
|
||||
|
||||
for root, dirs, files in os.walk(MEDIA_FOLDER, topdown=False):
|
||||
total_scan_dirs += 1
|
||||
for fname in files:
|
||||
total_all_files +=1 # 任何文件都计入总扫描文件
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext in ALLOW_EXT:
|
||||
total_media_files += 1
|
||||
# 调试:打印所有扫描到的媒体文件(无论是否修改)
|
||||
write_log(f"[扫描媒体] {fname}")
|
||||
new_name = clean_filename(fname)
|
||||
if new_name != fname:
|
||||
if SKIP_PROCESSED and re.search(r"\(\d{4}\)", new_name):
|
||||
write_log(f"【跳过文件】{fname},检测年份括号")
|
||||
continue
|
||||
old_path = os.path.join(root, fname)
|
||||
new_path = os.path.join(root, new_name)
|
||||
file_rename_list.append((old_path, new_path))
|
||||
write_log(f"待修改文件-> {fname} → {new_name}")
|
||||
|
||||
# 处理文件夹
|
||||
for dname in dirs:
|
||||
new_name = clean_filename(dname)
|
||||
if new_name != dname:
|
||||
if SKIP_PROCESSED and re.search(r"\(\d{4}\)", new_name):
|
||||
write_log(f"【跳过文件夹】{dname},检测年份括号")
|
||||
continue
|
||||
old_path = os.path.join(root, dname)
|
||||
new_path = os.path.join(root, new_name)
|
||||
dir_rename_list.append((old_path, new_name))
|
||||
write_log(f"待修改文件夹-> {dname} → {new_name}")
|
||||
|
||||
# ========== 执行阶段 ==========
|
||||
if not DRY_RUN:
|
||||
# 先文件
|
||||
for old, new in file_rename_list:
|
||||
try:
|
||||
os.rename(old, new)
|
||||
except Exception as e:
|
||||
write_log(f"【文件重命名失败】{old} -> {new} | 错误:{e}")
|
||||
# 后文件夹
|
||||
for old, new in dir_rename_list:
|
||||
try:
|
||||
os.rename(old, new)
|
||||
except Exception as e:
|
||||
write_log(f"【文件夹重命名失败】{old} -> {new} | 错误:{e}")
|
||||
|
||||
# 最终汇总输出
|
||||
write_log(f"任务完成,总扫描目录:{total_scan_dirs},待修改文件夹:{len(dir_rename_list)}")
|
||||
write_log(f"总扫描文件:{total_all_files},媒体文件:{total_media_files},待修改文件:{len(file_rename_list)}")
|
||||
write_log("============================================================")
|
||||
@@ -0,0 +1,100 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
# ====================== 【配置区,请修改】 ======================
|
||||
MEDIA_FOLDER = "/Volumes/JoyDisk/myMedia/olstrms/电影/未分类/2025" # Mac挂载NAS路径
|
||||
# MEDIA_FOLDER = "/media/movies" # 飞牛容器内运行用这个
|
||||
|
||||
DRY_RUN = True # True=预览,不修改;确认无误改为False执行改名
|
||||
SKIP_PROCESSED = False # True=跳过已处理(文件名含(20xx)年份)
|
||||
LOG_FILE = "./rename_log.txt"
|
||||
# =================================================================
|
||||
|
||||
# 清洗正则规则(文件夹、文件共用一套)
|
||||
RULES = [
|
||||
(r"^[A-Za-z] ", ""), # 【新增】移除开头单个英文字母+空格:A 流浪地球 → 流浪地球
|
||||
(r"(?<=[a-zA-Z0-9])\.(?=[a-zA-Z0-9])", " "), # Dune.Part → Dune Part
|
||||
(r"\[.*?\]", ""), # 删除全部 [xxx] 方括号内容
|
||||
(r"\s*(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD)\b", ""),
|
||||
(r"\.+", " "), # 剩下连续多个点替换成空格
|
||||
]
|
||||
|
||||
# 支持的文件后缀
|
||||
ALLOW_EXT = {".mkv", ".mp4", ".mov", ".srt", ".ass", ".sub"}
|
||||
|
||||
def clean_name(name: str) -> str:
|
||||
"""统一清洗函数,文件夹名、文件名共用"""
|
||||
name_no_ext, ext = os.path.splitext(name)
|
||||
|
||||
# 处理 A . B . C 这种「字母 空格 . 空格 字母」
|
||||
while re.search(r"(\w) \. (\w)", name_no_ext):
|
||||
name_no_ext = re.sub(r"(\w) \. (\w)", r"\1\2", name_no_ext)
|
||||
|
||||
# 执行所有正则替换规则
|
||||
for pattern, repl in RULES:
|
||||
name_no_ext = re.sub(pattern, repl, name_no_ext)
|
||||
|
||||
# 合并连续空格,去除首尾空格
|
||||
name_no_ext = re.sub(r"\s+", " ", name_no_ext).strip()
|
||||
return name_no_ext + ext
|
||||
|
||||
def write_log(msg: str):
|
||||
time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{time_str}] {msg}"
|
||||
print(line)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
write_log("===== 文件名+文件夹清洗(移除开头A 中文片名前缀) =====")
|
||||
write_log(f"根目录:{MEDIA_FOLDER}")
|
||||
write_log(f"预览模式 DRY_RUN = {DRY_RUN}")
|
||||
|
||||
dir_rename_list = []
|
||||
file_rename_list = []
|
||||
|
||||
# 遍历收集(自底向上,防止目录路径错乱)
|
||||
for root, dirs, files in os.walk(MEDIA_FOLDER, topdown=False):
|
||||
# 文件
|
||||
for fname in files:
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext not in ALLOW_EXT:
|
||||
continue
|
||||
new_name = clean_name(fname)
|
||||
if new_name != fname:
|
||||
if SKIP_PROCESSED and re.search(r"\(\d{4}\)", new_name):
|
||||
write_log(f"跳过文件(疑似已处理): {fname}")
|
||||
continue
|
||||
old_path = os.path.join(root, fname)
|
||||
new_path = os.path.join(root, new_name)
|
||||
file_rename_list.append((old_path, new_path))
|
||||
|
||||
# 文件夹
|
||||
for dname in dirs:
|
||||
new_name = clean_name(dname)
|
||||
if new_name != dname:
|
||||
if SKIP_PROCESSED and re.search(r"\(\d{4}\)", new_name):
|
||||
write_log(f"跳过文件夹(疑似已处理): {dname}")
|
||||
continue
|
||||
old_path = os.path.join(root, dname)
|
||||
new_path = os.path.join(root, new_name)
|
||||
dir_rename_list.append((old_path, new_name))
|
||||
|
||||
# 先改文件夹,再改文件
|
||||
dir_count = 0
|
||||
for old, new in dir_rename_list:
|
||||
write_log(f"【文件夹】{os.path.basename(old)} → {os.path.basename(new)}")
|
||||
dir_count +=1
|
||||
if not DRY_RUN:
|
||||
os.rename(old, new)
|
||||
|
||||
file_count =0
|
||||
for old, new in file_rename_list:
|
||||
write_log(f"【文件】{os.path.basename(old)} → {os.path.basename(new)}")
|
||||
file_count +=1
|
||||
if not DRY_RUN:
|
||||
os.rename(old, new)
|
||||
|
||||
write_log(f"任务完成,待修改文件夹:{dir_count},待修改文件:{file_count}")
|
||||
write_log("="*60 + "\n")
|
||||
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
# ====================== 【配置区,请修改】 ======================
|
||||
MEDIA_FOLDER = "/Volumes/JoyDisk/myMedia/olstrms/电影/未分类/2024"
|
||||
DRY_RUN = True # True预览不修改;确认后改为False
|
||||
SKIP_PROCESSED = False
|
||||
LOG_FILE = "./rename_log.txt"
|
||||
# =================================================================
|
||||
RULES = [
|
||||
(r"^[A-Za-z] ", ""), #1.开头单个字母+空格
|
||||
(r"\[.*?\]", ""), #2.英文方括号
|
||||
(r"【.*?】", ""), #2.中文方括号
|
||||
(r"\b(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|中英双语)\b", "", re.IGNORECASE),
|
||||
(r"\.+", ""), #4.连续多点删除
|
||||
(r"\s", ""), #5.删除全部空格
|
||||
]
|
||||
ALLOW_EXT = {".mkv", ".mp4", ".mov", ".srt", ".ass", ".sub"}
|
||||
|
||||
def clean_name(name: str) -> str:
|
||||
name_no_ext, ext = os.path.splitext(name)
|
||||
for item in RULES:
|
||||
if len(item) == 3:
|
||||
pattern, repl, flag = item
|
||||
name_no_ext = re.sub(pattern, repl, name_no_ext, flags=flag)
|
||||
else:
|
||||
pattern, repl = item
|
||||
name_no_ext = re.sub(pattern, repl, name_no_ext)
|
||||
name_no_ext = re.sub(r"\s+", " ", name_no_ext).strip()
|
||||
return name_no_ext + ext
|
||||
|
||||
def write_log(msg: str):
|
||||
time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{time_str}] {msg}"
|
||||
print(line)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
write_log("===== 文件名+文件夹清洗【递归处理全部下级子目录】 =====")
|
||||
write_log(f"根目录:{MEDIA_FOLDER}")
|
||||
write_log(f"预览模式 DRY_RUN = {DRY_RUN}")
|
||||
write_log("说明:递归扫描全部子目录,先收集所有待改,执行时【先文件、后文件夹】避免路径失效")
|
||||
dir_rename_list = []
|
||||
file_rename_list = []
|
||||
|
||||
for root, dirs, files in os.walk(MEDIA_FOLDER, topdown=False):
|
||||
write_log(f"\n==== 扫描目录:{root} | 当前目录文件数量:{len(files)} ====")
|
||||
# 处理文件
|
||||
for fname in files:
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext not in ALLOW_EXT:
|
||||
continue
|
||||
new_name = clean_name(fname)
|
||||
if new_name != fname:
|
||||
if SKIP_PROCESSED and re.search(r"\(\d{4}\)", new_name):
|
||||
write_log(f"【跳过文件】{fname},检测年份括号")
|
||||
continue
|
||||
old_path = os.path.join(root, fname)
|
||||
new_path = os.path.join(root, new_name)
|
||||
file_rename_list.append((old_path, new_path))
|
||||
write_log(f"【待改文件】{fname} → {new_name}")
|
||||
# 处理文件夹
|
||||
for dname in dirs:
|
||||
new_name = clean_name(dname)
|
||||
if new_name != dname:
|
||||
if SKIP_PROCESSED and re.search(r"\(\d{4}\)", new_name):
|
||||
write_log(f"【跳过文件夹】{dname},检测年份括号")
|
||||
continue
|
||||
old_path = os.path.join(root, dname)
|
||||
new_path = os.path.join(root, new_name)
|
||||
dir_rename_list.append((old_path, new_name))
|
||||
write_log(f"【待改文件夹】{dname} → {new_name}")
|
||||
|
||||
# 执行顺序:先全部文件,再全部文件夹
|
||||
file_count = 0
|
||||
for old, new in file_rename_list:
|
||||
write_log(f"【执行文件】{os.path.basename(old)} → {os.path.basename(new)}")
|
||||
file_count +=1
|
||||
if not DRY_RUN:
|
||||
os.rename(old, new)
|
||||
|
||||
dir_count = 0
|
||||
for old, new in dir_rename_list:
|
||||
write_log(f"【执行文件夹】{os.path.basename(old)} → {os.path.basename(new)}")
|
||||
dir_count +=1
|
||||
if not DRY_RUN:
|
||||
os.rename(old, new)
|
||||
|
||||
write_log(f"\n任务完成,待修改文件夹:{dir_count},待修改文件:{file_count}")
|
||||
write_log("="*60 + "\n")
|
||||
|
||||
Executable
+326
@@ -0,0 +1,326 @@
|
||||
import os
|
||||
import re
|
||||
import argparse
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
LOG_FILE = "./rename_log.txt"
|
||||
|
||||
SKIP_DIRS = [
|
||||
"@eaDir",
|
||||
]
|
||||
|
||||
SKIP_FILES = {
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"desktop.ini",
|
||||
}
|
||||
|
||||
RULES_FILE = [
|
||||
(r"^[A-Za-z] ", ""),
|
||||
(r"\[.*?\]", ""),
|
||||
(r"【.*?】", ""),
|
||||
(r"丨", ""),
|
||||
(r"(?<!\w)(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB-DL|HD|4K|中英双语|双语字幕|双语|中文|国语|粤语|台语|中英双字|国语中字|繁体中字|简体中字|简繁中字|双语中字|官方中字|特效中字|收藏不迷路|地址发布页|www6v123net|wwwdygangme)(?!\w)", "", re.IGNORECASE),
|
||||
(r"\.+", ""),
|
||||
(r"\s", ""),
|
||||
(r"[^\u4e00-\u9fa5a-zA-Z0-9().:+\-]", ""),
|
||||
]
|
||||
|
||||
RULES_DIR = [
|
||||
(r"【.*?】", ""),
|
||||
(r"^\[(.*)\]$", r"\1"),
|
||||
(r"\]\[", ""),
|
||||
(r"^[A-Za-z] ", ""),
|
||||
(r"丨", ""),
|
||||
(r"(?<!\w)(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB-DL|HD|4K|中英双语|双语字幕|双语|中文|国语|粤语|台语|中英双字|国语中字|繁体中字|简体中字|简繁中字|双语中字|官方中字|特效中字|收藏不迷路|地址发布页|www6v123net|wwwdygangme)(?!\w)", "", re.IGNORECASE),
|
||||
(r"\.+", ""),
|
||||
(r"\s", ""),
|
||||
(r"\(\)", ""),
|
||||
(r"()", ""),
|
||||
(r"[^\u4e00-\u9fa5a-zA-Z0-9().:+\-[\]]", ""),
|
||||
]
|
||||
|
||||
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_ONLY_EXT = {".strm", ".srt", ".ass", ".sub"}
|
||||
MAX_FILENAME_LEN = 245
|
||||
|
||||
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)
|
||||
orig_no_ext = 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) == 0:
|
||||
name_no_ext = orig_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 assign_unique_names(item_list):
|
||||
name_counter = dict()
|
||||
out = []
|
||||
for it in item_list:
|
||||
cand = it["candidate"]
|
||||
cnt = name_counter.get(cand, 0)
|
||||
if cnt == 0:
|
||||
final = cand
|
||||
else:
|
||||
final = f"{cand}({cnt})"
|
||||
name_counter[cand] = cnt + 1
|
||||
new_it = dict(it)
|
||||
new_it["final_basename"] = final
|
||||
out.append(new_it)
|
||||
return out
|
||||
|
||||
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 scan_full_tree(root_src, skip_dirs, skip_files, allow_ext):
|
||||
"""
|
||||
返回:
|
||||
dir_nodes: list,每一个文件夹独立节点,包含:
|
||||
src_abs: 当前文件夹完整源绝对路径
|
||||
parent_src_abs: 父文件夹完整源绝对路径
|
||||
orig_basename: 当前文件夹原始名字
|
||||
candidate: 清洗后的候选basename
|
||||
depth:目录深度,用于排序父先于子创建
|
||||
file_nodes: list,媒体文件节点
|
||||
root_src_abs:源根目录绝对路径
|
||||
"""
|
||||
dir_nodes = []
|
||||
file_nodes = []
|
||||
root_src_abs = os.path.abspath(root_src)
|
||||
for src_root, dirs, files in os.walk(root_src, topdown=True):
|
||||
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]
|
||||
dirs[:] = dirs
|
||||
|
||||
src_root_abs = os.path.abspath(src_root)
|
||||
parent_src_abs = os.path.dirname(src_root_abs)
|
||||
depth = src_root_abs.count(os.sep)
|
||||
basename = os.path.basename(src_root_abs)
|
||||
# 每遍历到一个文件夹,就记录为一条dir_nodes(包含所有层级全部目录节点)
|
||||
if src_root_abs != root_src_abs:
|
||||
cand = clean_dir_name(basename)
|
||||
if len(cand) == 0:
|
||||
cand = basename
|
||||
dir_nodes.append({
|
||||
"src_abs": src_root_abs,
|
||||
"parent_src_abs": parent_src_abs,
|
||||
"orig_basename": basename,
|
||||
"candidate": cand,
|
||||
"depth": depth
|
||||
})
|
||||
#收集本目录下媒体文件
|
||||
for fname in files:
|
||||
if fname in skip_files:
|
||||
continue
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext not in allow_ext:
|
||||
continue
|
||||
cand_f = clean_file_name(fname)
|
||||
file_nodes.append({
|
||||
"src_parent_abs": src_root_abs,
|
||||
"orig_basename": fname,
|
||||
"candidate": cand_f
|
||||
})
|
||||
return root_src_abs, dir_nodes, file_nodes
|
||||
|
||||
def group_by_parent_src_abs(item_list, key_name):
|
||||
groups = dict()
|
||||
for it in item_list:
|
||||
k = it[key_name]
|
||||
if k not in groups:
|
||||
groups[k] = []
|
||||
groups[k].append(it)
|
||||
return groups
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="镜像重命名【完整节点采集,严格1:1目录文件数量】")
|
||||
parser.add_argument("--dir", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--strm-mode", action="store_true")
|
||||
parser.add_argument("--log", default="./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.dry_run:
|
||||
DRY_RUN = True
|
||||
|
||||
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"日志初始化警告 {e}")
|
||||
|
||||
write_log("===== 重构:全节点采集,源输出条目严格1:1 =====", 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)
|
||||
|
||||
stat = {
|
||||
"src_dir_total":0,
|
||||
"src_file_total":0,
|
||||
"dir_success":0,
|
||||
"dir_fail":0,
|
||||
"file_success":0,
|
||||
"file_fail":0
|
||||
}
|
||||
|
||||
#阶段1:完整扫描,每一个文件夹都是独立节点
|
||||
root_src_abs, dir_nodes, file_nodes = scan_full_tree(target_dir, SKIP_DIRS, SKIP_FILES, ALLOW_EXT)
|
||||
stat["src_dir_total"] = len(dir_nodes)
|
||||
stat["src_file_total"] = len(file_nodes)
|
||||
write_log(f"【采集统计】源全部目录节点总数:{stat['src_dir_total']},源媒体文件总数:{stat['src_file_total']}", log_path)
|
||||
|
||||
#阶段2:按父源绝对路径分组,组内分配唯一basename,处理重名冲突
|
||||
dir_groups = group_by_parent_src_abs(dir_nodes, "parent_src_abs")
|
||||
dir_final = []
|
||||
for p_abs, items in dir_groups.items():
|
||||
assigned = assign_unique_names(items)
|
||||
dir_final.extend(assigned)
|
||||
|
||||
file_groups = group_by_parent_src_abs(file_nodes, "src_parent_abs")
|
||||
file_final = []
|
||||
for p_abs, items in file_groups.items():
|
||||
assigned = assign_unique_names(items)
|
||||
file_final.extend(assigned)
|
||||
|
||||
#构建源绝对路径 → 输出basename映射
|
||||
src_to_basename = {d["src_abs"]:d["final_basename"] for d in dir_final}
|
||||
#构建源绝对路径 → 输出完整路径映射
|
||||
src_to_out = dict()
|
||||
src_to_out[root_src_abs] = out_dir
|
||||
#按depth升序排序:浅目录优先,父一定先处理
|
||||
dir_final_sorted = sorted(dir_final, key=lambda x:x["depth"])
|
||||
for d in dir_final_sorted:
|
||||
p_src = d["parent_src_abs"]
|
||||
p_out = src_to_out[p_src]
|
||||
child_out = os.path.join(p_out, d["final_basename"])
|
||||
src_to_out[d["src_abs"]] = child_out
|
||||
|
||||
#打印目录日志
|
||||
for it in dir_final:
|
||||
orig = it["orig_basename"]
|
||||
cand = it["candidate"]
|
||||
final = it["final_basename"]
|
||||
if cand != final:
|
||||
write_log(f"【目录重名冲突】源目录[{orig}] 候选[{cand}] → 分配[{final}]", log_path)
|
||||
else:
|
||||
if orig != final:
|
||||
write_log(f"待修改文件夹-> {orig} → {final}", log_path)
|
||||
#打印文件日志
|
||||
for it in file_final:
|
||||
orig = it["orig_basename"]
|
||||
cand = it["candidate"]
|
||||
final = it["final_basename"]
|
||||
if cand != final:
|
||||
write_log(f"【文件重名冲突】源文件[{orig}] 候选[{cand}] → 分配[{final}]", log_path)
|
||||
else:
|
||||
if orig != final:
|
||||
write_log(f"待修改文件-> {orig} → {final}", log_path)
|
||||
|
||||
#阶段3:按深度顺序创建目录,父目录优先
|
||||
for d in dir_final_sorted:
|
||||
full_out = src_to_out[d["src_abs"]]
|
||||
if not DRY_RUN:
|
||||
try:
|
||||
os.makedirs(full_out, exist_ok=True)
|
||||
stat["dir_success"] += 1
|
||||
except OSError as e:
|
||||
stat["dir_fail"] += 1
|
||||
write_log(f"【目录创建失败】{full_out} | {e}", log_path)
|
||||
|
||||
#复制全部媒体文件
|
||||
for it in file_final:
|
||||
src_file = os.path.join(it["src_parent_abs"], it["orig_basename"])
|
||||
dst_parent = src_to_out[it["src_parent_abs"]]
|
||||
dst_file = os.path.join(dst_parent, it["final_basename"])
|
||||
if not DRY_RUN:
|
||||
try:
|
||||
shutil.copy2(src_file, dst_file)
|
||||
stat["file_success"] += 1
|
||||
except OSError as e:
|
||||
stat["file_fail"] +=1
|
||||
write_log(f"【文件复制失败】src:{src_file} dst:{dst_file} | {e}", log_path)
|
||||
|
||||
write_log("----------任务统计汇总----------", log_path)
|
||||
write_log(f"源采集目录节点总数量:{stat['src_dir_total']},源媒体文件总数量:{stat['src_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("【校验】预期目录输出={},实际成功={}".format(stat["src_dir_total"], stat["dir_success"]), log_path)
|
||||
write_log("【校验】预期文件输出={},实际成功={}".format(stat["src_file_total"], stat["file_success"]), log_path)
|
||||
write_log("============================================================", log_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,291 @@
|
||||
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"(?<!\w)(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB-DL|HD|4K|中英双语|双语字幕|双语|中文|国语|粤语|台语|中英双字|国语中字|繁体中字|简体中字|简繁中字|双语中字|官方中字|特效中字)(?!\w)", "", re.IGNORECASE),
|
||||
(r"\.+", ""),
|
||||
(r"\s", ""),
|
||||
(r"[^\u4e00-\u9fa5a-zA-Z0-9().:+\-]", ""),
|
||||
]
|
||||
|
||||
# 文件夹清洗规则
|
||||
RULES_DIR = [
|
||||
(r"【.*?】", ""), # 删除中文全角【】以及内部内容
|
||||
(r"^\[(.*)\]$", r"\1"), # 整串被[]包裹,只剥外层
|
||||
(r"\]\[", ""), # 清除中间碎片 ][
|
||||
(r"^[A-Za-z] ", ""),
|
||||
(r"丨", ""),
|
||||
(r"(?<!\w)(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB-DL|HD|4K|中英双语|双语字幕|双语|中文|国语|粤语|台语|中英双字|国语中字|繁体中字|简体中字|简繁中字|双语中字|官方中字|特效中字)(?!\w)", "", 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 resolve_dir_path(parent_out_dir: str, want_dir_name: str, orig_src_basename: str, log_file: str):
|
||||
"""目录冲突处理:父输出目录下,如果want_dir_name已存在,则追加(1)(2)..."""
|
||||
candidate = want_dir_name
|
||||
idx = 1
|
||||
while True:
|
||||
full_candidate_path = os.path.join(parent_out_dir, candidate)
|
||||
if not os.path.exists(full_candidate_path):
|
||||
return candidate
|
||||
write_log(f"【目录重名冲突】源目录[{orig_src_basename}],输出目录已存在[{candidate}],自动变更为 {want_dir_name}({idx})", log_file)
|
||||
candidate = f"{want_dir_name}({idx})"
|
||||
idx += 1
|
||||
|
||||
def get_quality_tag(filename:str):
|
||||
m = re.search(r"(?<!\w)(2160p|1080p|720p)(?!\w)", 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.dry_run:
|
||||
DRY_RUN = True
|
||||
|
||||
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)
|
||||
parent_src_rel = os.path.dirname(rel)
|
||||
parent_out_dir = os.path.join(out_dir, parent_src_rel)
|
||||
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
|
||||
# 目录冲突检测,获取最终输出目录名
|
||||
final_dir_name = resolve_dir_path(parent_out_dir, new_dir_name, src_basename, log_path)
|
||||
dst_current_dir = os.path.join(parent_out_dir, final_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()
|
||||
@@ -0,0 +1,277 @@
|
||||
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()
|
||||
@@ -0,0 +1,274 @@
|
||||
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"(?<!\w)(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB-DL|HD|4K|中英双语|双语字幕|双语|中文|国语|粤语|台语|韩语|中英双字|国语中字|繁体中字|简体中字|简繁中字|双语中字|官方中字|特效中字)(?!\w)", "", re.IGNORECASE),
|
||||
(r"\.+", ""),
|
||||
(r"\s", ""),
|
||||
(r"[^\u4e00-\u9fa5a-zA-Z0-9().:+\-]", ""),
|
||||
]
|
||||
|
||||
# 文件夹清洗规则
|
||||
RULES_DIR = [
|
||||
(r"【.*?】", ""), # 删除中文全角【】以及内部内容
|
||||
(r"^\[(.*)\]$", r"\1"), # 整串被[]包裹,只剥外层
|
||||
(r"\]\[", ""), # 清除中间碎片 ][
|
||||
(r"^[A-Za-z] ", ""),
|
||||
(r"丨", ""),
|
||||
(r"(?<!\w)(2160p|1080p|720p|HDR|DV|DoVi|HEVC|x265|x264|DDP|AAC|Atmos|TrueHD|BD|BluRay|BLURAY|WEBRip|WEB-DL|HD|4K|中英双语|双语字幕|双语|中文|国语|粤语|台语|韩语|中英双字|国语中字|繁体中字|简体中字|简繁中字|双语中字|官方中字|特效中字)(?!\w)", "", 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"(?<!\w)(2160p|1080p|720p)(?!\w)", 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.dry_run:
|
||||
DRY_RUN = True
|
||||
|
||||
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("===== 文件名+文件夹清洗【修复参数符号,过滤BD/HD/4K/双语/国语等标记】 =====", 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()
|
||||
@@ -0,0 +1,196 @@
|
||||
import os
|
||||
import re
|
||||
import argparse
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
# ====================== 配置区 ======================
|
||||
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().:+-]", ""),
|
||||
]
|
||||
|
||||
RULES_DIR = [
|
||||
(r"^[A-Za-z] ", ""),
|
||||
(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().:+-]", ""),
|
||||
]
|
||||
|
||||
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"}
|
||||
# ====================================================
|
||||
|
||||
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()
|
||||
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)
|
||||
name = re.sub(r"\[.*?\]", "", name)
|
||||
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})"
|
||||
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):
|
||||
"""解决输出目录同名冲突:优先追加画质,无画质则追加(1)(2)"""
|
||||
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)
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
f.flush()
|
||||
|
||||
def process_tree(src_root:str, dst_root:str, dry_run:bool, log_path:str):
|
||||
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
|
||||
}
|
||||
|
||||
for src_dir, dirs, files in os.walk(src_root):
|
||||
stat["scan_dir_total"] +=1
|
||||
rel = os.path.relpath(src_dir, src_root)
|
||||
if rel == ".":
|
||||
dst_current_dir = dst_root
|
||||
else:
|
||||
dst_current_dir = os.path.join(dst_root, clean_dir_name(os.path.basename(src_dir)))
|
||||
|
||||
# 创建输出目录
|
||||
if not dry_run:
|
||||
try:
|
||||
os.makedirs(dst_current_dir, exist_ok=True)
|
||||
stat["dir_success"] +=1
|
||||
except Exception as e:
|
||||
stat["dir_fail"] +=1
|
||||
write_log(f"【目录创建失败】{dst_current_dir} | {e}", log_path)
|
||||
else:
|
||||
write_log(f"【预览-输出目录】{dst_current_dir}", log_path)
|
||||
|
||||
# 处理本目录全部文件
|
||||
for fname in files:
|
||||
stat["scan_file_total"] += 1
|
||||
src_file = os.path.join(src_dir, fname)
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext in ALLOW_EXT:
|
||||
stat["media_file_total"] +=1
|
||||
new_fname = clean_file_name(fname)
|
||||
base, ext_out = os.path.splitext(new_fname)
|
||||
final_fname = resolve_conflict(dst_current_dir, base, ext_out, fname)
|
||||
dst_file = os.path.join(dst_current_dir, final_fname)
|
||||
write_log(f"待复制文件-> {fname} → {final_fname}", log_path)
|
||||
if not dry_run:
|
||||
try:
|
||||
shutil.copy2(src_file, dst_file)
|
||||
stat["file_success"] +=1
|
||||
except Exception as e:
|
||||
stat["file_fail"] +=1
|
||||
write_log(f"【文件复制失败】src:{src_file} dst:{dst_file} | {e}", log_path)
|
||||
return stat
|
||||
|
||||
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="加上此参数才真正复制生成镜像;默认预览dry‑run")
|
||||
parser.add_argument("--log", default="./rename_log.txt", help="日志文件路径,默认同脚本目录rename_log.txt")
|
||||
args = parser.parse_args()
|
||||
|
||||
src_dir = args.dir
|
||||
out_dir = args.output
|
||||
dry_run = not args.apply
|
||||
log_path = args.log
|
||||
|
||||
if not os.path.isdir(src_dir):
|
||||
print(f"错误:源目录不存在 -> {src_dir}")
|
||||
return
|
||||
|
||||
with open(log_path, "w", encoding="utf-8") as f:
|
||||
f.flush()
|
||||
|
||||
write_log("===== 媒体文件名镜像清洗【源目录只读,生成镜像副本】 =====", log_path)
|
||||
write_log(f"源目录:{src_dir}", log_path)
|
||||
write_log(f"输出镜像目录:{out_dir}", log_path)
|
||||
write_log(f"预览模式 DRY_RUN = {dry_run}", log_path)
|
||||
|
||||
stat = process_tree(src_dir, out_dir, dry_run, 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()
|
||||
@@ -0,0 +1,131 @@
|
||||
import os
|
||||
import re
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
# ====================== 【配置区,不再写死目录】 ======================
|
||||
LOG_FILE = "./rename_log.txt"
|
||||
|
||||
# 清洗规则
|
||||
RULES = [
|
||||
(r"^[A-Za-z] ", ""), #1.移除开头单个字母+空格
|
||||
(r"\[.*?\]", ""), #2.移除英文方括号及内部内容
|
||||
(r"【.*?】", ""), #3.移除中文【】及内部内容
|
||||
(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().:+-]", ""),
|
||||
]
|
||||
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"}
|
||||
|
||||
|
||||
# ====================================================================
|
||||
|
||||
def clean_filename(name: str) -> str:
|
||||
name_no_ext, ext = os.path.splitext(name)
|
||||
# 全角转半角
|
||||
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)
|
||||
name_no_ext = full2half(name_no_ext)
|
||||
|
||||
for item in RULES:
|
||||
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()
|
||||
return name_no_ext + ext
|
||||
|
||||
def write_log(msg: str):
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{now}] {msg}"
|
||||
print(line)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
f.flush()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="媒体文件名批量清洗重命名工具")
|
||||
parser.add_argument("--dir", required=True, help="目标根目录路径")
|
||||
parser.add_argument("--apply", action="store_true", help="加上此参数才执行真实重命名;默认仅预览DryRun")
|
||||
args = parser.parse_args()
|
||||
|
||||
target_dir = args.dir
|
||||
DRY_RUN = not args.apply
|
||||
|
||||
# 校验目录是否存在
|
||||
if not os.path.isdir(target_dir):
|
||||
print(f"错误:目录不存在 -> {target_dir}")
|
||||
return
|
||||
|
||||
# 每次运行前清空日志文件
|
||||
with open(LOG_FILE, "w", encoding="utf-8") as f:
|
||||
f.flush()
|
||||
|
||||
write_log("===== 文件名+文件夹清洗(移除开头A 中文片名前缀) =====")
|
||||
write_log(f"根目录:{target_dir}")
|
||||
write_log(f"预览模式 DRY_RUN = {DRY_RUN}")
|
||||
|
||||
file_rename_list = []
|
||||
dir_rename_list = []
|
||||
total_scan_dirs = 0
|
||||
total_all_files = 0 # 全部扫描到的文件(所有后缀)
|
||||
total_media_files = 0 # 媒体文件(ALLOW_EXT后缀)
|
||||
|
||||
for root, dirs, files in os.walk(target_dir, topdown=False):
|
||||
total_scan_dirs += 1
|
||||
for fname in files:
|
||||
total_all_files +=1
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext in ALLOW_EXT:
|
||||
total_media_files += 1
|
||||
new_name = clean_filename(fname)
|
||||
if new_name != fname:
|
||||
old_path = os.path.join(root, fname)
|
||||
new_path = os.path.join(root, new_name)
|
||||
file_rename_list.append((old_path, new_path))
|
||||
write_log(f"待修改文件-> {fname} → {new_name}")
|
||||
|
||||
# 处理文件夹
|
||||
for dname in dirs:
|
||||
new_name = clean_filename(dname)
|
||||
if new_name != dname:
|
||||
old_path = os.path.join(root, dname)
|
||||
new_path = os.path.join(root, new_name)
|
||||
dir_rename_list.append((old_path, new_name))
|
||||
write_log(f"待修改文件夹-> {dname} → {new_name}")
|
||||
|
||||
# ========== 执行阶段 ==========
|
||||
if not DRY_RUN:
|
||||
# 先文件
|
||||
for old, new in file_rename_list:
|
||||
try:
|
||||
os.rename(old, new)
|
||||
except Exception as e:
|
||||
write_log(f"【文件重命名失败】{old} -> {new} | 错误:{e}")
|
||||
# 后文件夹
|
||||
for old, new in dir_rename_list:
|
||||
try:
|
||||
os.rename(old, new)
|
||||
except Exception as e:
|
||||
write_log(f"【文件夹重命名失败】{old} -> {new} | 错误:{e}")
|
||||
|
||||
# 最终汇总输出
|
||||
write_log(f"任务完成,总扫描目录:{total_scan_dirs},待修改文件夹:{len(dir_rename_list)}")
|
||||
write_log(f"总扫描文件:{total_all_files},媒体文件:{total_media_files},待修改文件:{len(file_rename_list)}")
|
||||
write_log("============================================================")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+7833
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
[
|
||||
{
|
||||
"type": "dir",
|
||||
"orig_basename": "洛杉矶劫案.2160p.6v电影 地址发布页 www.6v123.net 收藏不迷路",
|
||||
"step1_name": "洛杉矶劫案.2160p.6v电影地址发布页www.6v123.net收藏不迷路",
|
||||
"step2_name": "洛杉矶劫案_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"D001",
|
||||
"D003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "dir",
|
||||
"orig_basename": "劫机",
|
||||
"step1_name": "劫机",
|
||||
"step2_name": "劫机_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": []
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "4K.strm",
|
||||
"step1_name": "4K.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "1080p.strm",
|
||||
"step1_name": "1080p.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "云边有个小卖部_1080P.strm",
|
||||
"step1_name": "云边有个小卖部_1080P.strm",
|
||||
"step2_name": "云边有个小卖部.strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "1080p.strm",
|
||||
"step1_name": "1080p.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "1080p.strm",
|
||||
"step1_name": "1080p.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[迅雷下载www.2tu.cc]鬼子来了.BD1280高清国语中字.strm",
|
||||
"step1_name": "[迅雷下载www.2tu.cc]鬼子来了.BD1280高清国语中字.strm",
|
||||
"step2_name": "鬼子来了.1280高清国语中字.strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "【太平洋幽灵.Against the Sun】【高清720P版HD-RMVB.中字】.strm",
|
||||
"step1_name": "【太平洋幽灵.AgainsttheSun】【高清720P版HD-RMVB.中字】.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F002"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "【Journey.To.The.West.Conquering.The.Demons】【高清版DVD-RMVB.国语中英双字】.strm",
|
||||
"step1_name": "【Journey.To.The.West.Conquering.The.Demons】【高清版DVD-RMVB.国语中英双字】.strm",
|
||||
"step2_name": "strm_dup(2)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F002"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "【The.Marine.3.Homefront】【高清1280版BD-RMVB.中英双字】.strm",
|
||||
"step1_name": "【The.Marine.3.Homefront】【高清1280版BD-RMVB.中英双字】.strm",
|
||||
"step2_name": "strm_dup(3)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F002"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "加勒比海盗4.strm",
|
||||
"step1_name": "加勒比海盗4.strm",
|
||||
"step2_name": "加勒比海盗4.strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": []
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "1080P.strm",
|
||||
"step1_name": "1080P.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "猎杀红色十月.The.Hunt.for.Red.October.1990.BD1080p.X264.2Audio.AAC.HALFCD-NORM.Christian.strm",
|
||||
"step1_name": "猎杀红色十月.The.Hunt.for.Red.October.1990.BD1080p.X264.2Audio.AAC.HALFCD-NORM.Christian.strm",
|
||||
"step2_name": "猎杀红色十月.The.Hunt.for.Red.October.1990.2Audio.HALFCD-NORM.Christian.strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F003"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[1982][CN][逆光]Ni.Guang.TVRip.Xvid-Mustdown[www.ed2kers.com].strm",
|
||||
"step1_name": "[1982][CN][逆光]Ni.Guang.TVRip.Xvid-Mustdown[www.ed2kers.com].strm",
|
||||
"step2_name": "Ni.Guang.TVRip.Xvid-Mustdown.strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[唐山大兄.cd1.1971.国粵双语].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[唐山大兄.cd1.1971.国粵双语].九洲客.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[死亡游戏.cd2.1978].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[死亡游戏.cd2.1978].九洲客.strm",
|
||||
"step2_name": "strm_dup(2)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[死亡游戏.cd1.1978].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[死亡游戏.cd1.1978].九洲客.strm",
|
||||
"step2_name": "strm_dup(3)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[死亡塔.cd2.1981].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[死亡塔.cd2.1981].九洲客.strm",
|
||||
"step2_name": "strm_dup(4)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[死亡塔.cd1.1981].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[死亡塔.cd1.1981].九洲客.strm",
|
||||
"step2_name": "strm_dup(5)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[猛龙过江.cd2.1972.国粵双语].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[猛龙过江.cd2.1972.国粵双语].九洲客.strm",
|
||||
"step2_name": "strm_dup(6)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[猛龙过江.cd1.1972.国粵双语].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[猛龙过江.cd1.1972.国粵双语].九洲客.strm",
|
||||
"step2_name": "strm_dup(7)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[龙争虎斗.cd2.1973.国粵双语].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[龙争虎斗.cd2.1973.国粵双语].九洲客.strm",
|
||||
"step2_name": "strm_dup(8)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[龙争虎斗.cd1.1973.国粵双语].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[龙争虎斗.cd1.1973.国粵双语].九洲客.strm",
|
||||
"step2_name": "strm_dup(9)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[精武门.cd2.1972.国粵双语].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[精武门.cd2.1972.国粵双语].九洲客.strm",
|
||||
"step2_name": "strm_dup(10)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[精武门.cd1.1972.国粵双语].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[精武门.cd1.1972.国粵双语].九洲客.strm",
|
||||
"step2_name": "strm_dup(11)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[开心鬼4.1989.国粤双语.简繁中字].暮云春树.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[开心鬼4.1989.国粤双语.简繁中字].暮云春树.strm",
|
||||
"step2_name": "暮云春树.strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[开心鬼2.1985.国粤双语.简繁中字].tdw9430.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[开心鬼2.1985.国粤双语.简繁中字].tdw9430.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[开心鬼1.1984.国粤双语.简繁中字].tdw9430.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[开心鬼1.1984.国粤双语.简繁中字].tdw9430.strm",
|
||||
"step2_name": "strm_dup(2)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[极限特工Ⅰ.2002.中英字幕].九洲客.strm",
|
||||
"step1_name": "[极限特工Ⅰ.2002.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn [黑客帝国 3.2003.中英字幕].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[黑客帝国3.2003.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn [黑客帝国 2.2003.中英字幕].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[黑客帝国2.2003.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(2)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn [黑客帝国 1.1999.中英字幕].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[黑客帝国1.1999.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(3)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[非常人贩2.2005.中英字幕].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[非常人贩2.2005.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[SCG-CMCT]www.dream2008.cn[非常人贩1.2002.中英字幕].九洲客.strm",
|
||||
"step1_name": "[SCG-CMCT]www.dream2008.cn[非常人贩1.2002.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(2)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[电锯惊魂Ⅳ.2007.中英字幕].九洲客.strm",
|
||||
"step1_name": "[电锯惊魂Ⅳ.2007.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[电锯惊魂Ⅲ.2006.中英字幕].九洲客.strm",
|
||||
"step1_name": "[电锯惊魂Ⅲ.2006.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(2)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[电锯惊魂Ⅱ.2005.中英字幕].九洲客.strm",
|
||||
"step1_name": "[电锯惊魂Ⅱ.2005.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(3)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[电锯惊魂Ⅰ.2004.中英字幕].九洲客.strm",
|
||||
"step1_name": "[电锯惊魂Ⅰ.2004.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(4)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[第一滴血Ⅲ.1988.中英字幕].九洲客.strm",
|
||||
"step1_name": "[第一滴血Ⅲ.1988.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(1)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[第一滴血Ⅱ.1985.中英字幕].九洲客.strm",
|
||||
"step1_name": "[第一滴血Ⅱ.1985.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(2)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"orig_basename": "[第一滴血Ⅰ.1982.中英字幕].九洲客.strm",
|
||||
"step1_name": "[第一滴血Ⅰ.1982.中英字幕].九洲客.strm",
|
||||
"step2_name": "strm_dup(3)",
|
||||
"step2_dup_warn": true,
|
||||
"safe_guard_trigger": false,
|
||||
"hit_rule_ids": [
|
||||
"F001",
|
||||
"F006"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
watcher.py —— 自研媒体刮削「监听服务」
|
||||
|
||||
功能:
|
||||
1. 用 watchdog 监听「清理后媒体目录」
|
||||
2. 检测到新媒体文件/目录写入稳定后,自动调用 media_scraper 刮削
|
||||
3. 刮削结果(媒体 + nfo + 海报)复制落库到成品目录
|
||||
4. 源目录保持原样,不写入任何文件
|
||||
|
||||
用法:
|
||||
./watcher.py --config config.json # 常驻服务
|
||||
./watcher.py --config config.json --once # 只扫描一次现有内容后退出(用于初始化存量)
|
||||
|
||||
config.json 字段:
|
||||
{
|
||||
"source": "<清理后媒体目录>",
|
||||
"dest": "<成品落库目录>",
|
||||
"tmdb_api_key": "", # TMDB API Key;留空时用 --mock 跑通管线
|
||||
"mock": true, # true=模拟元数据(无Key验证用);true后配好Key改为 false
|
||||
"settle": 6, # 写入稳定等待秒数
|
||||
"language": "zh-CN"
|
||||
}
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
# 复用核心刮削器
|
||||
from media_scraper import (Config, Scraper, clean_ext, VIDEO_EXTS,
|
||||
scan_and_scrape)
|
||||
|
||||
|
||||
class Config2(Config):
|
||||
"""扩展 Config:加载 watcher 的 source/dest/settle。"""
|
||||
|
||||
def __init__(self, path):
|
||||
super().__init__(path)
|
||||
data = json.load(open(path, encoding="utf-8"))
|
||||
self.source = os.path.abspath(data.get("source", ""))
|
||||
self.dest = os.path.abspath(data.get("dest", ""))
|
||||
self.settle = int(data.get("settle", 6))
|
||||
self.mock = data.get("mock", False)
|
||||
if self.mock:
|
||||
self.tmdb_api_key = "" # mock 模式不需要 key
|
||||
|
||||
|
||||
class ScraperWorker:
|
||||
"""把新媒体单元交给刮削器,带去重与写入稳定判定。"""
|
||||
|
||||
def __init__(self, cfg, log):
|
||||
self.cfg = cfg
|
||||
self.log = log
|
||||
self.scraper = Scraper(cfg, cfg.dest, source_root=cfg.source, log=log)
|
||||
self.processed = set() # 已处理的绝对路径
|
||||
self.lock = threading.Lock()
|
||||
os.makedirs(cfg.dest, exist_ok=True)
|
||||
|
||||
def _is_stable(self, path):
|
||||
"""判断文件/目录是否写入稳定:两次采样大小一致,最多等 settle 秒。"""
|
||||
def size(p):
|
||||
if os.path.isfile(p):
|
||||
return os.path.getsize(p)
|
||||
# 目录:取所有文件大小之和
|
||||
try:
|
||||
return sum(os.path.getsize(os.path.join(r, f))
|
||||
for r, _, fs in os.walk(p) for f in fs)
|
||||
except OSError:
|
||||
return -1
|
||||
last = size(path)
|
||||
wait = self.cfg.settle
|
||||
while wait > 0:
|
||||
time.sleep(min(wait, 2))
|
||||
wait -= 2
|
||||
cur = size(path)
|
||||
if cur == last and cur >= 0:
|
||||
return True
|
||||
last = cur
|
||||
return True
|
||||
|
||||
def handle(self, path):
|
||||
path = os.path.abspath(path)
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
key = path if os.path.isdir(path) else os.path.dirname(path)
|
||||
with self.lock:
|
||||
if key in self.processed:
|
||||
return
|
||||
self.processed.add(key)
|
||||
try:
|
||||
self.log(f"检测到新媒体: {path},等待写入稳定…")
|
||||
self._is_stable(path)
|
||||
self.log(f"开始刮削: {path}")
|
||||
scan_and_scrape(path, self.cfg.dest, self.scraper)
|
||||
self.log(f"完成: {path}")
|
||||
except Exception as e:
|
||||
self.log(f"处理失败 {path}: {e}")
|
||||
|
||||
|
||||
class MediaHandler(FileSystemEventHandler):
|
||||
def __init__(self, worker):
|
||||
self.worker = worker
|
||||
self._threads = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def _dispatch(self, path):
|
||||
if os.path.basename(path).startswith("."): # 隐藏/临时文件
|
||||
return
|
||||
ext = clean_ext(path)
|
||||
if ext in VIDEO_EXTS or os.path.isdir(path):
|
||||
t = threading.Thread(target=self.worker.handle, args=(path,),
|
||||
daemon=True)
|
||||
t.start()
|
||||
with self.lock:
|
||||
self._threads.append(t)
|
||||
self._threads = [x for x in self._threads if x.is_alive()]
|
||||
|
||||
def on_created(self, event):
|
||||
self._dispatch(event.src_path)
|
||||
|
||||
def on_moved(self, event):
|
||||
# 下载工具常见:先 .part 再改名成正式名,此时事件是 moved
|
||||
if not event.dest_path.endswith(".part"):
|
||||
self._dispatch(event.dest_path)
|
||||
|
||||
def on_closed(self, event):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="自研媒体刮削监听服务")
|
||||
ap.add_argument("--config", required=True, help="config.json 路径")
|
||||
ap.add_argument("--once", action="store_true",
|
||||
help="扫描现有内容一次后退出(初始化存量)")
|
||||
args = ap.parse_args()
|
||||
|
||||
cfg = Config2(args.config)
|
||||
if not cfg.source or not os.path.isdir(cfg.source):
|
||||
print(f"配置错误:source 不存在或未设置 -> {cfg.source}")
|
||||
sys.exit(1)
|
||||
|
||||
def log(msg):
|
||||
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
||||
print(line)
|
||||
# 追加写日志文件
|
||||
logfile = os.path.join(cfg.dest, "..", "scraper.log")
|
||||
try:
|
||||
with open(logfile, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
worker = ScraperWorker(cfg, log)
|
||||
|
||||
# 启动时先扫描一次现有内容(这样重启服务能补处理之前漏掉的媒体)
|
||||
for entry in os.listdir(cfg.source):
|
||||
p = os.path.join(cfg.source, entry)
|
||||
if os.path.isdir(p) or clean_ext(p) in VIDEO_EXTS:
|
||||
worker.handle(p)
|
||||
if args.once:
|
||||
log("存量扫描完成,退出")
|
||||
return
|
||||
|
||||
# 常驻监听
|
||||
handler = MediaHandler(worker)
|
||||
observer = Observer()
|
||||
observer.schedule(handler, cfg.source, recursive=False)
|
||||
observer.start()
|
||||
log(f"开始监听: {cfg.source} -> 成品目录: {cfg.dest}")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
# 自研媒体刮削监听服务 —— 使用说明
|
||||
|
||||
一个 Python 服务:监听「清理后媒体目录」,新媒体出现自动刮削(TMDB),结果**复制**落库到成品目录。
|
||||
已在本机 macOS 用 mock 模式跑通整条链路(监听 → 识别 → 刮削 → 复制落库)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `media_scraper.py` | 核心刮削器:文件名解析 → TMDB 查询 → 生成 nfo(Kodi规范) → 下载海报 → 复制落库 |
|
||||
| `watcher.py` | 监听服务:watchdog 监听清理目录,写入稳定后自动触发刮削 |
|
||||
| `config.json` | 配置:源目录、成品目录、TMDB Key、mock 开关 |
|
||||
| `.venv/` | 本地 Python 环境(已装 watchdog、requests) |
|
||||
|
||||
## 快速上手(本地 macOS)
|
||||
|
||||
```bash
|
||||
# 1) 已准备好 .venv(watchdog、requests 已装)。若新环境:
|
||||
python3 -m venv .venv
|
||||
./.venv/bin/pip install watchdog requests
|
||||
|
||||
# 2) 先 mock 跑通管线(无需 Key,验证 nfo 生成 + 复制落库)
|
||||
./.venv/bin/python watcher.py --config config.json --once
|
||||
|
||||
# 3) 配好 TMDB Key 后真刮削
|
||||
# 在 config.json 填 tmdb_api_key,并把 "mock": true 改成 false
|
||||
```
|
||||
|
||||
## 配置(config.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "/vol1/media/cleaned", // 你的清理后媒体目录(media_final.py 的输出)
|
||||
"dest": "/vol1/media/library", // 成品落库目录(刮削+复制后)
|
||||
"tmdb_api_key": "", // TMDB API Key(themoviedb.org/settings/api 免费申请)
|
||||
"mock": true, // true=模拟元数据;配好 Key 后改 false
|
||||
"settle": 6, // 写入稳定等待秒数(大文件拷贝完再处理)
|
||||
"language": "zh-CN" // 刮削文案语言
|
||||
}
|
||||
```
|
||||
|
||||
> `mock: true` 时不需要 Key,用于无网络/无 Key 环境下验证整条管线;正式使用务必 `mock: false` + 填 Key。
|
||||
|
||||
## 部署到飞牛 fnOS
|
||||
|
||||
飞牛基于 Linux(Debian)。推荐用 Python3 + systemd(或直接用容器把本目录挂进去跑)。
|
||||
|
||||
```bash
|
||||
cd /你的项目目录
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install watchdog requests
|
||||
# 编辑 config.json,把 source/dest 改成飞牛上的真实路径,填 TMDB Key,mock 改 false
|
||||
|
||||
# 常驻运行(前台,Ctrl+C 停止)
|
||||
.venv/bin/python watcher.py --config config.json
|
||||
|
||||
# 后台常驻(nohup)
|
||||
nohup .venv/bin/python watcher.py --config config.json > scraper.out 2>&1 &
|
||||
|
||||
# 或用 systemd 自启(推荐)
|
||||
# 项目里已附 media-scraper.service:先把它里面 3 处「/你的项目绝对路径」改成飞牛真实路径
|
||||
sudo cp media-scraper.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now media-scraper # 开机自启 + 立即启动
|
||||
journalctl -u media-scraper -f # 看日志
|
||||
```
|
||||
|
||||
## 命令行用法(media_scraper.py 单条/批量)
|
||||
|
||||
```bash
|
||||
# 处理单个媒体文件或目录
|
||||
./.venv/bin/python media_scraper.py --source "阿凡达(2009).mkv" --dest /成品目录
|
||||
|
||||
# 递归处理某目录下所有新媒体
|
||||
./.venv/bin/python media_scraper.py --source /清理目录 --dest /成品目录 --recurse
|
||||
|
||||
# 预览不落盘
|
||||
./.venv/bin/python media_scraper.py --source xx --dest yy --dry-run
|
||||
|
||||
# mock 模式(无 Key 验证)
|
||||
./.venv/bin/python media_scraper.py --source xx --dest yy --mock
|
||||
```
|
||||
|
||||
## 行为与命名约定
|
||||
|
||||
- **电影**:单文件 `阿凡达(2009).mkv` 或电影目录 → 复制后生成 `阿凡达(2009).nfo` + 海报。
|
||||
- **剧集**:目录内文件带 `S01E01` / `第01集` / `E01` → 识别为剧集 → 生成 `tvshow.nfo` + 每集 `xxx.nfo` + 海报。
|
||||
- **落库方式**:始终「复制」,源目录保持原样、不写入任何文件。
|
||||
- **安全保护**:目标路径若与源相同会直接报错终止,绝不误删源目录。
|
||||
|
||||
## 已知限制(诚实说明)
|
||||
|
||||
- 数据源仅 TMDB 单一来源;冷门片/非主流命名可能匹配不准。
|
||||
- 海报下载依赖能访问 TMDB 图片服务器;国内网络可能需代理或换 TMDB 备用源。
|
||||
- 单集文件若无季信息默认按第 1 季处理;建议目录按「剧名(年份)/剧名.SxxExx.mkv」组织识别最准。
|
||||
- nfo 字段已覆盖 title/year/rating/plot/uniqueid/genre/**director/studio/actor** 等核心项;如需更多字段可再扩展。演员、导演、制片厂来自 TMDB credits 接口,连不上时为空白但不影响 nfo 生成。
|
||||
|
||||
## 验证方式
|
||||
|
||||
- 本机已用 mock 跑通:电影、剧集目录、单集均正确复制 + 生成 nfo,且实时监听投放新文件能自动触发刮削落库。
|
||||
- 部署后自查:往 `source` 丢一个清理后的影片,几秒后检查 `dest` 是否出现「媒体副本 + .nfo(+ 海报)」。
|
||||
Reference in New Issue
Block a user