合并自建歌曲源支线(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:
@@ -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)
|
||||
Reference in New Issue
Block a user