Agent Movie 是一个智能、全自动的本地媒体库字幕管理系统。专为追求高质量观影体验的用户打造,通过引入 AI(LLM 与 Whisper)解决传统媒体库(如 Emby/Plex)在处理中文字幕时的痛点。系统强调“极简”、“不瞎猜”、“出错即提示”的严格运行逻辑。
在正式扫描媒体库之前,系统提供一键验证基础设施状态:
- OpenSubtitles 账户联通:验证账号密码有效性,确保后续下载字幕顺畅。
- Translate API 连通测试:检查配置的大模型(如 Gemini / Mistral / Groq 等)API Token 是否正常工作,保障高质量字幕翻译服务的可用性。
系统首先扫描媒体库目录,识别影片并解析元数据。
- 视频为王:不再依赖容易缺失的 NFO 文件。系统会直接地毯式扫描整个媒体库,将所有大于 100MB 的有效视频文件(如 mkv/mp4 等)统统认定为一部独立影片,彻底解决同一目录下存在多版本电影(如导演剪辑版和剧场版共存)导致被忽略的问题。
- 元数据双轨绑定:找到视频后,尝试读取周边的
.nfo文件提取精准的标题、年份、IMDB ID 或 TMDB ID;即使缺少其中一个,系统也会自适应降级使用另一个进行精确匹配。如果实在没有 NFO,则会使用视频文件名作为默认片名,保证任务顺利推进。 - 状态持久化:分析结果独立保存在该视频同目录下的
[视频名].json中,互不干扰。
在处理字幕前,系统先评估现有字幕的状态。
- 语言检测:读取字幕文件内容,智能判断语言(简体/繁体/英文)
- 编码检测:检测字幕文件编码格式
- 垃圾识别:识别体积过小的广告字幕、冗余配置文件等
- 脏数据标记:标记需要清洗的字幕文件
处理完评估后,自动清理和规范化字幕文件。
- 垃圾清理:一键清除体积过小的广告字幕文件、老旧的冗余配置文件等
- 按内容重命名:无视文件原有的后缀瞎标,程序直接读取字幕文件内容。只要内容里含有简体中文,强制重命名为
[电影原名].zh-CN.[格式];含有繁体则重命名为.zh-TW - 编码统一:强制转换所有字幕文件为 UTF-8 编码
在处理字幕前,系统必须先准确摸清库里所有电影的真实发音与内置字幕情况。
- 音轨识别:调用 FFmpeg 从视频中提取多个 15 秒片段(备选采样点
[25%, 50%, 75%, 15%, 85%, 35%, 65%]),并通过大模型 API(如 Groq 的 Whisper 模型)识别音轨语言。 - 内置字幕检测(全面兼容文本与图像字幕):从视频流中提取内置字幕轨道。对于文本格式字幕(如 SRT/ASS/VTT),抽样提取文本并统计 CJK 字符数量进行判断;对于无法直接提取文本的图像格式字幕(如蓝光原盘 PGS 或 DVD VobSub),则智能读取其轨道元数据的
chi或zho语言标签进行判定。纯本地处理,无需外部 API。 - 宁缺毫滥的严格模式:一旦识别到 3 个有效语音片段即见好就收。如果所有片段均为纯音乐或无声导致无法识别,系统将抛出异常并中止任务,强制要求人工介入。
- 监控大屏与管理页面:提供独立的 Web 监控中心,可实时查看大盘进度和处理日志。并且内置专属的电影管理页,支持按年份、片名、是否含内置中文字幕、是否为中文发音等多个维度进行精准排序与筛选。
- 持久化档案:分析结果保存在影片目录的
[视频名].json中。
只针对确认需要中文字幕的外语片执行操作:
- OpenSubtitles 极速双轨匹配:底层完全兼容 IMDB ID 与 TMDB ID。无论 NFO 中提供哪种 ID,都能精准直达对应字幕,从根本上解决部分老电影或亚洲电影因缺失 IMDB 数据而被无情跳过的痛点。
- AI 同步检测:使用 Whisper 提取视频音频并识别首尾台词的时间轴,与下载的外部字幕文件进行比对。判断外部字幕时间轴是否超前或滞后,并自动计算偏移量(Offset)来校准时间轴
- AI 错版甄别:下载字幕后,AI 会抽样对比“视频原音转写内容”和“外部字幕文本内容”的语义匹配度。杜绝“挂羊头卖狗肉”现象(如下载的是导演剪辑版字幕,但视频是剧场版导致后续完全错位,或者纯粹下载到了另一部电影的同名字幕)。如发现严重错版,自动废弃该字幕并重新拉取其他版本
- 大模型长文本翻译:对于只下到英文字幕的影片,调用配置的 LLM (Gemini / Groq 等) 对外挂字幕进行高精度上下文连贯翻译
- Ansible 一键部署:使用
deploy.sh配合 Ansible playbook 自动化环境搭建(Python venv 构建、依赖安装、文件分发) - 后台常驻服务:Flask Web 提供交互入口,后台静默执行任务并通过心跳机制防止僵尸进程。所有配置均在
settings.toml中统一维护 - 状态机驱动:使用
[视频名].json作为唯一状态源,每个 Stage 有明确的 done/error 字段,支持渐进式处理和错误追踪 - 模块化设计:职责解耦,每个模块单一职责,易于维护和扩展
-
电视剧集支持:
- 增加对电视剧集 (TV Shows / Episodes) 的批量智能支持
-
更多翻译器支持:
- 支持更多 LLM 提供商和翻译模型
在对接 OpenSubtitles API (opensubtitlescom) 期间,踩过以下严重深坑,特此记录以防后人再次踩雷:
-
极其严苛的 429 速率限制 (Too Many Requests)
- 坑点:每次调用
OpenSubtitles.login()算一次请求。如果在循环遍历多部电影时,每次都实例化客户端去登录,会瞬间触发1 req/sec per IP的限流封禁。 - 解法:必须使用全局单例 (Singleton) 缓存已登录的客户端对象;并且在
login、search、download动作之前强制time.sleep(1.5),外层包裹指数退避的@with_retry机制。
- 坑点:每次调用
-
自相矛盾的语种传参 (
Invalid language code)- 坑点:OpenSubtitles 的 REST API 底层其实支持
zh-cn,zh-tw的逗号拼接查询,但其官方 Python 封装库opensubtitlescom里却写死了断言,直接把包含逗号的字符串判定为非法并抛出异常。 - 解法:调用
search()前,手动通过正则re.split(r'[,|;]+', lang_code)将多语言拆散成独立的列表,然后在代码里写 for 循环挨个发起纯粹单一语种的搜索请求。
- 坑点:OpenSubtitles 的 REST API 底层其实支持
-
迷惑的返回值类型 (
Subtitle object is not subscriptable)- 坑点:当调用
ost.download_and_parse()时,库并不返回字典或纯文本,而是返回自己定义的opensubtitlescom.srt.Subtitle对象的列表。如果后续尝试用['index']下标读取或直接write_text写入,会导致整个程序崩溃。 - 解法:坚决弃用
download_and_parse(),改为调用最底层的ost.download()。它会原封不动地返回原始.srt文件的字节流 (bytes),直接write_bytes()落盘即可,完美避开乱码和格式解析问题。
- 坑点:当调用
Agent_Movie 采用基于状态机的5阶段架构,每个阶段有明确的输入、处理和输出:
- 状态持久化:每个影片目录维护一个
[视频名].json文件,记录该影片在各个阶段的状态 - 渐进式处理:按业务流程顺序执行,前序Stage阻塞后续Stage
- 错误明确:所有错误记录在 error 字段,前端直接显示给用户
- 幂等性:各Stage可重复执行,结果一致,已完成的Stage会自动跳过
- 本地化:无数据库依赖,基于文件系统,简单可靠
与 Emby / Plex 等需要庞大 SQLite 数据库维护状态的传统媒体中心不同,Agent_Movie 的核心设计理念是**“纯 I/O 实时驱动”**。
app.py 扮演着一个经典的死循环阻塞型 Web Server(Blocking Server)角色:
- 服务常驻:执行
./run.sh时,Python 线程被app.run()永久挂起,持续监听前端页面的指令 - 每次刷新即“全盘重扫”:
- 因为没有数据库“记仇”,你在浏览器按下
F5刷新的瞬间,系统会直接拉起后台的扫描脚本(scanner.py) - 脚本顺着配置的媒体库路径,实时且暴力地去扫物理硬盘上每一个大于 100MB 的视频文件,并嗅探它周边的字幕情况
- 因为没有数据库“记仇”,你在浏览器按下
- 所见即所得:无论你是通过本程序下载了字幕,还是手动往文件夹里拖入了一个新字幕,无需点击“同步媒体库”,只要刷新网页,页面就会瞬间根据硬盘的真实物理变动来剔除或更新列表。永不同步错误!
每个影片目录下的状态文件采用与视频文件同名的命名方式,便于识别和管理:
- 视频文件:
1917 (2019) 1080p AAC.mp4 - 状态文件:
1917 (2019) 1080p AAC.json
命名规则: 状态文件名 = 视频文件名(不含扩展名) + .json
{
"version": 2,
"last_updated": "2026-07-09T14:00:00Z",
"movie": {
"title": "电影名称",
"year": "1998",
"imdb_id": "tt0120915",
"version": "Director's Cut"
},
"video_file": {
"path": "/path/to/video.mkv",
"duration_seconds": 7200
},
"subtitles_assessment": {
"done": true,
"has_chinese": false,
"has_english": true,
"has_garbage": true,
"error": null
},
"subtitles_cleanup": {
"done": true,
"files_deleted": 2,
"files_renamed": 1,
"error": null
},
"audio_tracks": {
"done": true,
"primary_language": "en",
"is_chinese_audio": false,
"tracks": [
{"index": 0, "lang": "en"},
{"index": 1, "lang": "en"}
],
"error": null
},
"subtitle_tracks": {
"done": true,
"has_internal_chinese_sub": false,
"streams": [
{"index": 4, "codec_name": "subrip"}
],
"error": null
},
"subtitle_completion": {
"done": true,
"method": "translated",
"translator": "groq",
"chinese_subtitle": "Taxi (1998) Director's Cut.zh-CN.srt",
"sync_offset": 0.5,
"mismatch_detected": false,
"error": null
}
}- done: 该Stage是否已完成
- error: 该Stage的错误信息(null表示无错误)
- movie.version: 电影版本字段,区分同一电影的不同版本
- subtitle_tracks.has_internal_chinese_sub: 是否有内置中文字幕(由 Stage 4 内置字幕检测填充,null表示未检测,false表示无,true表示有)
- subtitle_completion.sync_offset: 同步检测计算的时间偏移量
- subtitle_completion.mismatch_detected: 错版甄别结果