版本号:
- 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 例)
385 lines
17 KiB
Python
385 lines
17 KiB
Python
"""whoami 设备心跳:复刻官方 App 对上游 `hbeat.ktvsky.com/whoami` 的周期上报。
|
||
|
||
## 为什么要有它
|
||
2026-10-11 抓包里官方 App 是**三个**请求:`POST /whoami`(设备登记/保活)→
|
||
`GET /i.php`(取 token)→ `GET /music/do.php`(取歌地址)。我们此前只做了后两个,
|
||
**完全没有 whoami** —— 上游按"设备是否合法/活跃"决定下发真源还是广告地址,
|
||
怀疑这正是"只给我们回 wb66/demo 广告地址"的原因之一。
|
||
|
||
## 请求形状(按 HAR 全量复刻)
|
||
`POST {whoami_url}?<24 个 query 参数>`,`Content-Type: text/plain; charset=utf-8`,
|
||
**body 为空**(`Content-Length: 0`),UA 为 `Dalvik/2.1.0 (Linux; U; Android …; … Build/…)`;
|
||
响应 `{"msg":"","code":200,"next_interval":3600}` —— `next_interval` 就是下次上报间隔。
|
||
|
||
## 行为约定(与 App 侧一致)
|
||
* 启动先打一次(`_boot=1`),之后按响应里的 `next_interval` 周期上报(`_boot=0`);
|
||
响应没给就用配置 `whoami_interval_sec`(默认 3600),并夹在 [60, 86400] 秒内;
|
||
* **全部在后台守护线程里**(参考 `StorageGuard` 的写法),失败只记日志 + 记进
|
||
`status()`,**绝不阻塞启动、绝不影响下载/取址主流程** —— 本项目已两次因为
|
||
启动期阻塞导致"进程活着但不监听端口";
|
||
* 手工验证走 `send_now()`(`POST /api/whoami/send`),**不受 `whoami_enabled` 限制**,
|
||
否则关掉心跳后就没法手工验证了;
|
||
* 单测用**本地 stub HTTP 服务**验证请求形状,绝不连外网。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import platform
|
||
import socket
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
from .identity import ANDROID_RELEASE as _ANDROID_RELEASE
|
||
from .identity import active_identity, to_url_params
|
||
from .resolver import VN
|
||
|
||
log = logging.getLogger("ktv.whoami")
|
||
|
||
DEFAULT_URL = "http://hbeat.ktvsky.com/whoami"
|
||
|
||
# 抓包(2026-10-11 HAR)里的固定值 —— 这些字段与设备无关,原版就是写死的。
|
||
WHOAMI_VC = 4 # 客户端 versionCode(原版 4.1.3)
|
||
WHOAMI_PLAYER = 0 # 播放器类型(0 = 默认)
|
||
WHOAMI_D_USER = "dpi" # 原版按 dpi 上报
|
||
WHOAMI_TIMEOUT_SEC = 10 # 心跳自己的超时(只是一次小 POST,无需下载那种 60s)
|
||
INTERVAL_MIN_SEC = 60 # next_interval 下限(防止上游给个 0 把节奏打疯)
|
||
INTERVAL_MAX_SEC = 24 * 3600 # 上限
|
||
DISABLED_RECHECK_SEC = 60 # 关闭心跳后,隔多久再看一眼开关(改配置无需重启)
|
||
|
||
# `_api` → Android 版本号(表在 ktv/identity.py:设备身份的 `_api`/`d_release` 也用它,单一来源)。
|
||
# `_api`/`_vc`/`_pkn`/`_vn` 合起来构成"这是官方 Android 客户端"的口径,因此 `d_release` 与 UA
|
||
# 都按它取值,保证三者自洽。取不到就用 12(= API 31)。
|
||
|
||
|
||
# query 参数的**顺序即抓包顺序**(上游若按位置解析就不会错)。
|
||
PARAM_ORDER = ("_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")
|
||
|
||
|
||
def android_release(api: int) -> str:
|
||
"""`_api` → Android 版本号(未知取值回退 "12",与默认 `whoami_api=31` 一致)。"""
|
||
try:
|
||
return _ANDROID_RELEASE.get(int(api), "12")
|
||
except Exception:
|
||
return "12"
|
||
|
||
|
||
def identity_facts(cfg, ident: dict) -> dict:
|
||
"""**伪装成安卓盒子**时的 whoami 设备字段:整份身份来自 `ktv/identity.py`。
|
||
|
||
这是"三处同源"的关键一环:`_mac`/`androidid`/`_cid` 与 `i.php` 的 `mac=<mac>_<sn>`、
|
||
`do.php` 的 `device=<mac>_<sn>` 用的是**同一份身份**(同一个 mac/sn),`_m`/`_manuf`/`d_*`
|
||
也来自同一套盒子模板 —— 不再出现"Linux 机型 + 安卓指纹"这种自相矛盾的上报。
|
||
|
||
配置项 `whoami_*` 只在**用户显式配置**过时覆盖身份里的对应字段(`is_explicit`),
|
||
否则一律以身份为准(否则自动身份会被默认值静默压掉)。
|
||
"""
|
||
from .identity import DEFAULT_KTV_ID, is_explicit # 避免顶部循环导入
|
||
p = to_url_params(ident)
|
||
# ⚠️ mac 以 `upstream_mac` 的**生效值**为准:`identity.apply_to_cfg()` 已经保证它要么是
|
||
# 用户显式配置、要么就是身份里的 mac —— 这样 whoami 的 `_mac`/`androidid`/`_cid` 与
|
||
# i.php 的 `mac=<mac>_<sn>`、do.php 的 `device=<mac>_<sn>` 永远是同一个 mac。
|
||
mac = str(cfg.get("upstream_mac") or p["_mac"])
|
||
p["_mac"] = mac
|
||
p["androidid"] = mac
|
||
p["_cid"] = f"A_{mac}"
|
||
if is_explicit(cfg, "whoami_pkn"): # 显式配置优先,否则用身份自带的包名
|
||
p["_pkn"] = str(cfg.get("whoami_pkn"))
|
||
if is_explicit(cfg, "whoami_api"):
|
||
try:
|
||
api = int(cfg.get("whoami_api") or 31)
|
||
release = android_release(api)
|
||
if release != p["d_release"]:
|
||
# 显式改了 `_api` 就必须把 `d_release` 与指纹里的版本段一起改掉,
|
||
# 否则会出现"_api=31 却自称 Android 9"这种自相矛盾的上报(一眼是假的)。
|
||
p["d_fingerprint"] = p["d_fingerprint"].replace(
|
||
f":{p['d_release']}/", f":{release}/", 1)
|
||
p["d_release"] = release
|
||
p["_api"] = str(api)
|
||
except Exception:
|
||
pass
|
||
if is_explicit(cfg, "whoami_ktv_id"):
|
||
p["ktv_id"] = str(cfg.get("whoami_ktv_id"))
|
||
elif not p["ktv_id"]:
|
||
p["ktv_id"] = str(cfg.get("whoami_ktv_id") or DEFAULT_KTV_ID)
|
||
for key, ident_key in (("whoami_model", "_m"), ("whoami_manufacturer", "_manuf")):
|
||
if is_explicit(cfg, key):
|
||
p[ident_key] = str(cfg.get(key))
|
||
if is_explicit(cfg, "whoami_cid"):
|
||
p["_cid"] = str(cfg.get("whoami_cid"))
|
||
return {
|
||
"_src": str(cfg.get("whoami_src") or "600007"),
|
||
"_src_at": str(cfg.get("whoami_src_at") or "600001"),
|
||
"_player": WHOAMI_PLAYER,
|
||
**p,
|
||
}
|
||
|
||
|
||
def device_facts(cfg) -> dict:
|
||
"""设备身份字段:**有盒子身份就用它;否则退回"能读到的真实值 + 配置覆盖"**。
|
||
|
||
分两种口径:
|
||
1. **伪装成安卓盒子**(`device_identity` 已接进配置,见 `ktv/identity.py`):
|
||
`_m`/`_manuf`/`d_name`/`d_device`/`d_board`/`d_hardware`/`d_fingerprint`/`d_incremental`/
|
||
`d_release`/`ktv_id`/`_mac`/`androidid`/`_cid` **全部来自那台假盒子**(三处同源);
|
||
2. **没有身份时**(`device_identity_auto=false` 且无身份文件)的兜底:
|
||
`d_name`=hostname、`d_device`/`d_board`=机器架构、`d_hardware`=processor、
|
||
`d_incremental`=内核构建号,`_manuf`/`_m` 取系统/架构(可用 `whoami_*` 覆盖)——
|
||
这条路径上报的就是真实 Linux 机器,只用于排错,不再作为默认。
|
||
"""
|
||
ident = active_identity(cfg)
|
||
if ident:
|
||
return identity_facts(cfg, ident)
|
||
mac = str(cfg.get("upstream_mac") or "")
|
||
try:
|
||
api = int(cfg.get("whoami_api") or 31)
|
||
except Exception:
|
||
api = 31
|
||
machine = platform.machine() or "unknown"
|
||
return {
|
||
"_src": str(cfg.get("whoami_src") or "600007"),
|
||
"_src_at": str(cfg.get("whoami_src_at") or "600001"),
|
||
"_m": str(cfg.get("whoami_model") or machine),
|
||
"_mac": mac,
|
||
"_vc": WHOAMI_VC,
|
||
"_vn": VN,
|
||
"_pkn": str(cfg.get("whoami_pkn") or "com.origjoy.local.ktv"),
|
||
"_player": WHOAMI_PLAYER,
|
||
"_api": api,
|
||
"ktv_id": str(cfg.get("whoami_ktv_id") or ""),
|
||
"androidid": mac,
|
||
"_manuf": str(cfg.get("whoami_manufacturer") or platform.system() or "Linux"),
|
||
"_cid": str(cfg.get("whoami_cid") or f"A_{mac}"),
|
||
"d_name": socket.gethostname() or "localhost",
|
||
"d_device": machine,
|
||
"d_board": machine,
|
||
"d_hardware": platform.processor() or machine,
|
||
"d_incremental": platform.version() or android_release(api),
|
||
"d_release": android_release(api),
|
||
"d_user": WHOAMI_D_USER,
|
||
# 真实平台没有可用的 Android 指纹,按原版格式拼一个(仅排错路径用)
|
||
"d_fingerprint": "",
|
||
}
|
||
|
||
|
||
def build_query(cfg, boot: bool = False, now_ms: int | None = None) -> list[tuple[str, str]]:
|
||
"""按抓包顺序生成 whoami 的 query 参数(**24 个字段一个不少**)。
|
||
|
||
`_boot=1` 表示"刚启动这次",之后周期上报都是 0(与 App 侧 `sendDeviceHeartbeat(boot)` 一致)。
|
||
`_ts`/`d_utc` 都是**当前毫秒**;抓包里 `d_utc` 与 `_ts` 同值。
|
||
"""
|
||
f = device_facts(cfg)
|
||
ts = int(now_ms if now_ms is not None else time.time() * 1000)
|
||
# fingerprint:盒子身份直接用它自带的(模板里的**真机形态**,见 ktv/identity.py);
|
||
# 只有"没有身份"的兜底路径才按原版格式临时拼一个(真实字段,仅排错用)。
|
||
fingerprint = str(f.get("d_fingerprint") or "") or (
|
||
f"{f['_manuf']}/{f['d_name']}/{f['d_device']}:{f['d_release']}/"
|
||
f"{f['d_incremental']}:user/release-keys")
|
||
values = {
|
||
**f,
|
||
"_boot": "1" if boot else "0",
|
||
"_ts": str(ts),
|
||
"d_fingerprint": fingerprint,
|
||
"d_utc": str(ts),
|
||
}
|
||
return [(k, str(values[k])) for k in PARAM_ORDER]
|
||
|
||
|
||
def build_url(cfg, boot: bool = False, now_ms: int | None = None) -> str:
|
||
"""完整的 whoami URL(query 用 RFC3986 百分号编码:`/`→`%2F`、`:`→`%3A`,与抓包一致)。"""
|
||
base = str(cfg.get("whoami_url") or DEFAULT_URL)
|
||
qs = urllib.parse.urlencode(build_query(cfg, boot, now_ms),
|
||
quote_via=urllib.parse.quote, safe="")
|
||
return f"{base}?{qs}"
|
||
|
||
|
||
def user_agent(cfg) -> str:
|
||
"""原版 UA:`Dalvik/2.1.0 (Linux; U; Android <版本>; <型号> Build/<构建>)`(抓包实测)。"""
|
||
f = device_facts(cfg)
|
||
return (f"Dalvik/2.1.0 (Linux; U; Android {f['d_release']}; "
|
||
f"{f['_m']} Build/{f['d_incremental']})")
|
||
|
||
|
||
def send_once(cfg, boot: bool = False, store=None) -> dict:
|
||
"""打一次 whoami;**任何异常都吞掉**并记进返回值(失败绝不能影响主流程)。
|
||
|
||
返回 `{ok, at, elapsed_ms, url, ua, http, response, error, next_interval}`,
|
||
管理界面的 `GET /api/whoami` 直接把最后一次的这个字典展示出来。
|
||
"""
|
||
started = time.time()
|
||
try:
|
||
default_interval = int(cfg.get("whoami_interval_sec") or 3600)
|
||
except Exception:
|
||
default_interval = 3600
|
||
out: dict = {"ok": False, "at": int(started), "elapsed_ms": 0, "http": 0,
|
||
"response": "", "error": "", "next_interval": default_interval,
|
||
"url": "", "ua": ""}
|
||
try:
|
||
out["url"] = build_url(cfg, boot)
|
||
out["ua"] = user_agent(cfg)
|
||
req = urllib.request.Request(out["url"], data=b"", method="POST")
|
||
req.add_header("Content-Type", "text/plain; charset=utf-8")
|
||
req.add_header("User-Agent", out["ua"])
|
||
with urllib.request.urlopen(req, timeout=WHOAMI_TIMEOUT_SEC) as resp:
|
||
out["http"] = int(getattr(resp, "status", 0) or 0)
|
||
body = resp.read().decode("utf-8", "replace")
|
||
out["response"] = body[:500]
|
||
out["ok"] = 200 <= out["http"] < 300
|
||
try:
|
||
data = json.loads(body) if body.strip() else {}
|
||
except Exception:
|
||
data = {}
|
||
if isinstance(data, dict) and data.get("next_interval"):
|
||
try:
|
||
out["next_interval"] = int(data["next_interval"])
|
||
except Exception:
|
||
pass
|
||
except urllib.error.HTTPError as exc: # 4xx/5xx 也带状态码回来
|
||
out["http"] = int(getattr(exc, "code", 0) or 0)
|
||
out["error"] = f"HTTP {exc.code}"
|
||
try:
|
||
exc.close() # 关掉底层连接,否则测试里报 ResourceWarning
|
||
except Exception:
|
||
pass
|
||
except Exception as exc:
|
||
out["error"] = f"{type(exc).__name__}: {exc}"
|
||
out["elapsed_ms"] = int((time.time() - started) * 1000)
|
||
return out
|
||
|
||
|
||
class WhoamiReporter:
|
||
"""后台守护线程:启动先打一次(`_boot=1`),之后按上游 `next_interval` 周期上报。
|
||
|
||
写法与 `StorageGuard` 一致:独立 daemon 线程 + 事件日志 + 状态快照。
|
||
`start()` 只起线程就立刻返回(**不在调用线程里发请求**),因此绝不会拖住启动。
|
||
"""
|
||
|
||
def __init__(self, cfg, store=None):
|
||
self.cfg = cfg
|
||
self.store = store
|
||
self._stop = threading.Event()
|
||
self._lock = threading.Lock()
|
||
self._thread: threading.Thread | None = None
|
||
self._last: dict = {}
|
||
self._sent = 0
|
||
self._failed = 0
|
||
self._started_at = 0
|
||
|
||
# ── 状态快照(给管理界面) ──
|
||
def status(self) -> dict:
|
||
with self._lock:
|
||
last = dict(self._last)
|
||
return {
|
||
"enabled": bool(self.cfg.get("whoami_enabled")),
|
||
"url": str(self.cfg.get("whoami_url") or DEFAULT_URL),
|
||
"interval_sec": self._cfg_interval(),
|
||
"running": bool(self._thread and self._thread.is_alive()),
|
||
"started_at": self._started_at,
|
||
"sent": self._sent, "failed": self._failed,
|
||
"last": last,
|
||
"last_ok": bool(last.get("ok")),
|
||
"last_at": int(last.get("at") or 0),
|
||
"last_elapsed_ms": int(last.get("elapsed_ms") or 0),
|
||
"last_http": int(last.get("http") or 0),
|
||
"last_response": last.get("response", ""),
|
||
"last_error": last.get("error", ""),
|
||
"last_next_interval": int(last.get("next_interval") or 0),
|
||
}
|
||
|
||
def _cfg_interval(self) -> int:
|
||
try:
|
||
return int(self.cfg.get("whoami_interval_sec") or 3600)
|
||
except Exception:
|
||
return 3600
|
||
|
||
def _interval(self, res: dict) -> int:
|
||
"""下次等待秒数:优先响应里的 `next_interval`,否则配置值;夹在 [60, 86400]。"""
|
||
try:
|
||
n = int(res.get("next_interval") or 0)
|
||
except Exception:
|
||
n = 0
|
||
if n <= 0:
|
||
n = self._cfg_interval()
|
||
return max(INTERVAL_MIN_SEC, min(INTERVAL_MAX_SEC, n))
|
||
|
||
def _record(self, res: dict) -> dict:
|
||
with self._lock:
|
||
self._last = dict(res)
|
||
if res.get("ok"):
|
||
self._sent += 1
|
||
else:
|
||
self._failed += 1
|
||
return res
|
||
|
||
def _note(self, res: dict, manual: bool = False) -> None:
|
||
"""成功/失败各记一条(**只记结论**,原始响应留在 status 里给界面看)。
|
||
|
||
⚠️ 两条都用 `log.info`:失败不是"程序出错",而是上游不可达这类预期内情况,
|
||
远程/文件日志里看消息文本即可;写成 `log.warning` 会在没配 handler 的场景
|
||
(如单测)经 `logging.lastResort` 刷 stderr,污染验收输出。
|
||
"""
|
||
who = "手工" if manual else "周期"
|
||
if res.get("ok"):
|
||
msg = (f"whoami 心跳成功({who})http={res.get('http')} "
|
||
f"next_interval={res.get('next_interval')}s 用时={res.get('elapsed_ms')}ms")
|
||
level = "INFO"
|
||
else:
|
||
msg = (f"whoami 心跳失败({who})http={res.get('http')} "
|
||
f"error={res.get('error') or '未知'} 用时={res.get('elapsed_ms')}ms")
|
||
level = "WARN"
|
||
log.info("%s", msg)
|
||
if self.store is not None:
|
||
try:
|
||
self.store.event(level, msg)
|
||
except Exception: # pragma: no cover - 兜底
|
||
pass
|
||
|
||
# ── 手工验证 ──
|
||
def send_now(self) -> dict:
|
||
"""**同步**打一次(供 `POST /api/whoami/send`)。不受 `whoami_enabled` 限制。"""
|
||
res = self._record(send_once(self.cfg, boot=False))
|
||
self._note(res, manual=True)
|
||
return res
|
||
|
||
# ── 后台线程 ──
|
||
def loop(self) -> None:
|
||
boot = True
|
||
while not self._stop.is_set():
|
||
if not bool(self.cfg.get("whoami_enabled")):
|
||
# 开关关掉时不发(运行中改配置无需重启:隔一会儿再看一眼)
|
||
if self._stop.wait(DISABLED_RECHECK_SEC):
|
||
return
|
||
continue
|
||
res = self._record(send_once(self.cfg, boot=boot))
|
||
self._note(res)
|
||
boot = False
|
||
if self._stop.wait(self._interval(res)):
|
||
return
|
||
|
||
def start(self) -> None:
|
||
"""起后台守护线程(**立即返回**,第一个请求在子线程里发)。
|
||
|
||
维护任务会在"长下载开始 / 排空结束"之间反复 `start()`/`stop()`:上一次的线程收到
|
||
停止位后还要一点点时间才退出,若直接看 `is_alive()` 就 return,会出现
|
||
"`stop()` 之后再也起不来心跳"。因此仅当**旧线程已收到停止位**时最多等 1 秒让它收尾。
|
||
"""
|
||
old = self._thread
|
||
if old is not None and old.is_alive():
|
||
if self._stop.is_set():
|
||
old.join(timeout=1.0)
|
||
if old.is_alive():
|
||
return
|
||
self._stop.clear()
|
||
self._started_at = int(time.time())
|
||
self._thread = threading.Thread(target=self.loop, name="whoami-heartbeat", daemon=True)
|
||
self._thread.start()
|
||
|
||
def stop(self) -> None:
|
||
self._stop.set()
|