177 lines
5.7 KiB
Python
177 lines
5.7 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from pathlib import Path
|
||
from typing import Dict, Set
|
||
|
||
from loguru import logger
|
||
|
||
from app.db import (
|
||
add_watch_processed,
|
||
health_snapshot_deliverable,
|
||
load_watch_processed,
|
||
save_health_snapshot,
|
||
)
|
||
from app.services import action as action_svc
|
||
from app.settings import Settings
|
||
from app.state import app_state
|
||
from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch
|
||
|
||
_ACTION_IDLE_WARN_INTERVAL_SEC = idle_warn_interval_sec(
|
||
"FISH_ACTION_WATCH_IDLE_WARN_INTERVAL_SEC"
|
||
)
|
||
|
||
|
||
def _state_path(settings: Settings) -> Path:
|
||
if settings.action_watch_state_file is not None:
|
||
return settings.action_watch_state_file
|
||
assert settings.action_watch_dir is not None
|
||
return settings.action_watch_dir / ".fishaction_watch_processed.json"
|
||
|
||
|
||
def iter_mp4(watch_dir: Path, recursive: bool) -> list[Path]:
|
||
if recursive:
|
||
return sorted(
|
||
p
|
||
for p in watch_dir.rglob("*")
|
||
if p.is_file() and p.suffix.lower() == ".mp4"
|
||
)
|
||
return sorted(
|
||
p
|
||
for p in watch_dir.iterdir()
|
||
if p.is_file() and p.suffix.lower() == ".mp4"
|
||
)
|
||
|
||
|
||
async def _run_inference_and_state(
|
||
mp4: Path,
|
||
settings: Settings,
|
||
processed: Set[str],
|
||
state_file: Path,
|
||
) -> None:
|
||
key = str(mp4.resolve())
|
||
if key in processed:
|
||
return
|
||
logger.info("[action-watch] inference: {}", mp4)
|
||
async with app_state.action_lock:
|
||
app_state.action_status = "running"
|
||
try:
|
||
# 返回 (第一个快照, 所有切片快照列表)
|
||
first_snap, all_snaps = await asyncio.to_thread(
|
||
action_svc.run_full_action, mp4, settings
|
||
)
|
||
|
||
# 将所有切片作为独立视频保存到数据库
|
||
saved_count = 0
|
||
for i, snap in enumerate(all_snaps):
|
||
if health_snapshot_deliverable(snap):
|
||
# 为每个切片生成独立的 source_path
|
||
slice_key = f"{key}#slice{i}"
|
||
save_health_snapshot(settings, snap, source_path=slice_key)
|
||
saved_count += 1
|
||
|
||
if saved_count == 0:
|
||
logger.warning(
|
||
"[action-watch] no deliverable health snapshot for {}, skip SQLite",
|
||
mp4.name,
|
||
)
|
||
|
||
app_state.action_status = "idle"
|
||
processed.add(key)
|
||
if settings.action_watch_use_state_file:
|
||
add_watch_processed(settings, key, "action")
|
||
|
||
pred = (first_snap.raw_class_en or "").strip()
|
||
logger.info(
|
||
"[action-watch] done: {} -> {} (saved {} slices)",
|
||
mp4.name,
|
||
pred,
|
||
saved_count,
|
||
)
|
||
except Exception as e:
|
||
logger.exception("[action-watch] error on {}: {}", mp4, e)
|
||
app_state.action_status = "idle"
|
||
processed.add(key)
|
||
if settings.action_watch_use_state_file:
|
||
add_watch_processed(settings, key, "action")
|
||
|
||
|
||
async def watch_tick(
|
||
settings: Settings,
|
||
processed: Set[str],
|
||
stability: Dict[str, tuple[int, int]],
|
||
state_file: Path,
|
||
) -> bool:
|
||
"""处理一轮目录扫描;若处理了至少一个文件返回 True。"""
|
||
assert settings.action_watch_dir is not None
|
||
watch_dir = settings.action_watch_dir
|
||
did = False
|
||
seen_keys: Set[str] = set()
|
||
for mp4 in iter_mp4(watch_dir, settings.action_watch_recursive):
|
||
key = str(mp4.resolve())
|
||
seen_keys.add(key)
|
||
if key in processed:
|
||
continue
|
||
try:
|
||
st = mp4.stat()
|
||
except OSError:
|
||
continue
|
||
size = int(st.st_size)
|
||
if size <= 0:
|
||
stability.pop(key, None)
|
||
continue
|
||
last = stability.get(key)
|
||
if last is None or last[0] != size:
|
||
stability[key] = (size, 1)
|
||
else:
|
||
_, cnt = last
|
||
stability[key] = (size, cnt + 1)
|
||
_, cnt = stability[key]
|
||
if cnt >= settings.action_watch_stable_polls:
|
||
await _run_inference_and_state(mp4, settings, processed, state_file)
|
||
stability.pop(key, None)
|
||
did = True
|
||
for k in list(stability.keys()):
|
||
if k not in seen_keys:
|
||
del stability[k]
|
||
return did
|
||
|
||
|
||
async def run_action_watch_loop(settings: Settings) -> None:
|
||
assert settings.action_watch_dir is not None
|
||
wd = settings.action_watch_dir
|
||
if not wd.is_dir():
|
||
logger.warning("[action-watch] skip: not a directory: {}", wd)
|
||
return
|
||
|
||
state_file = _state_path(settings)
|
||
processed: Set[str] = (
|
||
load_watch_processed(settings, state_file, "action")
|
||
if settings.action_watch_use_state_file
|
||
else set()
|
||
)
|
||
stability: Dict[str, tuple[int, int]] = {}
|
||
|
||
logger.info(
|
||
"[action-watch] watching {} (poll={}s, stable_polls={}, state={} {})",
|
||
wd,
|
||
settings.action_watch_poll_interval,
|
||
settings.action_watch_stable_polls,
|
||
"on" if settings.action_watch_use_state_file else "off",
|
||
state_file if settings.action_watch_use_state_file else "",
|
||
)
|
||
|
||
idle_warn_state = IdleWatchWarnState()
|
||
while True:
|
||
did = await watch_tick(settings, processed, stability, state_file)
|
||
maybe_warn_idle_watch(
|
||
did_work=did,
|
||
log_tag="action-watch",
|
||
algo_name="FishAction",
|
||
idle_hint="目录内无 .mp4、已全部处理完毕,或文件尚未达到稳定轮询次数",
|
||
watch_dir=wd,
|
||
state=idle_warn_state,
|
||
interval_sec=_ACTION_IDLE_WARN_INTERVAL_SEC,
|
||
)
|
||
await asyncio.sleep(max(settings.action_watch_poll_interval, 0.1))
|