video_monitor/scripts/local_server.py

181 lines
6.3 KiB
Python
Raw Normal View History

2026-08-30 22:22:11 +08:00
"""Start/stop the loopback-only development server without modifying saved config."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
from datetime import datetime
from urllib.request import build_opener, ProxyHandler
import psutil
ROOT = Path(__file__).resolve().parents[1]
PYTHON = ROOT / '.venv' / 'Scripts' / 'python.exe'
MANAGE = ROOT / 'manage.py'
RUNTIME = ROOT / '.runtime'
PID_FILE = RUNTIME / 'local-server.json'
URL = 'http://127.0.0.1:10001/login'
def local_environment():
import imageio_ffmpeg
(RUNTIME / 'ultralytics').mkdir(parents=True, exist_ok=True)
ffmpeg = Path(imageio_ffmpeg.__file__).parent / 'binaries'
executables = list(ffmpeg.glob('ffmpeg*.exe'))
if len(executables) != 1 or not executables[0].resolve().is_relative_to(ROOT / '.venv'):
raise RuntimeError('Expected one FFmpeg executable bundled inside the project virtualenv')
env = os.environ.copy()
env.update({
'DJANGO_SETTINGS_MODULE': 'framework.settings',
'MONITOR_DEBUG': 'true',
'MONITOR_ALLOWED_HOSTS': '127.0.0.1,localhost',
'MONITOR_HTTPS': 'false',
'MONITOR_SERVICE_MODE': 'disabled',
'MONITOR_BOOTSTRAP_SERVICES': 'false',
'MONITOR_TELEMETRY_ENDPOINT': '',
'MONITOR_UPDATE_ENDPOINT': '',
'MONITOR_FFMPEG': str(executables[0].resolve()),
'PYTHONUNBUFFERED': '1',
'YOLO_CONFIG_DIR': str(RUNTIME / 'ultralytics'),
'YOLO_OFFLINE': 'true',
'YOLO_AUTOINSTALL': 'false',
# OpenVINO 2026 initializes import telemetry with disable_in_ci=True.
# Use its upstream unattended-validation opt-out in this child only.
'CI': 'true',
})
return env
def backup():
target = RUNTIME / 'backups' / datetime.now().strftime('%Y%m%d-%H%M%S-%f')
target.mkdir(parents=True)
for name in ('monitor.sqlite3', '.runtime-secrets.json', 'config.json', 'settings.json'):
source = ROOT / name
if source.is_file():
shutil.copy2(source, target / name)
print('Backup:', target)
return target
def is_project_process(proc):
try:
args = proc.cmdline()
return (len(args) >= 5 and Path(args[0]).resolve() == PYTHON.resolve()
and Path(args[1]).resolve() == MANAGE.resolve()
and args[2:] == ['runserver', '127.0.0.1:10001', '--noreload'])
except (psutil.Error, OSError):
return False
def recorded_process():
if not PID_FILE.exists():
return None
data = json.loads(PID_FILE.read_text(encoding='utf-8'))
if Path(data['root']).resolve() != ROOT:
raise RuntimeError('Recorded project path mismatch; refusing to manage this process')
try:
proc = psutil.Process(data['pid'])
if abs(proc.create_time() - data['create_time']) > 0.01:
return None
if not is_project_process(proc):
raise RuntimeError('PID ownership mismatch; refusing to manage this process')
return proc
except psutil.NoSuchProcess:
return None
def port_open():
with socket.socket() as sock:
sock.settimeout(1)
return sock.connect_ex(('127.0.0.1', 10001)) == 0
def save_process(proc):
PID_FILE.write_text(json.dumps({'pid': proc.pid, 'create_time': proc.create_time(),
'root': str(ROOT)}, indent=2), encoding='utf-8')
def discover_project_listener():
"""Recover ownership if the PID file was lost, including the Windows venv launcher."""
for conn in psutil.net_connections(kind='tcp'):
if (conn.status != psutil.CONN_LISTEN or not conn.pid
or conn.laddr.ip != '127.0.0.1' or conn.laddr.port != 10001):
continue
try:
listener = psutil.Process(conn.pid)
candidates = [listener, *listener.parents()]
for candidate in reversed(candidates):
if is_project_process(candidate):
return candidate
except psutil.Error:
continue
return None
def start():
RUNTIME.mkdir(exist_ok=True)
proc = recorded_process()
if proc and port_open():
print('Already running:', URL, 'PID:', proc.pid)
return
if port_open() and not proc:
owner = discover_project_listener()
if owner:
save_process(owner)
print('Reused project server:', URL, 'PID:', owner.pid)
return
if port_open() or proc:
raise RuntimeError('Port 10001 is occupied or recorded server is still starting; no process was stopped')
backup()
with open(RUNTIME / 'local-server.stdout.log', 'ab') as out, open(RUNTIME / 'local-server.stderr.log', 'ab') as err:
child = subprocess.Popen(
[str(PYTHON), str(MANAGE), 'runserver', '127.0.0.1:10001', '--noreload'],
cwd=str(ROOT), env=local_environment(), stdin=subprocess.DEVNULL,
stdout=out, stderr=err, creationflags=subprocess.CREATE_NO_WINDOW,
)
info = psutil.Process(child.pid)
save_process(info)
opener = build_opener(ProxyHandler({}))
for _ in range(60):
if child.poll() is not None:
raise RuntimeError('Server exited; inspect .runtime/local-server.stderr.log')
try:
with opener.open(URL, timeout=2) as response:
if response.status == 200:
print('Ready:', URL, 'PID:', child.pid)
return
except OSError:
pass
time.sleep(1)
raise RuntimeError('Server readiness timeout; process left running for diagnosis')
def stop():
proc = recorded_process()
if not proc:
print('No recorded project server is running')
return
children = proc.children(recursive=True)
proc.terminate()
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
pass
_, remaining = psutil.wait_procs([proc] + children, timeout=10)
if remaining:
raise RuntimeError('Some owned processes did not stop; inspect before retrying')
PID_FILE.unlink(missing_ok=True)
print('Stopped project server')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=('start', 'stop', 'backup'))
args = parser.parse_args()
{'start': start, 'stop': stop, 'backup': backup}[args.action]()