"""Explicit Windows webcam service: loopback only, video only, no disk recording. Does not call ServiceManager.start(): SIP, auto-proxy, recording and telemetry remain disabled. Holds the shared leader lock so an embedded service cannot race it. """ import argparse import configparser from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import secrets import socket import sqlite3 import subprocess import sys import threading import time import psutil import requests ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from scripts.local_server import PYTHON, local_environment, recorded_process as web_process WORK = ROOT / '.runtime' / 'local-webcam' SCRIPT = Path(__file__).resolve() PID_FILE = WORK / 'process.json' STATUS = WORK / 'status.json' STOP = WORK / 'stop.request' STREAM = 'laptop_cam' DEVICE = 'Integrated Camera' MARKER = 'Managed by scripts/local_webcam.py; loopback video only' PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935} PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}' def write_json(path, data): temp = path.with_suffix('.tmp') temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') os.replace(temp, path) def make_config(source, api_secret, hook_url): config = configparser.ConfigParser(interpolation=None, strict=False) config.optionxform = str config.read_string(source) changes = { 'general': {'listen_ip': '127.0.0.1', 'check_nvidia_dev': '0', 'enable_ffmpeg_log': '0', 'mediaServerId': 'local-webcam', 'wait_add_track_ms': '100', 'maxStreamWaitMS': '3000'}, 'api': {'secret': api_secret, 'apiDebug': '0', 'downloadRoot': str(WORK / 'www'), 'snapRoot': str(WORK / 'www'), 'defaultSnap': ''}, 'cluster': {'origin_url': ''}, 'http': {'port': str(PORTS['http']), 'sslport': '0', 'rootPath': str(WORK / 'www'), 'virtualPath': '', 'dirMenu': '0', 'allow_ip_range': '127.0.0.1', 'allow_cross_domains': '0'}, 'rtmp': {'port': str(PORTS['rtmp']), 'sslport': '0'}, 'rtsp': {'port': str(PORTS['rtsp']), 'sslport': '0', 'rtpTransportType': '0'}, 'rtc': {'port': '0', 'tcpPort': '0'}, 'rtp_proxy': {'port': '0', 'dumpDir': ''}, 'srt': {'port': '0'}, 'shell': {'port': '0'}, 'onvif': {'port': '0'}, 'protocol': {'enable_audio': '0', 'add_mute_audio': '0', 'enable_mp4': '0', 'enable_hls': '0', 'enable_hls_fmp4': '0', 'enable_ts': '0', 'enable_rtsp': '1', 'enable_rtmp': '1', 'enable_fmp4': '1', 'continue_push_ms': '0', 'auto_close': '0', 'mp4_save_path': str(WORK / 'www'), 'hls_save_path': str(WORK / 'www')}, 'record': {'enableFmp4': '0'}, 'hls': {'broadcastRecordTs': '0', 'segKeep': '0'}, # ZLM must never launch another ffmpeg process or save a snapshot here. 'ffmpeg': {'bin': '', 'cmd': '', 'snap': '', 'log': ''}, } for section, values in changes.items(): if not config.has_section(section): config.add_section(section) config[section].update(values) if config.has_section('hook'): config.remove_section('hook') config['hook'] = {'enable': '1', 'on_publish': hook_url, 'timeoutSec': '5', 'retry': '0'} return config def capture_command(ffmpeg): return [ffmpeg, '-hide_banner', '-loglevel', 'warning', '-f', 'dshow', '-rtbufsize', '32M', '-video_size', '640x480', '-framerate', '15', '-i', 'video=' + DEVICE, '-map', '0:v:0', '-an', '-sn', '-dn', '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-g', '30', '-threads', '2', '-f', 'flv', PUSH_URL] def allowed_publish(data): return (data.get('app') == 'live' and data.get('stream') == STREAM and data.get('schema') == 'rtmp' and data.get('ip') == '127.0.0.1') def hook_server(): route = '/publish/' + secrets.token_urlsafe(32) class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass # Never log the per-run hook token or payload. def do_POST(self): accepted = False try: size = int(self.headers.get('Content-Length', '0')) if self.path == route and self.client_address[0] == '127.0.0.1' and 0 < size <= 16384: self.connection.settimeout(3) accepted = allowed_publish(json.loads(self.rfile.read(size))) except (ValueError, OSError, TypeError): pass data = {'code': 0 if accepted else -1, 'msg': 'video-only local publisher', 'enable_audio': False, 'add_mute_audio': False, 'enable_mp4': False, 'enable_hls': False, 'enable_hls_fmp4': False} body = json.dumps(data).encode() self.send_response(200) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) threading.Thread(target=server.serve_forever, daemon=True).start() return server, f'http://127.0.0.1:{server.server_port}{route}' def check_connections(processes, allowed_ports): listeners = set() for proc in processes: for conn in proc.connections(kind='inet'): if conn.type == socket.SOCK_DGRAM: raise RuntimeError('Unexpected UDP socket; stopping webcam') if conn.laddr and conn.laddr.ip not in ('127.0.0.1', '::1'): raise RuntimeError(f'Non-loopback binding {conn.laddr} ({conn.status}); stopping webcam') if conn.raddr and conn.raddr.ip not in ('127.0.0.1', '::1'): raise RuntimeError('Non-loopback peer; stopping webcam') if conn.status == psutil.CONN_LISTEN: if conn.laddr.port not in allowed_ports: raise RuntimeError('Unexpected listener; stopping webcam') listeners.add(conn.laddr.port) return listeners def owned_process(): if not PID_FILE.exists(): return None data = json.loads(PID_FILE.read_text(encoding='utf-8')) try: proc = psutil.Process(data['pid']) if abs(proc.create_time() - data['created']) > .01: return None args = proc.cmdline() if (data['root'] != str(ROOT) or len(args) != 3 or Path(args[0]) != PYTHON or Path(args[1]) != SCRIPT or args[2] != 'run'): raise RuntimeError('PID ownership mismatch; no process stopped') return proc except psutil.NoSuchProcess: return None def terminate(child): if child and child.poll() is None: child.terminate() try: child.wait(timeout=8) except subprocess.TimeoutExpired: child.kill() child.wait(timeout=5) def run(): os.environ.update(local_environment()) from app.services.lifecycle import ServiceLeaderLock lock = ServiceLeaderLock() if not lock.acquire(): raise RuntimeError('Another background-service leader exists; refusing to start') media = camera = hook = None row = None try: web = web_process() if not web: raise RuntimeError('Start the local web server first') for conn in psutil.net_connections('inet'): # TIME_WAIT sockets left by a terminated ZLM have no owning # process (pid 0/None on Windows); only a live owner blocks start. if conn.laddr and conn.laddr.port in PORTS.values() and conn.pid: raise RuntimeError('Media port occupied; no unrelated process was stopped') config_data = json.loads((ROOT / 'config.json').read_text(encoding='utf-8')) for key, section in [('mediaHttpPort', 'http'), ('mediaRtspPort', 'rtsp'), ('mediaRtmpPort', 'rtmp')]: if int(config_data[key]) != PORTS[section]: raise RuntimeError('Configured media ports changed; review local webcam settings first') from django.conf import settings database = Path(settings.DATABASES['default']['NAME']) backup_dir = WORK / ('backup-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f')) backup_dir.mkdir() with sqlite3.connect(database.as_uri() + '?mode=ro', uri=True) as source: with sqlite3.connect(backup_dir / database.name) as target: source.backup(target) import django django.setup() # Services explicitly disabled; normal existing schema migration only. from app.models import StreamModel existing = StreamModel.objects.filter(code=STREAM).first() if existing and (existing.remark != MARKER or existing.pull_stream_type != 32): raise RuntimeError('laptop_cam belongs to another stream; refusing to overwrite') from app.utils.Secrets import get_runtime_secret api_secret = get_runtime_secret('media_secret') exe = (ROOT / config_data['mediaStartPath']).resolve() if not exe.is_relative_to(ROOT) or not exe.is_file() or b'listen_ip' not in exe.read_bytes(): raise RuntimeError('Expected project ZLM binary with listen_ip support') template = (ROOT / config_data['mediaStartConfigPath']).read_text(encoding='utf-8', errors='replace') (WORK / 'www').mkdir(exist_ok=True) hook, hook_url = hook_server() ini = WORK / 'zlm.ini' with ini.open('w', encoding='utf-8') as handle: make_config(template, api_secret, hook_url).write(handle, space_around_delimiters=False) with (WORK / 'zlm.log').open('ab') as log: media = subprocess.Popen([str(exe), '-c', str(ini), '-l', '3', '-t', '2', '--log-dir', str(WORK / 'zlm-logs')], cwd=WORK, stdin=subprocess.DEVNULL, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW) client = requests.Session() client.trust_env = False def api(method, **params): response = client.post(f'http://127.0.0.1:{PORTS["http"]}/index/api/{method}', data={'secret': api_secret, **params}, timeout=3) response.raise_for_status() return response.json() for _ in range(30): if media.poll() is not None: raise RuntimeError('ZLM exited; see local-webcam/zlm.log') listeners = check_connections([psutil.Process(media.pid)], set(PORTS.values())) if listeners == set(PORTS.values()): break time.sleep(.3) else: raise RuntimeError('Loopback media listeners not ready') if api('getThreadsLoad').get('code') != 0: raise RuntimeError('Media API health check failed') with (WORK / 'ffmpeg.log').open('ab') as log: camera = subprocess.Popen(capture_command(os.environ['MONITOR_FFMPEG']), cwd=WORK, stdin=subprocess.DEVNULL, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW) for _ in range(40): if camera.poll() is not None: raise RuntimeError('Camera capture failed; see local-webcam/ffmpeg.log') info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM) if info.get('code') == 0 and info.get('tracks'): break time.sleep(.5) else: raise RuntimeError('Camera stream readiness timeout') if any(track.get('codec_type') != 0 for track in info['tracks']): raise RuntimeError('Unexpected non-video track') row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={ 'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头', 'remark': MARKER, 'pull_stream_type': 32, 'pull_stream_transfer_mode': 0, 'pull_stream_url': PUSH_URL, 'pull_stream_ip': '127.0.0.1', 'pull_stream_port': PORTS['rtmp'], 'forward_state': 1, 'is_audio': 0, 'record_enable': 0, 'state': 0, 'camera_name': DEVICE, 'camera_device_id': 'local-webcam'}) if row.remark != MARKER: raise RuntimeError('Stream ownership changed') StreamModel.objects.filter(pk=row.pk).update(forward_state=1, record_enable=0, is_audio=0) print('Ready: laptop_cam (Integrated Camera, 640x480/15fps, video only)', flush=True) while not STOP.exists(): if not web.is_running() or media.poll() is not None or camera.poll() is not None: raise RuntimeError('Web/camera/media process exited; shutting down capture') managed = [psutil.Process(os.getpid()), psutil.Process(media.pid), psutil.Process(camera.pid)] listeners = check_connections(managed, {*PORTS.values(), hook.server_port}) info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM) if info.get('code') != 0 or any(t.get('codec_type') != 0 for t in info.get('tracks', [])): raise RuntimeError('Video stream health check failed') if info.get('isRecordingMP4') or info.get('isRecordingHLS'): raise RuntimeError('Recording detected; stopping camera') write_json(STATUS, {'ready': True, 'stream_id': row.pk, 'stream': STREAM, 'device': DEVICE, 'media_pid': media.pid, 'camera_pid': camera.pid, 'loopback_ports': sorted(listeners), 'tracks': info['tracks'], 'audio': False, 'recording': False, 'sip': False, 'checked_at': datetime.now().isoformat(timespec='seconds')}) time.sleep(2) finally: terminate(camera) terminate(media) if hook: hook.shutdown() hook.server_close() if row is not None: StreamModel.objects.filter(pk=row.pk, remark=MARKER).update(forward_state=0) lock.release() write_json(STATUS, {'ready': False, 'stopped_at': datetime.now().isoformat(timespec='seconds')}) def start(): WORK.mkdir(parents=True, exist_ok=True) proc = owned_process() if proc: print('Webcam process already running:', proc.pid) return STOP.unlink(missing_ok=True) STATUS.unlink(missing_ok=True) with (WORK / 'supervisor.log').open('ab') as log: child = subprocess.Popen([str(PYTHON), str(SCRIPT), 'run'], cwd=ROOT, env=local_environment(), stdin=subprocess.DEVNULL, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW) proc = psutil.Process(child.pid) write_json(PID_FILE, {'pid': proc.pid, 'created': proc.create_time(), 'root': str(ROOT)}) for _ in range(60): if child.poll() is not None: raise RuntimeError('Webcam start failed; inspect .runtime/local-webcam/supervisor.log') if STATUS.exists() and json.loads(STATUS.read_text(encoding='utf-8')).get('ready'): print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | video only') return time.sleep(1) STOP.touch() raise RuntimeError('Webcam readiness timeout; requested shutdown') def stop(): proc = owned_process() if not proc: print('No owned webcam process running') return STOP.touch() try: proc.wait(timeout=20) except psutil.TimeoutExpired: raise RuntimeError('Graceful stop timed out; inspect owned processes before retrying') PID_FILE.unlink(missing_ok=True) print('Webcam and local media stopped; web server and saved stream record retained') if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=['start', 'stop', 'run', 'status']) action = parser.parse_args().action if action == 'status': print(STATUS.read_text(encoding='utf-8') if STATUS.exists() else 'Not started') else: {'start': start, 'stop': stop, 'run': run}[action]()