#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ media_scraper.py —— 自研媒体刮削器(核心库 + 命令行) 流程:接收一个媒体单元(单个电影文件 / 电影目录 / 剧集目录) 1. 从文件名/目录名解析标题、年份、类型(电影/剧集)、季/集 2. 查询 TMDB(电影 search/movie,剧集 search/tv) 3. 把媒体文件「复制」到成品目录(落库) 4. 在成品目录就地生成 Kodi 规范的 nfo + 下载海报/背景图 命令行用法: ./media_scraper.py --source <路径> --dest <成品目录> [--config config.json] [--mock] [--dry-run] [--recurse] 依赖:requests(TMDB 调用)。TMDB API Key 通过 config.json 或环境变量 TMDB_API_KEY 提供。 未配置 Key 时可用 --mock 模式跑通整条管线(用模拟元数据,验证 nfo 生成 + 复制落库)。 """ import argparse import json import os import re import shutil import sys import time import xml.etree.ElementTree as ET from datetime import datetime from urllib.parse import quote import requests # --------------------------------------------------------------------------- # 常量与配置 # --------------------------------------------------------------------------- VIDEO_EXTS = {".mkv", ".mp4", ".mov", ".avi", ".wmv", ".flv", ".ts", ".webm", ".rmvb", ".iso", ".m2ts", ".mts"} SUB_EXTS = {".srt", ".ass", ".sub", ".ssa"} TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500" TMDB_IMAGE_ORIG = "https://image.tmdb.org/t/p/original" DEFAULT_LANGUAGE = "zh-CN" # 刮削文案语言,可按需改 FALLBACK_LANGUAGE = "en-US" class Config: """从 config.json / 环境变量读取配置。""" def __init__(self, path=None): self.tmdb_api_key = os.environ.get("TMDB_API_KEY", "") self.language = DEFAULT_LANGUAGE self.mock = False self.timeout = 20 if path and os.path.isfile(path): data = json.load(open(path, encoding="utf-8")) self.tmdb_api_key = data.get("tmdb_api_key", self.tmdb_api_key) self.language = data.get("language", self.language) self.mock = data.get("mock", self.mock) self.timeout = data.get("timeout", self.timeout) # --------------------------------------------------------------------------- # 文件名解析 # --------------------------------------------------------------------------- def clean_ext(name): return os.path.splitext(name)[1].lower() def parse_media_name(name): """从单个文件名或目录名解析媒体信息。 返回 dict: kind: "movie" | "tv" title: 标题(尽量去除年份/季集标记) year: 年份(int) 或 None season/episode: 剧集时存在 """ base = os.path.basename(name) base = os.path.splitext(base)[0] info = {"kind": "movie", "title": base, "year": None, "season": None, "episode": None} # 剧集标记:S01E02 / s1e2 / 第01集 / E01 / 第 1 季 第 2 集 m = re.search(r"[Ss](\d{1,2})[Ee](\d{1,3})", base) # SxxExx if m: info["kind"] = "tv" info["season"] = int(m.group(1)) info["episode"] = int(m.group(2)) else: m = re.search(r"第\s*(\d{1,2})\s*[集話话]", base) # 第01集 if m: info["kind"] = "tv" info["season"] = 1 info["episode"] = int(m.group(1)) else: m2 = re.search(r"[Ee](\d{1,3})", base) # E01 if m2: info["kind"] = "tv" info["season"] = 1 info["episode"] = int(m2.group(1)) # 年份 m = re.search(r"\((\d{4})\)", base) or re.search(r"(\b19\d{2}\b|\b20\d{2}\b)", base) if m: info["year"] = int(m.group(1)) base = base.replace(f"({m.group(1)})", "") # 去除季集标记,得到标题 base = re.sub(r"[Ss]\d{1,2}[Ee]\d{1,3}", "", base) base = re.sub(r"第\s*\d{1,2}\s*[集話话]", "", base) base = re.sub(r"[Ee]\d{1,3}", "", base) base = re.sub(r"\((\d{4})\)", "", base) base = re.sub(r"(\b19\d{2}\b|\b20\d{2}\b)", "", base) base = re.sub(r"[\s._\-]+", " ", base).strip() info["title"] = base if base else info["title"] return info # --------------------------------------------------------------------------- # TMDB 客户端 # --------------------------------------------------------------------------- class TMDBApi: def __init__(self, config): self.cfg = config self.sess = requests.Session() self.sess.headers.update({ "Authorization": f"Bearer {config.tmdb_api_key}", "Content-Type": "application/json;charset=utf-8", }) self.base = "https://api.themoviedb.org/3" def _get(self, path, params): if not self.cfg.tmdb_api_key: raise RuntimeError("未配置 TMDB API Key(config.json 或环境变量 TMDB_API_KEY)") r = self.sess.get(f"{self.base}/{path}", params=params, timeout=self.cfg.timeout) r.raise_for_status() return r.json() def search(self, kind, title, year=None): path = "search/movie" if kind == "movie" else "search/tv" params = {"query": title, "language": self.cfg.language} if year: params["year" if kind == "movie" else "first_air_date_year"] = year data = self._get(path, params) results = data.get("results") or [] # 优先标题精确匹配 + 年份匹配 exact = [r for r in results if r.get("title" if kind == "movie" else "name", "").lower() == title.lower()] if year and exact: exact = [r for r in exact if str(r.get("release_date" if kind == "movie" else "first_air_date", "") or "") .startswith(str(year))] if exact: return exact[0] if results: return results[0] return None def get_details(self, kind, tmdb_id): path = f"movie/{tmdb_id}" if kind == "movie" else f"tv/{tmdb_id}" return self._get(path, {"language": self.cfg.language}) def get_credits(self, kind, tmdb_id): """电影 cast/crew;剧集 cast。用于 nfo 的演员、导演、制片厂字段。""" path = f"movie/{tmdb_id}/credits" if kind == "movie" else f"tv/{tmdb_id}/credits" return self._get(path, {"language": self.cfg.language}) def image_url(self, path, size="w500"): if not path: return None base = TMDB_IMAGE_BASE if size == "w500" else TMDB_IMAGE_ORIG return f"{base}{path}" # --------------------------------------------------------------------------- # nfo 生成 # --------------------------------------------------------------------------- def _esc(s): if s is None: return "" return str(s).replace("&", "&").replace("<", "<").replace(">", ">") def _txt(node, tag, value): if value is not None and str(value).strip(): ET.SubElement(node, tag).text = str(value).strip() def build_movie_nfo(meta): """meta: TMDB movie details 的整理字段。返回 movie.nfo XML 字符串。""" root = ET.Element("movie") _txt(root, "title", meta.get("title")) _txt(root, "originaltitle", meta.get("original_title")) _txt(root, "year", meta.get("year")) _txt(root, "rating", meta.get("vote_average")) _txt(root, "votes", meta.get("vote_count")) _txt(root, "runtime", meta.get("runtime")) _txt(root, "plot", meta.get("overview")) _txt(root, "tagline", meta.get("tagline")) _txt(root, "premiered", meta.get("release_date")) _txt(root, "release", meta.get("release_date")) for g in meta.get("genres", []) or []: _txt(root, "genre", g) for d in meta.get("directors", []) or []: _txt(root, "director", d) for s in meta.get("studios", []) or []: _txt(root, "studio", s) _txt(root, "mpaa", meta.get("certification")) if meta.get("tmdb_id"): ET.SubElement(root, "uniqueid", attrib={"type": "tmdb"}).text = str(meta["tmdb_id"]) if meta.get("imdb_id"): ET.SubElement(root, "uniqueid", attrib={"type": "imdb"}).text = str(meta["imdb_id"]) if meta.get("poster_path"): ET.SubElement(root, "thumb").text = TMDB_IMAGE_BASE + meta["poster_path"] if meta.get("fanart_path"): f = ET.SubElement(root, "fanart") ET.SubElement(f, "thumb").text = TMDB_IMAGE_ORIG + meta["fanart_path"] for a in meta.get("cast", []) or []: ac = ET.SubElement(root, "actor") _txt(ac, "name", a.get("name")) _txt(ac, "role", a.get("role")) if a.get("thumb"): _txt(ac, "thumb", TMDB_IMAGE_BASE + a["thumb"]) return '\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 '\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 '\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()