Add ACTION_WATCH_* settings, background watch loop in FastAPI lifespan, and shared action_watch service that updates app_state. Add fish-action-watch CLI and optional FishAction watch_folder_predict.py for standalone use. Made-with: Cursor
160 lines
5.1 KiB
Python
160 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import traceback
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Set
|
|
|
|
from app.services import action as action_svc
|
|
from app.settings import Settings
|
|
from app.state import HealthSnapshot, app_state
|
|
|
|
|
|
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 load_processed(path: Path) -> Set[str]:
|
|
if not path.is_file():
|
|
return set()
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
data: Any = json.load(f)
|
|
if isinstance(data, list):
|
|
return set(str(x) for x in data)
|
|
if isinstance(data, dict) and "processed" in data:
|
|
return set(str(x) for x in data["processed"])
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
return set()
|
|
|
|
|
|
def save_processed(path: Path, processed: Set[str]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
json.dump(sorted(processed), f, indent=0, ensure_ascii=False)
|
|
tmp.replace(path)
|
|
|
|
|
|
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
|
|
print(f"[action-watch] inference: {mp4}", flush=True)
|
|
async with app_state.action_lock:
|
|
app_state.action_status = "running"
|
|
try:
|
|
snap = await asyncio.to_thread(action_svc.run_full_action, mp4, settings)
|
|
app_state.last_health = snap
|
|
app_state.action_status = "idle"
|
|
processed.add(key)
|
|
if settings.action_watch_use_state_file:
|
|
save_processed(state_file, processed)
|
|
pred = (snap.raw_class_en or "").strip()
|
|
print(f"[action-watch] done: {mp4.name} -> {pred}", flush=True)
|
|
except Exception as e:
|
|
app_state.last_health = HealthSnapshot(
|
|
behavior_result="",
|
|
health_result="",
|
|
error=str(e),
|
|
)
|
|
app_state.action_status = "error"
|
|
print(f"[action-watch] error on {mp4}: {e}", flush=True)
|
|
traceback.print_exc()
|
|
raise
|
|
|
|
|
|
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:
|
|
try:
|
|
await _run_inference_and_state(mp4, settings, processed, state_file)
|
|
stability.pop(key, None)
|
|
did = True
|
|
except Exception:
|
|
stability[key] = (size, 1)
|
|
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():
|
|
print(f"[action-watch] skip: not a directory: {wd}", flush=True)
|
|
return
|
|
|
|
state_file = _state_path(settings)
|
|
processed: Set[str] = (
|
|
load_processed(state_file) if settings.action_watch_use_state_file else set()
|
|
)
|
|
stability: Dict[str, tuple[int, int]] = {}
|
|
|
|
print(
|
|
f"[action-watch] watching {wd} "
|
|
f"(poll={settings.action_watch_poll_interval}s, "
|
|
f"stable_polls={settings.action_watch_stable_polls}, "
|
|
f"state={'on' if settings.action_watch_use_state_file else 'off'} "
|
|
f"{state_file if settings.action_watch_use_state_file else ''})",
|
|
flush=True,
|
|
)
|
|
|
|
while True:
|
|
await watch_tick(settings, processed, stability, state_file)
|
|
await asyncio.sleep(max(settings.action_watch_poll_interval, 0.1))
|