# coding=utf-8 """本机媒体 + 客户端 API:静态图 + 管理系统下发的手动学习。 访问形式: GET http://<本机局域网IP>:9100/media///crop_xxx.jpg POST http://<本机局域网IP>:9100/api/client/manual-learn """ import json import os import socket import threading from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import unquote from dish_server.media_store import MEDIA_ROOT, MediaStore _MANUAL_LEARN_PATH = "/api/client/manual-learn" _MAX_JSON_BYTES = 2 * 1024 * 1024 _DEFAULT_PORT = int(os.environ.get('DISH_MEDIA_SERVER_PORT', '9100')) _ENABLED = os.environ.get('DISH_MEDIA_SERVER_ENABLED', '1') == '1' _lock = threading.Lock() _httpd = None _thread = None _public_base = None _port = _DEFAULT_PORT _media_root = os.path.abspath(MEDIA_ROOT) def _lan_ip(): env = (os.environ.get('DISH_MEDIA_HOST') or '').strip() if env: return env try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(('8.8.8.8', 80)) return s.getsockname()[0] finally: s.close() except Exception: return '127.0.0.1' class _MediaHandler(SimpleHTTPRequestHandler): def log_message(self, fmt, *args): pass def end_headers(self): self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Base-Code') self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') self.send_header('Cache-Control', 'public, max-age=60') super().end_headers() def do_OPTIONS(self): self.send_response(204) self.send_header('Content-Length', '0') self.end_headers() def do_GET(self): raw = unquote(self.path.split('?', 1)[0]) if raw == '/health': self._send_plain(b'ok') return if raw == _MANUAL_LEARN_PATH: self._send_json(405, { 'code': 40500, 'message': '请使用 POST /api/client/manual-learn', 'data': None, }) return if not raw.startswith('/media/') and raw != '/media': self.send_error(404, 'Not Found') return super().do_GET() def do_POST(self): raw = unquote(self.path.split('?', 1)[0]) if raw != _MANUAL_LEARN_PATH: self.send_error(404, 'Not Found') return if not self._check_api_key(): self._send_json(401, {'code': 40100, 'message': 'X-API-Key 无效', 'data': None}) return try: length = int(self.headers.get('Content-Length') or 0) except ValueError: length = 0 if length <= 0 or length > _MAX_JSON_BYTES: self._send_json(400, {'code': 40001, 'message': '请求体过大或为空', 'data': None}) return raw_body = self.rfile.read(length) try: payload = json.loads(raw_body.decode('utf-8')) except Exception: self._send_json(400, {'code': 40001, 'message': 'JSON 无法解析', 'data': None}) return try: from service.manual_learn import handle_manual_learn status, body = handle_manual_learn(payload) except Exception as exc: print(f'[manual-learn] handler failed: {exc}') self._send_json(500, {'code': 50000, 'message': str(exc), 'data': None}) return self._send_json(status, body) def _check_api_key(self): try: from dao.settings import DISH_MGMT_API_KEY expected = (DISH_MGMT_API_KEY or '').strip() except Exception: expected = '' if not expected: return True got = (self.headers.get('X-API-Key') or '').strip() return got == expected def _send_plain(self, body): self.send_response(200) self.send_header('Content-Type', 'text/plain') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) def _send_json(self, status, obj): raw = json.dumps(obj, ensure_ascii=False).encode('utf-8') self.send_response(status) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(raw))) self.end_headers() self.wfile.write(raw) def translate_path(self, path): """把 /media/xxx 映射到 MEDIA_ROOT/xxx。""" path = unquote(path.split('?', 1)[0]) if path.startswith('/media/'): rel = path[len('/media/'):] elif path == '/media': rel = '' else: rel = '' rel = rel.replace('\\', '/').lstrip('/') # 防目录穿越 parts = [p for p in rel.split('/') if p and p not in ('.', '..')] dest = os.path.normpath(os.path.join(_media_root, *parts)) root = os.path.abspath(_media_root) if dest != root and not dest.startswith(root + os.sep): return os.path.join(root, '__invalid__') return dest def get_public_base(): """本机兜底媒体前缀(局域网),不是管理系统用的 HTTPS tpweb。""" global _public_base if _public_base: return _public_base env = (os.environ.get('DISH_LOCAL_MEDIA_PUBLIC_BASE') or '').strip().rstrip('/') if env: return env return f'http://{_lan_ip()}:{_port}/media' def ensure_media_server(port=None, enabled=None): """启动本机 /media 静态服务(幂等)。""" global _httpd, _thread, _public_base, _port, _media_root if enabled is None: enabled = _ENABLED if not enabled: return get_public_base() with _lock: if _httpd is not None: return _public_base _port = int(port or _DEFAULT_PORT) _media_root = os.path.abspath(MEDIA_ROOT) os.makedirs(_media_root, exist_ok=True) try: _httpd = ThreadingHTTPServer(('0.0.0.0', _port), _MediaHandler) except OSError as exc: print(f'[WARN] media server bind :{_port} failed: {exc}') return get_public_base() lan = _lan_ip() # 本机兜底地址只用局域网;不要覆盖 DISH_MEDIA_PUBLIC_BASE(可能是 https://cpsb.../tpweb) _public_base = f'http://{lan}:{_port}/media' os.environ['DISH_LOCAL_MEDIA_PUBLIC_BASE'] = _public_base _thread = threading.Thread(target=_httpd.serve_forever, name='MediaHttpServer', daemon=True) _thread.start() print(f'[OK] local media fallback: {_public_base} (root={_media_root})') print(f'[OK] manual-learn POST http://{lan}:{_port}{_MANUAL_LEARN_PATH}') return _public_base def stop_media_server(): global _httpd, _thread with _lock: if _httpd is None: return try: _httpd.shutdown() except Exception: pass _httpd = None _thread = None def media_store_with_public(): base = ensure_media_server() return MediaStore(public_base=base)