215 lines
7.1 KiB
Python
215 lines
7.1 KiB
Python
# 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)
|