Files
maidong-ktv/selfhost-source/server/ktv/whoami.py
T
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

385 lines
17 KiB
Python
Raw 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.
"""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()