339 lines
16 KiB
Python
339 lines
16 KiB
Python
|
|
"""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]()
|