Dish_Recognition/service/media_server.py

215 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# coding=utf-8
"""本机媒体 + 客户端 API:静态图 + 管理系统下发的手动学习。
访问形式:
GET http://<本机局域网IP>:9100/media/<base>/<request_id>/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)