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

598 lines
24 KiB
Python
Raw Permalink Blame History

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