云端部署

Emby 家庭视频整理:按拍摄时间批量归档并同步媒体库

用 Python、ffprobe 和 Emby API 按拍摄时间整理家庭视频,保留原文件,并为时间来源不可靠的片段提示复核。

BINBHO · 图文教程

手机里导出来的家庭视频,文件名五花八门:VID_20260503_101500.mp4、IMG_8841.MOV、从聊天软件里保存的 clip_from_chat.mp4。这个脚本读出每段视频的拍摄时间,按 年/年月/日期 时间 原文件名 整理好(只复制,不动原文件),再让 Emby 的“家庭视频”库自动创建并重新扫描。

先看清成品

  • 测试: pytest 1 项通过,覆盖 UTC 转本地时区、按文件名兜底两种情况。
  • 素材: 4 段 ffmpeg 生成的测试片,带不同的 creation_time 元数据。
  • Emby 实测: 在 Emby 4.10.2.0 上整理并创建了“家庭视频”库(collectionType=homevideos),4 个条目都以正确的名称入库。
  • 过程中发现 Emby 把某个视频显示成了 “05”,在全新实例上复现后修复(见调试示例)。

注意:媒体库里只放你有权使用的内容,比如自己拍的家庭视频、自己购买的影片的合法备份、开放授权的作品。不要安装盗版插件,不要分享资源。硬件加速转码属于 Emby Premiere 的功能(官方 Premiere 页面写明“Hardware Accelerated Transcoding”),请按官方方式订阅,价格以官网为准。

隐私提醒:家庭视频是敏感数据。这个库不要开放给外部用户,远程访问参考 39-emby-C 和 38-jellyfin-C 的方式做好 HTTPS 和访问控制。

做完之后你会得到什么?

成果 说明
organize_home_videos.py 读取拍摄时间:元数据 → 文件名 → 修改时间,并标注时间来源;默认只打印计划,--apply 才复制,--emby-refresh 创建库并扫描
test_organize.py 时区转换和文件名兜底的测试

需要准备哪些环境?

项目 要求 说明
ffprobe 随 ffmpeg 一起安装 用来读取 creation_time
Python 3.9 以上(用到 zoneinfo) 调用 Emby 时需要 requests
Emby A 篇创建的 API 密钥 填到 EMBY_API_KEY

文件结构

39-emby-B-src/
├── organize_home_videos.py
└── test_organize.py

第 1 步:脚本

organize_home_videos.py:

"""把手机导出的家庭视频按拍摄日期整理成 年/年月 文件夹,再让 Emby 的“家庭视频”库重新扫描。
拍摄时间的来源,按优先级:视频元数据 creation_time(UTC,转换成本地时区)→ 文件名里的 YYYYMMDD_HHMMSS → 文件修改时间(会标注“不可靠”)
默认只打印计划;加 --apply 才会复制(原文件保留不动)。
用法:python organize_home_videos.py 手机导出目录 家庭视频目录 [--tz Asia/Shanghai] [--apply]
      [--emby-refresh](需要 EMBY_URL、EMBY_API_KEY;第一次运行会自动创建“家庭视频”库,库路径通过 EMBY_LIB_PATH 指定)"""
import json, os, re, shutil, subprocess, sys
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo

EXT = {".mp4", ".mov", ".m4v", ".3gp", ".mkv"}
NAME_TS = re.compile(r"(20\d{2})(\d{2})(\d{2})[_-]?(\d{2})(\d{2})(\d{2})")

def shot_time(p, tz):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format_tags=creation_time", "-of", "json", str(p)],
                         capture_output=True, text=True).stdout
    ct = json.loads(out or "{}").get("format", {}).get("tags", {}).get("creation_time")
    m = NAME_TS.search(p.name)
    if ct:
        t = datetime.fromisoformat(ct.replace("Z", "+00:00")).astimezone(tz)
        if m:                                       # 两个来源都有时,互相核对一下
            diff = abs((datetime(*map(int, m.groups()), tzinfo=tz) - t).total_seconds()) / 3600
            if diff >= 0.02: return t, f"元数据;⚠ 和文件名相差 {diff:.1f} 小时,检查相机或手机的时区设置"
        return t, "元数据"
    if m: return datetime(*map(int, m.groups()), tzinfo=tz), "文件名"
    return datetime.fromtimestamp(p.stat().st_mtime, tz), "修改时间(不可靠)"

def plan(src, dst, tz):
    for p in sorted(Path(src).iterdir()):
        if p.suffix.lower() not in EXT: continue
        t, how = shot_time(p, tz)
        # 月份文件夹用“2026年05月”:用“2026-05”时,Emby 会把同一文件夹里的某个视频显示成“05”(见调试示例)
        yield p, Path(dst) / f"{t:%Y}" / f"{t:%Y}年{t:%m}月" / f"{t:%Y-%m-%d %H.%M.%S} {p.stem}{p.suffix.lower()}", how

def emby_refresh(lib_path):
    import requests
    base, h = os.environ["EMBY_URL"].rstrip("/") + "/emby", {"X-Emby-Token": os.environ["EMBY_API_KEY"]}
    libs = requests.get(base + "/Library/VirtualFolders", headers=h, timeout=15).json()
    if not any(l["Name"] == "家庭视频" for l in libs):
        requests.post(base + "/Library/VirtualFolders", headers=h, timeout=15,
                      params={"name": "家庭视频", "collectionType": "homevideos", "refreshLibrary": "true"},
                      json={"LibraryOptions": {"PathInfos": [{"Path": lib_path}]}}).raise_for_status()
        print("✓ 已创建 Emby 库:家庭视频")
    requests.post(base + "/Library/Refresh", headers=h, timeout=15).raise_for_status(); print("✓ 已请求 Emby 重新扫描")

if __name__ == "__main__":
    a = sys.argv[1:]; tz = ZoneInfo(a[a.index("--tz") + 1]) if "--tz" in a else ZoneInfo("Asia/Shanghai")
    for src, dst, how in plan(a[0], a[1], tz):
        print(f"{src.name}  →  {dst.relative_to(a[1])}   [{how}]")
        if "--apply" in a:
            if dst.exists(): print("   已存在,跳过"); continue
            dst.parent.mkdir(parents=True, exist_ok=True); shutil.copy2(src, dst)
    if "--emby-refresh" in a: emby_refresh(os.environ.get("EMBY_LIB_PATH", "/mnt/media/Home Videos"))

第 2 步:先看计划,再执行

$ python organize_home_videos.py ~/phone-export "/srv/media/Home Videos"
IMG_8841.MOV  →  2025/2025年12月/2025-12-25 03.05.00 IMG_8841.mov   [元数据]
VID_20260503_101500.mp4  →  2026/2026年05月/2026-05-03 18.15.00 VID_20260503_101500.mp4   [元数据;⚠ 和文件名相差 8.0 小时,检查相机或手机的时区设置]
VID_20260503_103000.mp4  →  2026/2026年05月/2026-05-03 18.30.00 VID_20260503_103000.mp4   [元数据;⚠ 和文件名相差 8.0 小时,检查相机或手机的时区设置]
clip_from_chat.mp4  →  2026/2026年07月/2026-07-01 08.00.00 clip_from_chat.mp4   [修改时间(不可靠)]
$ EMBY_URL=http://127.0.0.1:8096 EMBY_API_KEY=… EMBY_LIB_PATH="/mnt/media/Home Videos" python organize_home_videos.py ~/phone-export "/srv/media/Home Videos" --apply --emby-refresh
✓ 已创建 Emby 库:家庭视频
✓ 已请求 Emby 重新扫描
  • 元数据是 UTC 时间: IMG_8841 的元数据是 2025-12-24 19:05(UTC),换成上海时间是 12 月 25 日凌晨 3 点 05 分,归到了 12 月 25 日。
  • 8 小时的提示: 这个 ⚠ 是我自己造的测试数据导致的:文件名里写的是本地时间,元数据里我也写成了同一个钟点,却标成了 UTC。真实手机上如果出现这种提示,通常说明设备的时区设置有问题。

test_organize.py:

import subprocess
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from organize_home_videos import plan

def clip(p, ct=None):
    md = ["-metadata", f"creation_time={ct}"] if ct else []
    subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc=size=160x120:duration=1", "-c:v", "libx264", "-preset", "ultrafast", *md, str(p)], check=True)

def test_timezone_and_fallback(tmp_path):
    s = tmp_path / "s"; s.mkdir()
    clip(s / "IMG_1.MOV", "2025-12-24T19:05:00Z")            # UTC 晚上 7 点 = 上海 12 月 25 日凌晨 3 点
    clip(s / "VID_20260503_101500.mp4")                       # 没有元数据,靠文件名
    got = {p.name: (str(d.relative_to(tmp_path / "d")), how) for p, d, how in plan(s, tmp_path / "d", ZoneInfo("Asia/Shanghai"))}
    assert got["IMG_1.MOV"] == ("2025/2025年12月/2025-12-25 03.05.00 IMG_1.mov", "元数据")
    assert got["VID_20260503_101500.mp4"] == ("2026/2026年05月/2026-05-03 10.15.00 VID_20260503_101500.mp4", "文件名")

检查点:Emby 里“家庭视频”库的条目名称就是整理后的文件名。用 /Items?IncludeItemTypes=Video 查到 4 个条目,名称都正确。

调试示例:有一个视频在 Emby 里叫 “05”

第一版用 年/年-月/ 的文件夹结构,也就是 2026/2026-05/。扫描后,这个文件夹里的第一个视频在 Emby 里显示的名字是 “05”:

'05' | 2026/2026-05/2026-05-03 18.15.00 VID_20260503_101500.mp4
'2026-05-03 18.30.00 VID_20260503_103000' | 2026/2026-05/2026-05-03 18.30.00 VID_20260503_103000.mp4

我把时间格式从 181500 换成 18.15.00,结果一样。在全新的 Emby 实例上,两种时间格式也都能复现,说明不是旧数据缓存导致的。把月份文件夹改成 2026年05月 以后,同样两段视频的名称就正常了(在全新实例上验证过)。具体是 Emby 的哪条文件名解析规则造成的,我没有查到官方说明,所以只记录实测结果:在家庭视频库里,月份文件夹不要用 YYYY-MM 这种写法。

常见问题

现象 原因 处理
时间差了 8 小时 元数据是 UTC,没有转换时区 用 --tz 指定你所在的时区
标注“修改时间(不可靠)” 聊天软件转存时去掉了元数据 手动确认日期后改名
条目名称很奇怪 文件夹名和文件名的前缀一样 月份文件夹用 YYYY年MM月
重复运行会不会重复复制 目标文件已经存在时会跳过 安全,可以重复运行

模板规则

  • 只复制,不移动,原文件一律保留。
  • 每一个推断出来的时间都标明来源;来源不可靠的要明确提示。
  • 家庭视频库只对家人开放。

项目资源

用途 地址
API 密钥认证(官方) https://dev.emby.media/doc/restapi/API-Key-Authentication.html
ffprobe 文档 https://ffmpeg.org/ffprobe.html
Emby Premiere 功能列表 https://emby.media/premiere.html

整理家庭视频的关键是“时间从哪里来”:脚本把每个时间的来源都写清楚,你只需要核对标了 ⚠ 的那几条。