Files
yingshiyun/sync.py
T
yuming 45d17ee576 feat: 萤石 CB60 录像每日同步初始版本
通过 ADB 自动化模拟器中的萤石 App,每天把 SD 卡里昨天的录像批量
下载到本地。云 API 方案 (sync.py/probe.py) 是备选,需先在萤石开放
平台关闭设备码流加密。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 10:41:38 +08:00

156 lines
4.6 KiB
Python
Raw 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
"""
萤石CB60 SD卡录像每日同步脚本
每天凌晨运行,下载前24小时内的所有SD卡录像到本地目录。
"""
import json, os, re, subprocess, logging
from datetime import datetime
from pathlib import Path
import requests
from config import *
# ====== 路径配置 ======
OUTPUT_DIR = Path(OUTPUT_DIR)
TOKEN_CACHE = Path(TOKEN_CACHE)
LOG_FILE = Path("./sync.log")
# NAS上部署时改为 "ffmpeg"Docker镜像内)
FFMPEG = "/opt/homebrew/bin/ffmpeg"
BASE = "https://open.ys7.com/api/lapp"
# ====== 日志 ======
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(message)s",
handlers=[
logging.FileHandler(LOG_FILE, encoding="utf-8"),
logging.StreamHandler()
]
)
log = logging.getLogger(__name__)
# ====== Token 管理 ======
def get_token():
if TOKEN_CACHE.exists():
cache = json.loads(TOKEN_CACHE.read_text())
if datetime.now().timestamp() < cache["expire_ts"] - 3600:
log.info(f"使用缓存 Token,有效期至 {cache['expire_str']}")
return cache["token"]
r = requests.post(f"{BASE}/token/get",
data={"appKey": APP_KEY, "appSecret": APP_SECRET})
d = r.json()
if d["code"] != "200":
raise Exception(f"Token 获取失败: {d}")
token = d["data"]["accessToken"]
expire_ts = d["data"]["expireTime"] / 1000
expire_str = datetime.fromtimestamp(expire_ts).strftime("%Y-%m-%d %H:%M:%S")
TOKEN_CACHE.write_text(json.dumps({
"token": token, "expire_ts": expire_ts, "expire_str": expire_str
}))
log.info(f"Token 已刷新,有效期至 {expire_str}")
return token
# ====== 查询录像列表 ======
def get_recording_list(token, start_ms, end_ms):
r = requests.post(f"{BASE}/video/by/time", data={
"accessToken": token,
"deviceSerial": DEVICE_SERIAL,
"channelNo": CHANNEL_NO,
"startTime": start_ms,
"endTime": end_ms,
})
d = r.json()
if d["code"] != "200":
log.warning(f"录像列表查询失败: {d}")
return []
return d.get("data", [])
# ====== 获取 FLV 回放流地址 ======
def get_flv_url(token):
"""
type=2:SD卡回放流,覆盖过去24小时内所有录像片段(服务器拼接成一个 FLV)
protocol=4HTTP FLVffmpeg 可直接下载
"""
r = requests.post(f"{BASE}/v2/live/address/get", data={
"accessToken": token,
"deviceSerial": DEVICE_SERIAL,
"channelNo": CHANNEL_NO,
"quality": 1,
"type": 2,
"protocol": 4,
})
d = r.json()
if d["code"] != "200":
raise Exception(f"FLV 地址获取失败: {d}")
return d["data"]["url"], d["data"]["expireTime"]
# ====== ffmpeg 下载 ======
def download_flv(url, output_path):
output_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
FFMPEG,
"-i", url,
"-c", "copy",
"-y", str(output_path),
"-v", "warning",
]
log.info(f"开始下载 → {output_path}")
result = subprocess.run(cmd, capture_output=True, text=True)
if not output_path.exists() or output_path.stat().st_size < 1024:
log.error(f"下载失败或文件过小\n{result.stderr[:400]}")
return False
size_kb = output_path.stat().st_size / 1024
log.info(f"下载完成,大小 {size_kb:.0f} KB")
return True
# ====== 主流程 ======
def sync():
log.info("===== 开始同步 =====")
token = get_token()
# 1. 查过去24小时是否有录像
now_ms = int(datetime.now().timestamp() * 1000)
start_ms = now_ms - 86_400_000
recordings = get_recording_list(token, start_ms, now_ms)
if not recordings:
log.info("过去24小时无SD卡录像,退出")
return
log.info(f"发现 {len(recordings)} 段录像")
# 2. 获取 FLV 流(服务器把24小时内所有录像拼接好)
url, expire_time = get_flv_url(token)
log.info(f"FLV URL 有效期至 {expire_time}")
# 3. 文件命名:按 begin 时间戳
begin_match = re.search(r'begin=(\d{14})', url)
begin_str = begin_match.group(1) if begin_match else datetime.now().strftime("%Y%m%d%H%M%S")
date_str = begin_str[:8] # e.g. 20260613
output_path = OUTPUT_DIR / DEVICE_SERIAL / date_str / f"{begin_str}.mp4"
# 4. 已下载则跳过
if output_path.exists() and output_path.stat().st_size > 1024:
log.info(f"已存在 {output_path.name},跳过")
return
# 5. 下载
download_flv(url, output_path)
log.info("===== 同步完成 =====")
if __name__ == "__main__":
sync()