This commit is contained in:
CrazyJasonwell
2026-10-07 11:07:00 +08:00
parent e88e4de0f5
commit 290ff8984f
96 changed files with 213854 additions and 0 deletions
+94
View File
@@ -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
}
]
}
+142
View File
@@ -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
}
]
}
+8
View File
@@ -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"
}
+17
View File
@@ -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
+449
View File
@@ -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()
+597
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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()
+23
View File
@@ -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 # 使用国内源加速依赖安装
+37
View File
@@ -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`。
Binary file not shown.
+124
View File
@@ -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("============================================================")
+100
View File
@@ -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")
+92
View File
@@ -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")
+326
View File
@@ -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()
+291
View File
@@ -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()
+277
View File
@@ -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()
+274
View File
@@ -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()
+196
View File
@@ -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()
+131
View File
@@ -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()
+7833
View File
File diff suppressed because it is too large Load Diff
+489
View File
@@ -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"
]
}
]
+186
View File
@@ -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()
+101
View File
@@ -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(+ 海报)」。