合并自建歌曲源支线(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 例)
This commit is contained in:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""whoami 设备心跳自测。
**全程只连本地 stub HTTP 服务(127.0.0.1:随机端口),不碰外网**:
* 抓包的 24 个字段一个不少、顺序一致、可配置项生效;
* 真实 POST 的形状(方法/path/query/Content-Type/UA/空 body)与 `next_interval` 解析;
* 失败(连不上 / 5xx / 非 JSON)**只记状态不抛异常**;
* 后台线程:启动即打一次、`whoami_enabled=False` 时一次都不打、间隔被夹在 [60s, 24h];
* 管理接口 `GET /api/whoami`、`POST /api/whoami/send`。
python3 -m unittest tests.test_whoami -v
"""
import json
import os
import sys
import tempfile
import threading
import unittest
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ktv import admin, whoami
from ktv.config import Config
from ktv.store import Store
from ktv.web import Request
# 2026-10-11 抓包(hbeat.ktvsky.com/whoami)里的设备相关取值
HAR_TS_MS = 1791702330016
HAR_KEYS = ("_src", "_src_at", "_m", "_mac", "_vc", "_vn", "_pkn", "_player", "_boot", "_ts",
"_api", "ktv_id", "androidid", "_manuf", "_cid", "d_name", "d_device", "d_board",
"d_hardware", "d_fingerprint", "d_incremental", "d_release", "d_utc", "d_user")
class _StubServer:
"""本地 stub:记录每一次请求,按固定内容作答(默认 200 + `next_interval=7`)。"""
def __init__(self, body: bytes = b'{"msg":"","code":200,"next_interval":7}',
status: int = 200, content_type: str = "application/json"):
self.body, self.status, self.content_type = body, status, content_type
self.requests: list[dict] = []
self.hit = threading.Event()
outer = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args): # 测试里不要往 stderr 刷日志
pass
def do_POST(self):
parsed = urllib.parse.urlsplit(self.path)
length = int(self.headers.get("Content-Length") or 0)
payload = self.rfile.read(length) if length else b""
outer.requests.append({
"method": self.command, "path": parsed.path, "raw_query": parsed.query,
"query": {k: v[0] for k, v in
urllib.parse.parse_qs(parsed.query, keep_blank_values=True).items()},
"headers": {k.lower(): v for k, v in self.headers.items()},
"body": payload})
outer.hit.set()
self.send_response(outer.status)
self.send_header("Content-Type", outer.content_type)
self.send_header("Content-Length", str(len(outer.body)))
self.end_headers()
self.wfile.write(outer.body)
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.httpd.daemon_threads = True
self.port = self.httpd.server_address[1]
self.url = f"http://127.0.0.1:{self.port}/whoami"
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
def close(self) -> None:
try:
self.httpd.shutdown()
self.httpd.server_close()
except Exception:
pass
def _free_port() -> int:
"""占一个端口再释放 —— 得到一个"确定连不上"的端口号(不会被别的服务占用)。"""
import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class TestWhoamiQuery(unittest.TestCase):
"""query 构造:字段全量、顺序一致、可配置项生效、编码与抓包一致。"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
def test_fields_and_order_match_har(self):
params = whoami.build_query(self.cfg, boot=True, now_ms=HAR_TS_MS)
self.assertEqual([k for k, _ in params], list(HAR_KEYS))
self.assertEqual([k for k, _ in params], list(whoami.PARAM_ORDER))
d = dict(params)
self.assertEqual(len(params), 24)
# 抓包里的固定值
self.assertEqual(d["_src"], "600007")
self.assertEqual(d["_src_at"], "600001")
self.assertEqual(d["_vc"], "4")
self.assertEqual(d["_vn"], "4.1.3.03281430")
self.assertEqual(d["_pkn"], "com.origjoy.local.ktv")
self.assertEqual(d["_player"], "0")
self.assertEqual(d["_api"], "31")
self.assertEqual(d["ktv_id"], "10006789")
self.assertEqual(d["d_user"], "dpi")
self.assertEqual(d["d_release"], "12") # _api=31 → Android 12
# 设备身份:抓包时 _mac / androidid / _cid 三者同源(_cid 带 A_ 前缀)
self.assertEqual(d["androidid"], d["_mac"])
self.assertEqual(d["_cid"], f"A_{d['_mac']}")
# 时间戳:_ts 与 d_utc 同值(抓包如此)
self.assertEqual(d["_ts"], str(HAR_TS_MS))
self.assertEqual(d["d_utc"], str(HAR_TS_MS))
# 平台真实可读的字段必须非空
for k in ("_m", "_manuf", "d_name", "d_device", "d_board", "d_hardware", "d_incremental"):
self.assertTrue(d[k], k)
self.assertTrue(d["d_fingerprint"].startswith(d["_manuf"] + "/"))
def test_boot_flag(self):
self.assertEqual(dict(whoami.build_query(self.cfg, boot=True))["_boot"], "1")
self.assertEqual(dict(whoami.build_query(self.cfg, boot=False))["_boot"], "0")
def test_config_overrides_are_used(self):
self.cfg.update({"whoami_pkn": "com.example.custom", "whoami_model": "SM-TEST",
"whoami_manufacturer": "Acme", "whoami_cid": "C_123",
"whoami_api": 33, "whoami_ktv_id": "42", "whoami_src": "1",
"whoami_src_at": "2", "upstream_mac": "aabbccddeeff"})
d = dict(whoami.build_query(self.cfg, boot=False))
self.assertEqual(d["_pkn"], "com.example.custom")
self.assertEqual(d["_m"], "SM-TEST")
self.assertEqual(d["_manuf"], "Acme")
self.assertEqual(d["_cid"], "C_123")
self.assertEqual(d["_api"], "33")
self.assertEqual(d["d_release"], "13") # 33 → Android 13
self.assertEqual(d["ktv_id"], "42")
self.assertEqual(d["_src"], "1")
self.assertEqual(d["_src_at"], "2")
self.assertEqual(d["_mac"], "aabbccddeeff")
def test_url_uses_config_url_and_percent_encoding(self):
self.cfg.update({"whoami_url": "http://127.0.0.1:9/whoami"})
url = whoami.build_url(self.cfg, boot=True, now_ms=HAR_TS_MS)
self.assertTrue(url.startswith("http://127.0.0.1:9/whoami?"), url)
raw = urllib.parse.urlsplit(url).query
# 抓包里 fingerprint 的 `/` 与 `:` 都是百分号编码
self.assertIn("%2F", raw)
self.assertIn("%3A", raw)
self.assertEqual(dict(urllib.parse.parse_qsl(raw))["_pkn"], "com.origjoy.local.ktv")
def test_user_agent_matches_dalvik_shape(self):
ua = whoami.user_agent(self.cfg)
self.assertTrue(ua.startswith("Dalvik/2.1.0 (Linux; U; Android 12; "), ua)
self.assertIn(" Build/", ua)
class TestWhoamiHttp(unittest.TestCase):
"""真实 POST 到本地 stub:形状、`next_interval` 解析、失败路径。"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.stub = _StubServer()
self.cfg.update({"whoami_url": self.stub.url, "whoami_interval_sec": 1234})
self.addCleanup(self.stub.close)
def test_send_once_posts_expected_request(self):
res = whoami.send_once(self.cfg, boot=True)
self.assertTrue(res["ok"], res)
self.assertEqual(res["http"], 200)
self.assertEqual(res["next_interval"], 7) # 来自响应,而不是配置的 1234
self.assertEqual(self.stub.requests.__len__(), 1)
req = self.stub.requests[0]
self.assertEqual(req["method"], "POST")
self.assertEqual(req["path"], "/whoami")
self.assertEqual(req["body"], b"") # 抓包就是空 body
self.assertEqual(req["headers"].get("content-type"), "text/plain; charset=utf-8")
self.assertTrue(req["headers"].get("user-agent", "").startswith("Dalvik/2.1.0"))
# 24 个字段全量送出
self.assertEqual(set(req["query"]), set(HAR_KEYS))
self.assertEqual(req["query"]["_boot"], "1")
self.assertEqual(req["query"]["_pkn"], "com.origjoy.local.ktv")
self.assertTrue(req["query"]["d_fingerprint"])
self.assertEqual(req["raw_query"].count("&"), 23) # 24 个参数 = 23 个 &
def test_next_interval_falls_back_when_body_not_json(self):
stub = _StubServer(body=b"<html>502</html>")
self.cfg.update({"whoami_url": stub.url})
try:
res = whoami.send_once(self.cfg)
finally:
stub.close()
self.assertTrue(res["ok"]) # HTTP 200 就算成功
self.assertEqual(res["next_interval"], 1234) # 解析不到 → 用配置值
def test_http_error_is_recorded_not_raised(self):
stub = _StubServer(body=b'{"code":500}', status=500)
self.cfg.update({"whoami_url": stub.url})
try:
res = whoami.send_once(self.cfg)
finally:
stub.close()
self.assertFalse(res["ok"])
self.assertEqual(res["http"], 500)
self.assertIn("HTTP 500", res["error"])
def test_connection_refused_is_swallowed(self):
self.cfg.update({"whoami_url": f"http://127.0.0.1:{_free_port()}/whoami"})
res = whoami.send_once(self.cfg) # 不能抛异常
self.assertFalse(res["ok"])
self.assertEqual(res["http"], 0)
self.assertTrue(res["error"])
self.assertGreaterEqual(res["elapsed_ms"], 0)
def test_reporter_sends_on_start_then_waits(self):
rep = whoami.WhoamiReporter(self.cfg)
self.addCleanup(rep.stop)
rep.start()
self.assertTrue(self.stub.hit.wait(5), "启动后应立即打一次")
for _ in range(50): # 等状态落定
if rep.status()["sent"]:
break
threading.Event().wait(0.05)
st = rep.status()
self.assertTrue(st["running"])
self.assertEqual(st["sent"], 1)
self.assertEqual(st["failed"], 0)
self.assertTrue(st["last_ok"])
self.assertEqual(st["last"]["next_interval"], 7)
self.assertEqual(st["last_http"], 200)
self.assertEqual(self.stub.requests[0]["query"]["_boot"], "1") # 启动第一次是 boot
self.assertEqual(rep._interval({"next_interval": 7}), 60) # 夹到下限
self.assertEqual(rep._interval({"next_interval": 10 ** 9}), 24 * 3600)
self.assertEqual(rep._interval({}), 1234) # 回落配置值
def test_reporter_disabled_sends_nothing(self):
self.cfg.update({"whoami_enabled": False})
rep = whoami.WhoamiReporter(self.cfg)
self.addCleanup(rep.stop)
rep.start()
self.assertFalse(self.stub.hit.wait(0.4), "关闭时不应发心跳")
self.assertEqual(rep.status()["sent"], 0)
def test_send_now_works_even_when_disabled(self):
self.cfg.update({"whoami_enabled": False})
rep = whoami.WhoamiReporter(self.cfg)
res = rep.send_now()
self.assertTrue(res["ok"])
self.assertTrue(self.stub.hit.wait(1))
def test_reporter_never_blocks_start(self):
"""`start()` 必须在毫秒级返回(请求在子线程里发)—— 防止启动期阻塞。"""
import time
self.cfg.update({"whoami_url": f"http://127.0.0.1:{_free_port()}/whoami"})
rep = whoami.WhoamiReporter(self.cfg)
self.addCleanup(rep.stop)
t0 = time.time()
rep.start()
self.assertLess(time.time() - t0, 0.5)
class TestWhoamiApi(unittest.TestCase):
"""管理接口:GET /api/whoami(状态)与 POST /api/whoami/send(立刻打一次)。"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.stub = _StubServer()
self.cfg.update({"whoami_url": self.stub.url, "data_dir": self.tmp,
"media_dir": os.path.join(self.tmp, "media")})
self.store = Store(os.path.join(self.tmp, "t.db"))
self.addCleanup(self.stub.close)
self.addCleanup(self.store.conn.close)
@staticmethod
def _req(method, path):
return Request(method, path, {}, {}, b"")
def test_status_and_manual_send(self):
rep = whoami.WhoamiReporter(self.cfg, self.store)
router = admin.build_router(self.cfg, self.store, None, whoami=rep)
st = json.loads(router.dispatch(self._req("GET", "/api/whoami")).body)
self.assertTrue(st["enabled"])
self.assertEqual(st["url"], self.stub.url)
self.assertFalse(st["last_ok"]) # 还没打过
# 立刻打一次 → 状态里应能看到时间/耗时/响应/成功
res = json.loads(router.dispatch(self._req("POST", "/api/whoami/send")).body)
self.assertTrue(res["ok"], res)
self.assertEqual(res["http"], 200)
self.assertIn("next_interval", res["response"])
st2 = json.loads(router.dispatch(self._req("GET", "/api/whoami")).body)
self.assertTrue(st2["last_ok"])
self.assertEqual(st2["sent"], 1)
self.assertGreater(st2["last_at"], 0)
self.assertGreaterEqual(st2["last_elapsed_ms"], 0)
self.assertEqual(st2["last_http"], 200)
self.assertEqual(st2["last_next_interval"], 7)
def test_manual_send_records_failure(self):
self.cfg.update({"whoami_url": f"http://127.0.0.1:{_free_port()}/whoami"})
rep = whoami.WhoamiReporter(self.cfg, self.store)
router = admin.build_router(self.cfg, self.store, None, whoami=rep)
res = json.loads(router.dispatch(self._req("POST", "/api/whoami/send")).body)
self.assertFalse(res["ok"])
self.assertTrue(res["error"])
st = json.loads(router.dispatch(self._req("GET", "/api/whoami")).body)
self.assertEqual(st["failed"], 1)
self.assertFalse(st["last_ok"])
def test_endpoints_are_safe_without_reporter(self):
router = admin.build_router(self.cfg, self.store, None)
st = router.dispatch(self._req("GET", "/api/whoami"))
self.assertEqual(st.status, 200)
self.assertFalse(json.loads(st.body)["enabled"])
send = router.dispatch(self._req("POST", "/api/whoami/send"))
self.assertEqual(send.status, 503)
def test_events_are_written(self):
rep = whoami.WhoamiReporter(self.cfg, self.store)
rep.send_now()
texts = " ".join(e["text"] for e in self.store.events(20))
self.assertIn("whoami 心跳成功", texts)
if __name__ == "__main__":
unittest.main(verbosity=2)