Files
zijunchiang 87aa979abd 合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0
版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定

合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/   自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
  已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成

同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)

文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明

验证:assembleRelease + testReleaseUnitTest 全过(39 例)
2026-10-14 02:06:28 +08:00

368 lines
16 KiB
Python
Raw Permalink 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.
"""极简 HTTP 框架(纯标准库):路由 + JSON/表单解析 + 静态文件(支持 Range/206)。
为什么不用框架:Debian 上零依赖即可部署(`apt install python3` 就够),
省掉 pip/venv 与版本漂移问题。性能对 KTV 场景(1~3 台设备)足够。
**反向代理友好**(IIS/ARR,见 `deploy/iis/`):
* 每个响应都带精确 `Content-Length`(HTTP/1.1 keep-alive 的前提,缺了代理会挂住);
* 媒体流式发送(256KB 分块),**绝不整块读进内存**,发完连接照常复用;
* `HEAD`/`Range` 与直连时行为一致(206 + `Content-Length` + `Accept-Ranges`);
* 请求体超过 `max_body_bytes` → **413 + 中文说明**(并主动关闭连接,不让管道错位);
* 监听队列长度可配(`http_backlog`,默认 128):探活/突发请求不会被拒。
"""
from __future__ import annotations
import json
import os
import re
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable
from . import proxy
# 请求体上限的**兜底值**(实际以配置 `max_body_bytes` 为准,见 make_handler)
MAX_BODY = 8 << 20
# 监听队列长度兜底(实际以配置 `http_backlog` 为准,见 start_server)
DEFAULT_BACKLOG = 128
# 媒体流式发送的分块大小(75MB 的文件整块读进内存会拖垮 2GB 的机器 —— 部署实测)
CHUNK = 256 * 1024
class Request:
def __init__(self, method: str, path: str, query: dict, headers: dict, body: bytes,
client_ip: str = "", peer_ip: str = "", via_proxy: bool = False,
proxy_source: str = "", forwarded_proto: str = "", forwarded_host: str = "",
forwarded_for: str = ""):
self.method = method
self.path = path
self.query = query
# ⚠️ 统一小写:`dict(HTTPMessage)` 之后失去大小写不敏感特性,
# 若保留原样,`Range` 永远匹配不到 `range` → 媒体分发退回全量 200(部署实测踩过)。
self.headers = {(k or "").lower(): v for k, v in dict(headers).items()}
self.body = body
self.params: dict = {}
# ── 来源 IP 的三层口径(见 ktv/proxy.py)──
# `client_ip` = **真实客户端 IP**(限流与日志都用它;经可信代理时来自 XFF/X-Real-IP);
# `peer_ip` = TCP 直连对端(反向代理部署时就是代理地址);
# 手工构造 Request(测试)时通常只给 `client_ip`,其余为空 —— 与直连语义一致。
self.client_ip = client_ip
self.peer_ip = peer_ip or client_ip
self.via_proxy = bool(via_proxy)
self.proxy_source = proxy_source
self.forwarded_proto = forwarded_proto
self.forwarded_host = forwarded_host
self.forwarded_for = forwarded_for
def text(self) -> str:
return self.body.decode("utf-8", "replace")
def json(self) -> dict:
try:
data = json.loads(self.text() or "{}")
return data if isinstance(data, dict) else {}
except Exception:
return {}
def form(self) -> dict:
ctype = (self.headers.get("content-type") or "").lower()
if "application/json" in ctype:
return self.json()
parsed = urllib.parse.parse_qs(self.text(), keep_blank_values=True)
return {k: v[0] for k, v in parsed.items()}
def q(self, key: str, default: str = "") -> str:
return self.query.get(key, default)
def qi(self, key: str, default: int = 0) -> int:
try:
return int(self.query.get(key, default))
except Exception:
return default
def header(self, key: str, default: str = "") -> str:
return self.headers.get(key, default)
class Response:
def __init__(self, body: bytes = b"", status: int = 200, content_type: str = "application/json; charset=utf-8",
headers: dict | None = None, file_path: str | None = None,
file_start: int = 0, file_len: int = 0):
self.body = body
self.status = status
self.content_type = content_type
self.headers = headers or {}
# 媒体走流式发送:75MB 的文件整块读进内存会拖垮 2GB 的机器(部署实测)
self.file_path = file_path
self.file_start = file_start
self.file_len = file_len
def json_response(data, status: int = 200) -> Response:
return Response(json.dumps(data, ensure_ascii=False).encode(), status)
def text_response(text: str, status: int = 200) -> Response:
return Response(str(text).encode(), status, "text/plain; charset=utf-8")
def html_response(html: str, status: int = 200) -> Response:
return Response(str(html).encode(), status, "text/html; charset=utf-8")
class Router:
def __init__(self):
self.routes: list[tuple[str, re.Pattern, Callable, bool]] = []
# **前置守卫**(可选):`guard(req)` 返回 Response 就短路(不再走路由),返回 None 放行。
# 用途:App 令牌校验(ktv/apptoken.py)与双站点宿主隔离 —— 都必须发生在**路由匹配之前**
# (否则 `/media/x.ts` 会先被媒体路由读到文件,隔离就形同虚设)。
self.guard: Callable[[Request], Response | None] | None = None
def add(self, method: str, pattern: str, handler: Callable, auth: bool = False) -> None:
self.routes.append((method.upper(), re.compile("^" + pattern + "$"), handler, auth))
def get(self, pattern: str, auth: bool = False):
def deco(fn):
self.add("GET", pattern, fn, auth)
return fn
return deco
def post(self, pattern: str, auth: bool = False):
def deco(fn):
self.add("POST", pattern, fn, auth)
return fn
return deco
def dispatch(self, req: Request) -> Response:
# 前置守卫(App 令牌 / 宿主隔离):返回响应即短路;**必须在路由之前**。
# 守卫自身出错也不能打死请求 —— 按"放行"处理并在响应里体现(见 apptoken 的防御式写法)。
if self.guard is not None:
early = self.guard(req)
if early is not None:
return early
# ⚠️ HEAD 按 GET 路由(curl -I / OkDownload 的探测都会用到),
# 否则 HEAD 一律 404(部署实测);响应体在 _send 里对 HEAD 主动跳过。
want = "GET" if req.method == "HEAD" else req.method
for method, rx, handler, _auth in self.routes:
if method != want:
continue
m = rx.match(req.path)
if m:
req.params = {k: v for k, v in m.groupdict().items()}
return handler(req)
return json_response({"error": "not found", "path": req.path}, 404)
def serve_file(path: str, req: Request, content_type: str = "video/mp2t") -> Response:
"""支持 Range/206 的文件响应(App 的 OkDownload 断点续传依赖它)。"""
if not os.path.isfile(path):
return text_response("not found", 404)
size = os.path.getsize(path)
rng = req.header("range")
start, end = 0, size - 1
status = 200
headers = {"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=86400"}
if rng:
m = re.match(r"bytes=(\d*)-(\d*)", rng.strip())
if m:
if m.group(1):
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else size - 1
else: # bytes=-N 后缀范围
start = max(0, size - int(m.group(2) or 0))
if start >= size or start > end:
return Response(b"", 416, content_type, {"Content-Range": f"bytes */{size}"})
status = 206
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
length = end - start + 1
# Content-Length 由 _send 统一发送(这里再写一次会出现重复头)
return Response(b"", status, content_type, headers, file_path=path, file_start=start, file_len=length)
def make_handler(router: Router, auth_token: str = "", log_fn: Callable[[str], None] | None = None,
cfg=None, max_body_bytes: int | None = None):
"""构造 handler;`cfg` 非空时按配置认定真实客户端 IP(反向代理)与请求体上限。"""
limit = max_body_bytes
if limit is None and cfg is not None:
try:
limit = int(cfg.get("max_body_bytes") or 0)
except Exception:
limit = 0
limit = int(limit if limit else MAX_BODY)
if limit <= 0:
limit = MAX_BODY
class Handler(BaseHTTPRequestHandler):
server_version = "KtvSource/1.0"
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
# 交给上层日志,不直接写 stderr;**用真实客户端 IP**(经可信代理时是设备 IP,
# 否则反代后面所有日志都是代理地址,排查设备问题等于没日志)。
if log_fn:
who = str(getattr(self, "_log_client_ip", "") or self.address_string())
log_fn(f"{who} {fmt % args}")
# ── 入口 ──
def _handle(self):
try:
parsed = urllib.parse.urlparse(self.path)
query = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query, keep_blank_values=True).items()}
# 真实客户端 IP:直连 = TCP 对端;经**可信**代理 = XFF 最左非代理 / X-Real-IP
# (未注入 cfg 时退回"直连"口径,行为与改造前一致)
peer = str(getattr(self, "client_address", ("", 0))[0] or "")
if cfg is not None:
info = proxy.resolve(peer, self.headers, cfg)
else:
info = proxy.ClientInfo(ip=proxy.norm_ip(peer), peer_ip=proxy.norm_ip(peer))
self._log_client_ip = info.ip or info.peer_ip
if not (self.headers.get("Content-Length") or "").strip() \
and str(self.headers.get("Transfer-Encoding") or "").strip():
# 分块请求体(chunked)本框架不支持:**必须明确拒绝并关连接**,否则那串 chunk
# 会被当成下一个请求解析(keep-alive 管道错位,经代理时表现为"偶发乱响应")。
self.close_connection = True
resp = json_response({
"ok": False,
"error": "不支持分块请求体(Transfer-Encoding: chunked),请带 Content-Length"}, 411)
resp.headers["Connection"] = "close"
self._send(resp)
return
length = self._content_length()
if length is None:
self.close_connection = True
self._send(json_response({"ok": False, "error": "Content-Length 非法"}, 400))
return
limit_now = self._body_limit()
if length > limit_now:
# 超过上限**不读进内存**:直接 413 + 中文说明,并关掉连接(否则剩余字节
# 会被当成下一个请求解析 —— keep-alive 管道错位)。
self.close_connection = True
resp = json_response({
"ok": False,
"error": f"请求体过大:{length} 字节,超过上限 {limit_now} 字节"
f"(配置项 max_body_bytes)"}, 413)
resp.headers["Connection"] = "close"
self._send(resp)
return
body = self.rfile.read(length) if length > 0 else b""
req = Request(self.command, urllib.parse.unquote(parsed.path), query,
dict(self.headers), body, client_ip=info.ip,
peer_ip=info.peer_ip, via_proxy=info.via_proxy,
proxy_source=info.source, forwarded_proto=info.proto,
forwarded_host=info.host, forwarded_for=info.xff)
if auth_token and self._needs_auth(parsed.path):
if not self._authorized(req):
self._send(json_response({"error": "unauthorized"}, 401))
return
resp = router.dispatch(req)
self._send(resp)
except BrokenPipeError:
pass
except Exception as exc: # pragma: no cover
if log_fn:
log_fn(f"handler error: {type(exc).__name__}: {exc}")
try:
self._send(json_response({"error": f"{type(exc).__name__}: {exc}"}, 500))
except Exception:
pass
def _content_length(self) -> int | None:
raw = self.headers.get("Content-Length")
if raw is None or str(raw).strip() == "":
return 0
try:
n = int(str(raw).strip())
except Exception:
return None
return n if n >= 0 else None
def _body_limit(self) -> int:
"""请求体上限:**每次现读配置**(改完立刻生效);读不到就用构造时的兜底值。"""
if cfg is not None:
try:
v = int(cfg.get("max_body_bytes") or 0)
if v > 0:
return v
except Exception:
pass
return limit
def _authorized(self, req: Request) -> bool:
if req.header("x-auth-token") == auth_token:
return True
if req.query.get("token") == auth_token:
return True
auth = req.header("authorization")
return auth == f"Bearer {auth_token}"
def _needs_auth(self, path: str) -> bool:
return path.startswith("/api/") and not path.startswith("/api/public")
def _send(self, resp: Response):
self.send_response(resp.status)
self.send_header("Content-Type", resp.content_type)
if resp.file_path:
# 精确长度 = keep-alive 的前提;HEAD 也要带(App/代理的探测靠它)
self.send_header("Content-Length", str(resp.file_len))
else:
self.send_header("Content-Length", str(len(resp.body)))
for k, v in resp.headers.items():
self.send_header(k, v)
self.end_headers()
if self.command == "HEAD":
return
if resp.file_path:
# 流式分块发送:绝不整块读进内存;发完刚好等于 Content-Length,连接可复用
remaining = resp.file_len
with open(resp.file_path, "rb") as fh:
fh.seek(resp.file_start)
while remaining > 0:
chunk = fh.read(min(CHUNK, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
return
if resp.body:
self.wfile.write(resp.body)
def do_GET(self):
self._handle()
def do_HEAD(self):
self._handle()
def do_POST(self):
self._handle()
def do_PUT(self):
self._handle()
def do_DELETE(self):
self._handle()
return Handler
def start_server(host: str, port: int, router: Router, auth_token: str = "",
log_fn: Callable[[str], None] | None = None, cfg=None,
backlog: int | None = None) -> ThreadingHTTPServer:
"""起一个监听线程并返回 server(`shutdown()` 关闭)。
`backlog` = `request_queue_size`(监听队列长度,配置项 `http_backlog`,默认 128):
IIS/负载均衡的探活 + 设备突发请求并发时,队列太短会直接 Connection refused。
"""
size = int(backlog if backlog is not None else DEFAULT_BACKLOG)
size = max(5, min(4096, size))
class _Server(ThreadingHTTPServer):
request_queue_size = size # 必须在 __init__(= server_activate/listen)之前生效
srv = _Server((host, port), make_handler(router, auth_token, log_fn, cfg=cfg))
srv.daemon_threads = True
t = threading.Thread(target=srv.serve_forever, name=f"http-{port}", daemon=True)
t.start()
return srv