b3608f534e
- 新增 analysis/ 模块:MOG2活动检测、YOLO人物跟踪、MediaPipe摔倒检测、Ollama活动打标、缩略图抽取 - 新增 webapp/:Flask事件浏览界面,按需HEVC→H264转码缓存 - 新增 analyze.py:7阶段状态机流水线(scan→backfill→motion→person→fall→activity→thumbnail) - 新增 cleanup_orphans.py:清理文件已删除的数据库孤儿记录 - 修复:motion/person/activity三阶段加单视频异常兜底,避免批次中断 - 修复:ollama_client加健康检查和3次重试,num_ctx从8192降到2048 - 修复:transcode_cache加fcntl文件锁,防止并发转码同一视频 - 修复:SQLite开WAL模式+busy_timeout,支持读写并发 - 修复:sync.py ffmpeg路径改为shutil.which查找,删除automate.py死代码PYTHON - 修复:thumbnail失败加3次重试上限,避免无限重试损坏文件 - 新增:requirements.lock锁定版本、README文档 - 更新:.gitignore排除.playwright-mcp、截图、备份文件
159 lines
4.9 KiB
Python
159 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
[已废弃,保留作为备选] 萤石CB60 SD卡录像每日同步脚本(开放平台 API 方案)
|
||
|
||
当前主力下载方案是 automate.py(ADB 自动化模拟器内萤石 App 批量下载)。
|
||
本脚本走萤石开放平台 API(open.ys7.com),需要先在开放平台关闭设备码流加密,
|
||
且 FLV 回放流拼接的录像不含精确时间戳,因此被 ADB 方案取代。保留仅作备选参考。
|
||
"""
|
||
import json, os, re, shutil, 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")
|
||
|
||
# ffmpeg 从 PATH 查找;NAS/Docker 部署时确保 ffmpeg 在 PATH 里即可,无需改代码
|
||
FFMPEG = shutil.which("ffmpeg") or "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=4:HTTP FLV,ffmpeg 可直接下载
|
||
"""
|
||
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()
|