老版《神秘博士》编号混乱?用Python与ffprobe规范媒体库文件
2026/9/5 21:36:25 网站建设 项目流程

这几年陆续收了一批老剧资源,尤其是老版《神秘博士》(Classic Doctor Who),真正让我头疼的其实不是画质,而是“编号”。比如打开《死亡特使》(The Ambassadors of Death)第三集,文件名里赫然写着 S07E03,但拿去给 Emby、Kodi 一刮削,经常匹配出完全不同的结果:到底应该按“第 7 季第 3 集”理解,还是按“第 7 季第 3 个故事的第 3 集”理解?这个细节如果没搞明白,媒体库就会乱成一锅粥。

本文就围绕这个问题,讲清楚老版《神秘博士》这类“多集连续单元剧”的资源命名逻辑,并给出一个用 Python 批量整理文件名的完整方案。大家可以把它当成一次“影视文件元数据规范化”的实战记录来看,既能解决老剧归档的实际痛点,也能学到一些文件整理、ffprobe 校验和媒体库刮削的通用技巧。

1. 老版《神秘博士》的“季/集”为什么容易搞混

1.1 老版与新版的结构差异

很多朋友是从 2005 年重启版《神秘博士》开始入坑的。重启版的结构很清晰,每一季由若干相对独立的剧集组成,一集一个故事,偶尔遇到上下集,也会通过 Episode 编号连续排下去。所以看到 S07E03,可以直接理解成“第 7 季第 3 集”。

但老版《神秘博士》不一样。老版从 1963 年一直播到 1989 年,在 2005 年重启前,每一季内部通常是分成若干个“连续单元剧”(英文里常叫 Serial / Story)。每个 Serial 负责讲一个完整故事,而这个完整故事又被拆成 4 到 10 集不等。以 1970 年的第 7 季为例,这一季包含 4 个主要故事,其中就包括《Spearhead from Space》《Doctor Who and the Silurians》《The Ambassadors of Death》《Inferno》。

《死亡特使》正是第 7 季的第 3 个故事。因此,标题里写到的“S07E03”和“The Ambassadors of Death Episode 3”放在一起,正确含义更接近“第 7 季、第 3 个故事、第 3 集”,而不是很多新入坑观众理解的那种“第 7 季的第 3 个独立剧集”。

1.2 影视资料库眼中的老版编号

如果你去查 TheTVDB 或者 TMDB 这类影视元数据库,会发现老版《神秘博士》的数据建模方式比我们日常文件夹复杂得多。它需要同时管理“故事”和“分集”两层信息:

  • Story 维度:描述某个故事属于哪一季、是第几个故事。
  • Episode 维度:描述某个分集在这个故事内部是第几集,以及在整个季播出序列中是第几集。

正是因为这个“两套数据维度”,老剧收藏者最常踩的坑就是:拿着文件名里的 S07E03 去匹配刮削器,匹配出来的却是《Spearhead from Space》第三集,或者干脆匹配到 2005 年新版《神秘博士》的第七季第三集,因为新版的数据源更新、热度更高,一些刮削器会优先命中新版条目。

1.3 从技术上理解这个问题

如果把这个场景抽象成数据问题,大概可以这么理解:文件名是“物理主键”,影视数据库的编号是“逻辑主键”,两者之间存在一个映射关系。我们要做的,就是维护好这张映射表,把物理文件名翻译成媒体库能够识别的逻辑编号。

在整理大规模经典影集资源时,大家经常会遇到类似情况:不是所有视频站、种子资源站都遵循同一种命名规范。有人喜欢把老版《神秘博士》写成Doctor.Who.1963.S07E03,有人写成Doctor Who (1963) - 7x03,还有人会写成Doctor.Who.S07.E03.The.Ambassadors.of.Death。这些写法都会造成刮削混乱。

所以,先别急着抱怨服务器识别错,本质是缺一个“从资源标题到数据库编号”的清洗规则。下面我们从工程化角度一步步解决。

2. 环境准备与工具说明

2.1 建议的工作目录结构

在进行资源整理前,建议把“下载/临时目录”和“媒体库目录”严格分开。这样即使某一步脚本出问题,也不会污染已经整理好的媒体库。

示例目录结构如下:

D:\classic-who-work ├── _raw # 原始文件,建议只读,不直接修改 │ ├── Doctor.Who.1963.S07E03.Part3.The.Ambassadors.of.Death.mkv │ └── Doctor.Who.1963.S07E03.Part3.The.Ambassadors.of.Death.ass ├── _renamed # 脚本输出目录,放置整理后的文件 └── _logs # 重命名日志与报告

在实际项目中,大家可以根据自己的磁盘结构调整。比如《神秘博士》老版资源可能按季存放:

E:\TV\Doctor Who (1963) ├── Season 07 │ ├── Doctor Who (1963) - S07E01 - Spearhead from Space - Part 1.mkv │ ├── Doctor Who (1963) - S07E02 - Spearhead from Space - Part 2.mkv │ ├── ...

但注意,这个结构不一定能全网通用,因为不同媒体服务器的解析规则不同。后面实战章节会给出一种更灵活的方案。

2.2 Python 环境

本文的脚本使用 Python 编写,建议 Python 3.10 及以上版本。需要确保本机可以正常运行python命令。在 Windows 下可以通过 CMD 或 PowerShell 执行:

python --version

如果输出版本号低于 3.10,建议先升级,或者至少使用 3.8+,否则有些新版语法特性可能不支持。

为了减少环境依赖,脚本不会使用第三方 Python 库,只用标准库osrejsoncsvshutilargparsepathlib。也就是说,只要 Python 环境没问题,代码拿到就能跑,不需要执行pip install

2.3 FFmpeg 与 FFprobe

FFprobe 是 FFmpeg 套件中的一个工具,用来读取媒体文件信息,比如编码格式、时长、分辨率、音轨数量、字幕轨道等。在检查视频文件是否完整时非常有用。

FFmpeg 的安装方式因系统而异,常见的是:

  • Windows:从 FFmpeg 官网下载编译好的 Windows 版本,解压后把bin目录加入系统 PATH。
  • macOS:可以通过 Homebrew 安装,命令是brew install ffmpeg
  • Linux:多数发行版通过包管理器安装,比如sudo apt install ffmpeg

安装完成后,建议验证一下:

ffprobe -version

只要能看到版本信息,就说明工具可用。由于 FFmpeg 版本迭代比较快,本文示例不依赖某个特定版本,不同版本的输出格式大体一致,但个别字段会略有差异,请以你本机实际输出为准。

2.4 可选工具:媒体管理服务器

如果只是单纯整理文件,不一定要安装 Emby、Jellyfin 或 Kodi。但如果想验证最终命名是否被正确刮削,可以用 Jellyfin 或者 Kodi 搭建一个本地媒体库。Jellyfin 是开源软件,适合作为本地测试环境,不会额外收费。不过这不属于本文的核心内容,后面只在排错章节里作为示范场景提到。

3. 老版剧集元数据建模与命名思路

3.1 拆解标题中的关键字段

我们先把标题信息拆开看:Classic Doctor Who S07E03 The Ambassadors Of Death Episode 3

这个标题可以解释成如下字段:

  • series:Doctor Who
  • format:Classic,表示老版
  • season:7
  • story_index:3,说明这是第 7 季的第 3 个故事
  • story_title:The Ambassadors of Death
  • part_index:3,说明这是该故事的第 3 集

之所以出现“Episode 3”和“S07E03”同时出现,正是因为老版资源在发布时习惯用“SxxEyy”表示“第几季第几个故事”,再用“Episode z”表示“这个故事的第几集”。

在媒体库里,为了保持最终文件名的可读性,通常希望得到这样一层信息:

剧集数据库里的 Season = 7 剧集数据库里的 Episode = 服务端匹配后计算出的分集顺序 故事标题 = The Ambassadors of Death 分集序号 = 3

问题在于,不同影视数据库对“分集顺序”的计算方式不同。有的数据库会把这个故事的第 1 集映射成 Season 7 Episode 12,有的可能映射成 Episode 15。如果只根据我们的记忆写死,很容易在 Emby 里出现“文件内容与简介不符”的情况。

3.2 用 JSON 构造映射表

为了规避个人记忆偏差,工程化的做法是:把“从文件名中抽取到的 story_level_key”作为主键,把“在某个数据库中确认过的 episode_number”作为映射结果,保存成一份独立的 JSON 或 CSV 表。

例如:

{ "07x03": { "season": 7, "story_index": 3, "story_title": "The Ambassadors of Death", "total_parts": 7, "database_episode_offset": 9 } }

这里的database_episode_offset字段需要你通过 TheTVDB 或 TMDB 的官方页面确认,不同站点数值可能不同。如果你在整理老版《神秘博士》时,希望刮削器准确识别,建议以 TheTVDB 当前数据为准,不要凭记忆硬填。这个偏移量代表:该故事的第 1 集相对于当前季第 1 个 EP 编号的偏移。有了它,脚本就可以通过part_index计算出最终的 Episode 号:

最终 Episode = 故事首集在季中的起始集号 + part_index - 1

当然,如果你完全不想维护映射表,也可以走另一条路径:保留文件名的原始结构,然后为该视频生成一个 NFO 文件。Kodi、Emby、Jellyfin 都支持通过 NFO 文件读取剧集信息,这样即使文件名不那么“标准”,也能被正确识别。后面第 5 节会写一个兼顾两种模式的脚本。

3.3 多命名风格兼容

整理老剧时,我们最好对资源发布者的“恶趣味”有心理准备。同一个文件,你可能会看到下面这些命名:

Doctor.Who.1963.S07E03.The.Ambassadors.of.Death.Part.3.mkv Doctor.Who.S07.E03.The.Ambassadors.of.Death.3of7.mkv Doctor Who (1963) - 7x03 - The Ambassadors of Death Episode 3.mkv 死亡特使.第三集.mkv

正则表达式要做的事情,是先把这些写法统一提取成下面的结构化信息:

season=7 story_index=3 part_index=3 story_title=The Ambassadors of Death

如果没有 story_title,只靠数字,那么“07x03”是“第 3 个故事”还是“整个季播出顺序中的第 3 集”其实是无解的。这就是为什么老剧整理必须额外维护映射表,不能只用正则全自动解决。影视文件的自动整理,从来不是单纯的“文件重命名”,而是一个小型的“元数据清洗”项目。

4. 批量整理脚本实战

4.1 脚本目标

下面我们写一个完整的 Python 脚本,用来批量整理目录下的《神秘博士》老版资源文件。它具备以下能力:

  1. 扫描指定目录。
  2. 从文件名中抽取季、故事序号、分集序号。
  3. 根据内置映射表生成标准化文件名。
  4. 支持--dry-run,默认只输出将要执行的变更,不真正改文件。
  5. 生成 CSV 报告,方便回滚。
  6. 可选:把文件移动到整理目录。

为了保护原始资源,默认建议开启--dry-run跑一遍,确认无误后再去掉该参数执行。

4.2 完整脚本

#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Classic Doctor Who 老版资源批量重命名脚本 使用方式: python rename_classic_who.py --root "D:/classic-who-work/_raw" --dry-run python rename_classic_who.py --root "D:/classic-who-work/_raw" --output "D:/classic-who-work/_renamed" """ import argparse import csv import re import shutil from pathlib import Path from typing import Dict, List, Optional, Tuple # ------------------------------------------------------------ # 1. 手动维护的映射表 # 每个 key 表示 "S07E03" 这类文件前缀,映射到编剧层面的故事信息。 # 注意:database_episode_offset 不是固定值,请以 TheTVDB 实际数据为准。 # 如果某个故事在数据库中的首集集数是 10,则 offset = 10 - 1 = 9。 # ------------------------------------------------------------ STORY_MAP: Dict[str, Dict] = { "07x01": { "story_title": "Spearhead from Space", "total_parts": 4, "database_episode_offset": 0, }, "07x02": { "story_title": "Doctor Who and the Silurians", "total_parts": 7, "database_episode_offset": 4, }, "07x03": { "story_title": "The Ambassadors of Death", "total_parts": 7, "database_episode_offset": 11, }, "07x04": { "story_title": "Inferno", "total_parts": 7, "database_episode_offset": 18, }, } # 输出目录里的剧集目录名,这里直接用 1963 老版标识 SERIES_FOLDER_NAME = "Doctor Who (1963)" # ------------------------------------------------------------ # 2. 从原始文件名抽取结构化信息 # ------------------------------------------------------------ def extract_story_key(filename: str) -> Optional[Tuple[int, int]]: """ 尝试从文件名中匹配 S07E03 / 7x03 / S07.E03 等模式。 返回 (season, story_index),如果没有匹配则返回 None。 """ # 常见写法:S07E03、S07.E03、7x03、7x03 patterns = [ r"[Ss](\d{1,2})[.\-_ ]?[Ee](\d{1,3})", r"[Ss](\d{1,2})[.\-_ ]?[Ee](\d{1,3})", r"(\d{1,2})[xX](\d{1,3})", r"[Ss](\d{1,2})[.\-_ ]?[Ee](\d{1,3})" ] for pattern in patterns: match = re.search(pattern, filename) if match: season = int(match.group(1)) story_index = int(match.group(2)) return season, story_index return None def extract_part_index(filename: str) -> int: """ 尝试从文件名中抽取分集号,比如: Part.3、EP3、Episode.3、3of7、第3集 如果抽取不到,默认返回 1。 """ patterns = [ r"[Pp]art[\s._\-]?(\d{1,2})", r"[Ee][Pp][\s._\-]?(\d{1,2})", r"[Ee]pisode[\s._\-]?(\d{1,2})", r"(\d{1,2})\s?of\s?\d{1,2}", r"第\s?(\d{1,2})\s?集", ] for pattern in patterns: match = re.search(pattern, filename) if match: return int(match.group(1)) return 1 def extract_file_suffix(path: Path) -> str: """获取文件后缀,保留 .mkv .mp4 等扩展名。""" return path.suffix.lower() def parse_raw_file(path: Path) -> Optional[Dict]: """ 解析单个文件,得到可用于后续处理的字典。 返回 None 表示未识别到有效故事编号。 """ filename = path.name story_key = extract_story_key(filename) if not story_key: return None season, story_index = story_key story_key_str = f"{season:02d}x{story_index:02d}" story_info = STORY_MAP.get(story_key_str) if not story_info: return None part_index = extract_part_index(filename) return { "source_path": path, "story_key": story_key_str, "season": season, "story_index": story_index, "part_index": part_index, "story_title": story_info["story_title"], "total_parts": story_info["total_parts"], "offset": story_info["database_episode_offset"], } # ------------------------------------------------------------ # 3. 生成标准化文件名 # ------------------------------------------------------------ def build_new_name(item: Dict, preferred_style: str = "episode") -> str: """ 根据解析结果生成新的文件主名(不含后缀)。 style 参数: - "episode":生成类似 Doctor Who (1963) - S07E14 的命名 - "story":生成类似 Doctor Who (1963) - S07E03 - The Ambassadors of Death Part 3 的命名 """ season = item["season"] part_index = item["part_index"] story_title = item["story_title"] offset = item["offset"] if preferred_style == "episode": # 根据数据库偏移量计算在季中的连续集号 episode_number = offset + part_index # 保留 2 位季号与集号是为了方便通过文件资源管理器排序 return f"{SERIES_FOLDER_NAME} - S{season:02d}E{episode_number:02d} - {story_title} - Part {part_index}" # story 风格:保留 SxxExx 作为故事编号 story_index = item["story_index"] return f"{SERIES_FOLDER_NAME} - S{season:02d}E{story_index:02d} - {story_title} Part {part_index}" def build_destination_path(root: Path, item: Dict, style: str) -> Path: """ 构造目标路径。 如果 root 为 None,则文件原地重命名;否则将文件移动到 root 下。 """ new_name = build_new_name(item, style) suffix = extract_file_suffix(item["source_path"]) new_filename = new_name + suffix if root is None: return item["source_path"].with_name(new_filename) season_folder = root / SERIES_FOLDER_NAME / f"Season {item['season']:02d}" return season_folder / new_filename # ------------------------------------------------------------ # 4. 扫描目录并执行重命名/移动 # ------------------------------------------------------------ def scan_directory(root: Path) -> List[Dict]: """扫描目录中所有视频与字幕文件。""" video_extensions = {".mkv", ".mp4", ".avi", ".ts", ".m2ts", ".mov"} subtitle_extensions = {".srt", ".ass", ".ssa", ".vtt", ".sub"} items = [] for path in root.rglob("*"): if not path.is_file(): continue if path.suffix.lower() in video_extensions or path.suffix.lower() in subtitle_extensions: parsed = parse_raw_file(path) if parsed: parsed["is_video"] = path.suffix.lower() in video_extensions items.append(parsed) return items def rename_files(items: List[Dict], output_root: Optional[Path], dry_run: bool, style: str) -> str: """ 执行重命名,并返回 CSV 报告内容。 """ report_rows = [] for item in items: src = item["source_path"] dst = build_destination_path(output_root, item, style) action = "move" if output_root and dst.parent != src.parent else "rename" # 如果目标文件已存在,则添加序号避免覆盖 counter = 1 original_dst = dst while dst.exists(): dst = original_dst.with_name( original_dst.stem + f"_{counter}" + original_dst.suffix ) counter += 1 report_rows.append({ "source": str(src), "destination": str(dst), "action": action, "story_key": item["story_key"], "part_index": item["part_index"], "dry_run": str(dry_run), }) if dry_run: print(f"[DRY RUN] {action}: {src.name} -> {dst}") else: if dst.parent != src.parent: dst.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(dst)) print(f"[OK] {action}: {src.name} -> {dst}") return report_rows def write_report(report_rows: List[Dict], report_path: Path) -> None: """把报告写入 CSV,方便后续核对或恢复。""" report_path.parent.mkdir(parents=True, exist_ok=True) with open(report_path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=["source", "destination", "action", "story_key", "part_index", "dry_run"]) writer.writeheader() writer.writerows(report_rows) print(f"报告已写入: {report_path}") # ------------------------------------------------------------ # 5. 主函数入口 # ------------------------------------------------------------ def main(): parser = argparse.ArgumentParser(description="整理 Classic Doctor Who 老版资源") parser.add_argument("--root", required=True, help="原始资源所在目录") parser.add_argument("--output", required=False, help="整理后的目录,不填则原地重命名") parser.add_argument("--dry-run", action="store_true", help="只预览不修改") parser.add_argument("--style", choices=["episode", "story"], default="episode", help="输出命名风格") parser.add_argument("--report", required=False, default="rename_report.csv", help="CSV 报告路径") args = parser.parse_args() root = Path(args.root) if not root.exists(): print(f"目录不存在: {root}") return output_root = Path(args.output) if args.output else None items = scan_directory(root) if not items: print("未识别到任何符合规则的文件,请检查目录中是否有 S07E03、7x03 之类的命名。") return # 先进行 dry-run,展示完整计划 print(f"共识别到 {len(items)} 个文件,下面列出的操作均未真正执行。") report_rows = rename_files(items, output_root, dry_run=True, style=args.style) if not args.dry_run: write_report(report_rows, Path(args.report)) else: # 只是演示时保留 dry_run 报告也可以写 write_report(report_rows, Path(args.report)) print("当前为 dry-run 模式,未修改任何文件。确认无误后,请去掉 --dry-run 再执行一次。") if __name__ == "__main__": main()

4.3 代码说明

这个脚本虽然不短,但逻辑非常清晰,核心是三个部分。

第一部分是STORY_MAP。它相当于我们的“人工知识库”,管理 season、story_index、story_title、total_parts 以及数据库偏移量。老版《神秘博士》由于年代久远,不同资料站对分集编号的统计口径不同,所以我把最容易被搞错的database_episode_offset也暴露成配置项。你可以去 TheTVDB 搜索对应剧集,查看实际 Season 7 中《死亡特使》第 1 集显示的 Episode number,然后填入这里。

第二部分是解析函数。extract_story_key()通过正则尝试多种写法,包括S07E037x03S07.E03等。注意正则匹配在真实文件名中会有很多边界情况,比如文件名里同时出现好几次年份或集数,因此这个写法针对一个文件只取第一次匹配。如果发现有的文件没有正确解析,你可以先单独打印filename,再调整正则。

第三部分是重命名逻辑。build_new_name()支持两种风格:

  • episode风格:生成类似Doctor Who (1963) - S07E14 - The Ambassadors of Death - Part 3.mkv,这种命名更容易被 Emby/Jellyfin 按标准连续剧处理。
  • story风格:生成类似Doctor Who (1963) - S07E03 - The Ambassadors of Death Part 3.mkv,这种命名保真度高,适合对老剧编号有执念的收藏者。

个人建议优先使用episode风格,因为媒体服务器的刮削器更喜欢这种连续编号形式。但如果你搞不清数据库里的正确编号,宁可使用story风格,也不要张冠李戴。

脚本在运行前默认会走一次 dry-run,并把报告写到rename_report.csv。这份 CSV 很有用,一旦重命名后发现问题,可以根据报告里的sourcedestination恢复原名。恢复时可以用下面的 Python 代码:

import pandas as pd # 仅演示思路,也可以使用 csv 标准库 df = pd.read_csv("rename_report.csv") for _, row in df.iterrows(): # 实际执行时请先确认目标文件存在,且原路径没有被占用 print(row["destination"], "->", row["source"])

不过在没有任何 Python 环境的情况下,也可以直接打开 CSV 表格,把第一列当作原路径手动恢复。

4.4 运行效果演示

假设在_raw目录下存在下面的文件:

Doctor.Who.1963.S07E03.Part3.The.Ambassadors.of.Death.mkv Doctor.Who.1963.S07E03.Part3.The.Ambassadors.of.Death.srt

执行:

python rename_classic_who.py --root "D:/classic-who-work/_raw" --dry-run --report "D:/classic-who-work/_logs/rename_report.csv"

预期输出类似于:

共识别到 2 个文件,下面列出的操作均未真正执行。 [DRY RUN] rename: Doctor.Who.1963.S07E03.Part3.The.Ambassadors.of.Death.mkv -> Doctor Who (1963) - S07E14 - The Ambassadors of Death - Part 3.mkv [DRY RUN] rename: Doctor.Who.1963.S07E03.Part3.The.Ambassadors.of.Death.srt -> Doctor Who (1963) - S07E14 - The Ambassadors of Death - Part 3.srt 报告已写入: D:/classic-who-work/_logs/rename_report.csv

注意,这里示例中的 S07E14 是根据database_episode_offset=11计算出来的。如果你的数据库显示第 14 集,那才正确。如果实际上第 14 集是另一个故事的内容,说明偏移量设置错误,你需要修改后再跑。

当确认 dry-run 结果无误后,再执行真正重命名:

python rename_classic_who.py --root "D:/classic-who-work/_raw" --output "D:/classic-who-work/_renamed" --report "D:/classic-who-work/_logs/rename_report.csv"

此时脚本会把整理后的文件放到_renamed目录下,并自动创建Doctor Who (1963)/Season 07文件夹。如果不想移动,只希望原地改名,则省略--output

4.5 校验文件完整性

重命名之后,最好对文件做一次基础校验。用 FFprobe 可以快速看视频文件是否完整、时长是否合理:

ffprobe -v error -show_entries format=filename,duration,size -of default=noprint_wrappers=1 "Doctor Who (1963) - S07E14 - The Ambassadors of Death - Part 3.mkv"

输出看起来大致是:

filename=Doctor Who (1963) - S07E14 - The Ambassadors of Death - Part 3.mkv duration=1500.000000 size=1234567890

如果ffprobe报错,比如No such file or directory,大概率是文件名中的空格或特殊字符导致路径没有正确转义。在 Windows CMD 中,需要给文件路径加英文双引号。

如果duration明显过短,比如一个剧集时长只有几十秒,说明这个文件可能下载不完整,建议回原始来源补档。这一步和重命名没有直接关系,但在整理大影集时非常必要。

5. 常见问题与排查思路

5.1 Emby/Jellyfin 匹配到 2005 版《神秘博士》

这是老版剧集刮削最常见的问题。原因是文件命名中没有年份标识,而刮削器默认匹配到热度更高、封面资料更全的新版。

解决方案是:在文件名中加入(1963),例如Doctor Who (1963) - S07E14 - Part 3.mkv。如果还是匹配错误,可以在 Emby/Jellyfin 的元数据管理里手动指定 TheTVDB ID 或 TMDB ID。不同服务器操作入口不一样,但思路一致:手动“锁定”到老版条目,避免服务器再次自动识别。

5.2 S07E03 被识别成其他剧集

这个问题的根源是故事编号与连续集号不一致。如果你坚持用Doctor.Who.1963.S07E03.The.Ambassadors.of.Death.Part.3.mkv这种命名,媒体服务器很可能把S07E03当作整季播出顺序的第 3 集,于是匹配到《Spearhead from Space》的某一集。

排查时先打开该剧在 TheTVDB 的页面,查看 Season 7 的完整分集列表,找到《死亡特使》第 3 集在服务器中的 Episode number。然后据此修改映射表。这一步骤没有捷径,属于典型的“本地命名体系”与“平台编号体系”之间的数据对齐。

5.3 外挂字幕匹配不上

很多老剧资源会附带字幕文件。如果视频文件是Doctor Who (1963) - S07E14 - Part 3.mkv,字幕文件也需要改成同样的名字,比如Doctor Who (1963) - S07E14 - Part 3.srt,否则播放器无法自动加载。上面的脚本已经自动处理了字幕文件,因为在扫描时会把.srt.ass等扩展名一并纳入解析。如果你遇到了“视频有声音,但没有中文字幕”的情况,可以先检查是不是文件名不一致,再用播放器手动挂载字幕测试。

如果字幕时间轴整体偏移,可以先用 FFplay 手动播放并记录偏移时间,再使用 Subtitle Edit 或 FFmpeg 的字幕延迟参数调整。字幕处理是一个相对独立的话题,这里不展开。

5.4 重命名后做种失效

如果你从 PT(Private Tracker)下载了资源并正在做种,那么直接重命名文件、移动目录都会导致种子校验失败,影响下载贡献,情况严重时会影响账号考核。

最佳实践是:重命名操作只针对“已经完成下载、不参与做种、用于本地归档”的文件。如果还在做种,请先复制一份到临时目录,再对副本执行整理。千万不要为了美观而直接改正在做种的文件名。从工程角度讲,资源和归档最好分离,这也是第 2 节建议“一个原始目录,一个媒体库目录”的原因。

5.5 脚本解析不到部分文件

主要原因是文件的命名风格差异过大,比如名称里没有Part、没有Episode,只写了The Ambassadors of Death 3。这种情况下脚本无法自动判断3是分集序号,所以得不到正确结果。

有两种处理办法:一种是先手动把极少数不规则的视频文件重命名成统一格式,再让脚本处理其余文件;另一种是在脚本里增加自定义正则规则。考虑到人工处理几十个文件比较麻烦,个人更建议先统一文件命名风格,避免为了少数异类文件编写过于复杂的正则。

6. 最佳实践与工程建议

6.1 把“人工知识”从代码里分离

很多朋友整理影视资源时会把这些规则写在“心里”,实际上最好写进代码或者配置文件里。本文的STORY_MAP本质上就是知识配置。你可以把它单独放到story_map.json中,这样以后遇到另一部老剧,比如《神秘博士》第 8 季、第 9 季,只需要维护 JSON 配置,而不需要改动主脚本。

推荐的结构是:

configs/ doctor_who_1963_season07.json scripts/ rename_classic_who.py raw/ ... output/ ... logs/ ...

在代码里读取 JSON:

import json with open("configs/doctor_who_1963_season07.json", "r", encoding="utf-8") as f: STORY_MAP = json.load(f)

这样做的好处很明显:过几个月回头维护时,不需要翻开 Python 源码,只需在配置里修改编号和标题映射,脚本就能复用。

6.2 始终先跑 dry-run

任何批量重命名脚本,都必须支持 dry-run。不要觉得这一步多余。真实场景中,一次随手批量重命名可能影响几百个文件。误操作后恢复成本很高,尤其是文件名里如果带着中文、空格、方括号等特殊字符,不同操作系统之间的文件名兼容性问题也会暴露出来。

哪怕不写脚本,在 Windows / macOS / Linux 上手动重命名文件时,也要养成“先列清单,再执行”的习惯。这条经验不仅适用于老版《神秘博士》,也适用于所有大规模数字资源的整理。

6.3 保留原始文件名记录

建议每次批量操作都输出一份 CSV 报告。这份报告有两个用途:

  1. 出错时快速回滚。
  2. 反向追溯“这个文件原来叫什么”。

如果在媒体库刮削后依然有问题,我们通常还需要回到source路径查看原始发布的命名格式,确认是不是发布者使用了另一种编号体系。这些原始信息在重命名后会被覆盖,所以报告要保留在单独目录,比如_logs

6.4 视频文件校验与备份

对于收藏型老剧资源,文件完整性远比美观文件名重要。重命名操作一般不会破坏文件内容,但在移动大文件时,仍要注意磁盘空间是否足够。如果磁盘空间不足,shutil.move可能移动一半后失败。

简单做法是:在移动前用脚本检查目标磁盘剩余空间。标准库shutil.disk_usage()可以帮你获取磁盘信息。更稳妥的方案是先对视频文件做一次哈希校验,比如计算 SHA-256,移动之后再算一次。哈希校验计算量相对较大,可以在夜间批量任务里执行,日常整理则不必每次计算。

6.5 安全与版权提醒

老版《神秘博士》是 BBC 的版权作品。这篇文章讨论的是如何整理“你合法获取的数字化资源”,包括正版 DVD 的私人备份、已进入合理使用范围或你拥有相应授权的内容。请务必遵守所在地法律法规和平台规则,不要传播盗版资源,也不要从非正规渠道下载内容。在整理网络资源时,来自 PT 站或 BT 站的文件要注意做种规则,保护自己的账号权益。

整理过程中如果发现某个文件来源可疑,文件名带有明显盗录小组标志,建议直接删除或放弃使用。收藏影集是为了长期观看,不是给系统制造安全风险。

6.6 考虑后续自动化扩展

当你掌握了基础的“文件名清洗”思路后,可以进一步扩展:

  • 接入 TheTVDB API,自动获取故事标题、分集编号、首播日期,减少人工维护成本。
  • 接入 TMDB API,用于下载海报、背景图和演职员信息。
  • 在重命名完成后调用 Jellyfin API 触发媒体库刷新。
  • 把脚本放到 NAS 上,配合定时任务定期检查下载目录。

本文的脚本刻意没有引入这些外部接口,是为了保证作为教程的“最小可运行”特性。当你真正理解了映射关系,再封装成自动化任务就不难了。

7. 总结与进一步整理方向

老版《神秘博士》的资源整理,表面上是“改文件名”,实际上是在做两件事:一是把不同发布者的命名风格统一成自己的归档规范;二是把资源文件和影视数据库的编号建立起可信的映射关系。理解这一点后,再去处理其他经典剧集,比如老《星际迷航》、经典科幻剧、动画长剧,思路都是通用的。

本文给出了一个可直接运行的 Python 重命名脚本,支持STORY_MAP配置、dry-run 预览、CSV 日志和字幕同步处理,并演示了如何使用 FFprobe 校验文件完整性。哪怕你完全没写过 Python,只要按照前两节把环境准备好,也可以把脚本复制下来,把root改为自己的目录,跑一次看看效果。

如果你正准备搭建自己的经典剧集媒体库,建议下一步先做三件事:

  1. 把散乱的原始资源,先按“原始目录 / 整理目录 / 日志目录”分层。
  2. 去 TheTVDB 或 TMDB 核对一遍目标季的完整分集列表,把结果填进STORY_MAP
  3. 使用 dry-run 模式在副本上测试整批重命名,确认无误后再对正式文件执行。

老剧编号的混乱是历史遗留问题,很难靠某一个播放器自动解决。能真正解决问题的,始终是自己维护的那张映射表。如果这篇文章对你整理《神秘博士》或其他老剧有启发,可以收藏备用,后续遇到命名混乱时再对照操作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询