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

407 lines
18 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.
"""取址:**完全按 App 的方式**向上游歌曲源要地址(i.php → token → do.php → 媒体地址)。
并复刻 App 的"可信地址"闸门(对应 `ktv_api.js` 的 isDemoUrl / `SongUrlResolver.isValidSongUrl`
/ `AdVideoFilter.isTrustedSongUrl`)—— 自建服务器取到的地址也必须过同一套规则,
否则会把广告/测试资源当歌曲下载入库。
闸门规则(任一命中即拒收):
1. **host 或路径**含 wb66 / demo;
2. 域名命中 *.ac16.vip、路径含 /ad_files/、my_ad_video;
3. host 是裸 IP(http://1.2.3.4[:port]/…)→ **必须是域名**;
4. pub-*.r2.dev/ss/ ;
5. `/<编号>.ts` 文件名与点歌编号不一致。
⚠️ **一律先在「剥离 query/fragment」的 URL 上判定**(`urlsplit` 取 host 与 path):
真源地址带 `?token=..&t=..`(2026-10-11 抓包:`.../480p/7200088.ts?token=…&t=…`),
若拿整串做 `.ts` 比对就会误判;反过来若把整串拿去搜 `wb66|demo`,query 里的随机串
也可能把**合法地址误杀**(App 侧注释同样提到 `demo` 这类词太宽松)。
"""
from __future__ import annotations
import hashlib
import json
import random
import re
import threading
import time
import urllib.parse
import urllib.request
APP_ID = "d4eeacc6cec3434fbc8c41608a3056a0"
APP_KEY = "024210cba40d4385a93e6c2d3249bfb5"
SDK_KEY = "19042303a8374f67ae3fe1e25c97936f"
VER = "2.0"
VN = "4.1.3.03281430"
_IP_URL = re.compile(r"^https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?/", re.I)
_R2_DEMO = re.compile(r"pub-[a-z0-9]+\.r2\.dev/ss/", re.I)
_AC16 = re.compile(r"^https?://(?:[^/]+\.)?ac16\.vip(?::\d+)?/", re.I)
# `.ts` 文件名:**只在 URL 的 path 上匹配**(path 里没有 `?`/`#`),故按 `[^/]+` 取整段文件名。
_TS_NAME = re.compile(r"/([^/]+)\.ts(?:/|$)", re.I)
# 广告/测试资源的路径特征(只搜 path,见模块头注释)
_AD_PATH = re.compile(r"/ad_files/|my_ad_video", re.I)
# 测试资源关键字(只搜 host + path,**不搜 query**:query 里是签名 token,误杀风险高)
_DEMO_WORD = re.compile(r"wb66|demo", re.I)
_rate_lock = threading.Lock()
_last_request = [0.0]
def md5(text: str) -> str:
return hashlib.md5(text.encode()).hexdigest()
def split_url(url: str) -> tuple[str, str]:
"""把 URL 拆成 `(host, path)`,**query 与 fragment 一律丢弃**。
正常走 `urllib.parse.urlsplit`;对畸形 URL(`urlsplit` 抛异常/取不到 host)
退化为手工剥离 `?`/`#`,宁可少判也不要拿整串去误判。
"""
s = str(url or "")
try:
p = urllib.parse.urlsplit(s)
if p.netloc:
return (p.hostname or "").lower(), (p.path or "")
except Exception:
pass
head = s.split("#", 1)[0].split("?", 1)[0]
m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/]*)(/.*)?$", head)
if not m:
return "", head
host = m.group(1).rsplit("@", 1)[-1] # 去掉 user:pass@
if host.startswith("["): # IPv6 字面量
host = host[1:host.find("]")] if "]" in host else host
elif ":" in host:
host = host.split(":", 1)[0]
return host.lower(), (m.group(2) or "")
def ts_basename(url: str) -> str | None:
"""取 URL **路径**里 `.ts` 的文件名主体(不含扩展名);路径里没有则返回 None。
例:`http://txog.ktvsky.com/…/480p/7200088.ts?token=…&t=…` → `7200088`。
"""
_host, path = split_url(url)
m = _TS_NAME.search(path)
return m.group(1) if m else None
def _pace(min_interval_ms: int, max_interval_ms: int | None = None) -> float:
"""按 [min,max] 随机间隔限速(**全局串行**)。返回本次实际等待秒数,便于测试。"""
lo = max(0, int(min_interval_ms or 0)) / 1000.0
hi = max(lo, (int(max_interval_ms) if max_interval_ms else int(min_interval_ms or 0)) / 1000.0)
with _rate_lock:
gap = random.uniform(lo, hi)
wait = gap - (time.time() - _last_request[0])
if wait > 0:
time.sleep(wait)
_last_request[0] = time.time()
return max(0.0, wait)
def _get(url: str, timeout: int = 20, ua: str = "MaidongKTV/1.2",
min_interval_ms: int = 300, max_interval_ms: int | None = None) -> tuple[int, str]:
"""干净 GET(App 的原生通道只发 Host+Accept)。
**取链限速**:每次请求前等 `[min_interval_ms, max_interval_ms]` 内的一个随机值
(默认 3~5 秒,随机化避免节奏固定被上游识别)。限速是**全局串行**的 ——
10 个下载线程共用同一个节奏,因此并发不会叠加成对上游的洪水。
"""
_pace(min_interval_ms, max_interval_ms)
req = urllib.request.Request(url, headers={"Accept": "*/*", "User-Agent": ua})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc: # 403/5xx:把状态带回去判断
return exc.code, ""
except Exception:
return 0, ""
def is_trusted_url(url: str, tid: int) -> tuple[bool, str]:
s = str(url or "")
if not re.match(r"^https?://", s, re.I):
return False, "非 http(s)"
# ⚠️ host/path 一律在**剥离 query/fragment** 之后取:真源地址带 `?token=..&t=..`,
# 拿整串做 `.ts` 比对会误判,拿整串搜 wb66/demo 也可能误杀(见模块头注释)。
host, path = split_url(s)
if _DEMO_WORD.search(host + path):
return False, "命中 wb66/demo"
if _AC16.match(s) or _AD_PATH.search(path):
return False, "命中广告域名/路径"
if _IP_URL.match(s):
return False, "host 是裸 IP(App 只接受域名)"
if _R2_DEMO.search(host + path):
return False, "命中 r2.dev 测试桶"
name = ts_basename(s)
if name is not None and name != str(tid):
return False, f"文件名 {name}.ts != 编号 {tid}"
return True, "ok"
# ── 取址签名串(**顺序即原版协议**,2026-10-11 抓包逐字节复核通过)──
# 参数顺序、原始 token 里的 `=`、以及"拼串 + KEY 再 md5"必须与原版 App 完全一致;
# whoami.py 也用这两个函数复用设备身份口径(各取所需字段)。
def token_params(mac: str, sn: str, ts: int | None = None) -> str:
"""`i.php` 的签名串:appid→mac→sn→time→ver→vn。"""
return (f"appid={APP_ID}&mac={mac}_{sn}&sn={sn}"
f"&time={int(ts if ts is not None else time.time())}&ver={VER}&vn={VN}")
def token_sign(params: str) -> str:
"""`i.php` 签名 = md5(原样拼串 + APP_KEY)。"""
return md5(params + APP_KEY)
def song_params(tid: int | str, token: str, mac: str, sn: str, resolution: str = "720",
h265: bool = False, ts: int | None = None) -> str:
"""`music/do.php` 的签名串:appid→device→ish265→ls→musicno→resolution→sn→time→token。"""
return (f"appid={APP_ID}&device={mac}_{sn}&ish265={'1' if h265 else '0'}&ls=1"
f"&musicno={tid}&resolution={resolution}&sn={sn}"
f"&time={int(ts if ts is not None else time.time())}&token={token}")
def song_sign(params: str) -> str:
"""`music/do.php` 签名 = md5(原样拼串 + SDK_KEY)。"""
return md5(params + SDK_KEY)
def fetch_config(host: str, device_id: str, timeout: int = 15, ua: str = "MaidongKTV/1.2") -> dict | None:
"""POST {host}/api.php action=get_config(原版取址前会调;用于了解试用额度)。"""
host = host.rstrip("/")
if not host.startswith("http"):
host = "http://" + host
body = urllib.parse.urlencode({"action": "get_config", "device_id": device_id}).encode()
req = urllib.request.Request(host + "/api.php", data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
req.add_header("User-Agent", ua)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8", "replace"))
return data.get("config") or {}
except Exception:
return None
def get_token(host: str, mac: str, sn: str, timeout: int = 20, ua: str = "MaidongKTV/1.2",
min_interval_ms: int = 300, max_interval_ms: int | None = None) -> str | None:
host = host.rstrip("/")
if not host.startswith("http"):
host = "http://" + host
params = token_params(mac, sn)
status, body = _get(f"{host}/i.php?{params}&sign={token_sign(params)}", timeout, ua,
min_interval_ms, max_interval_ms)
if status != 200 or not body:
return None
try:
data = json.loads(body)
except Exception:
return None
if data.get("code") != 200:
return None
return data.get("token") or None
def get_song_url(host: str, tid: int, token: str, mac: str, sn: str, resolution: str = "720",
h265: bool = False, timeout: int = 20, ua: str = "MaidongKTV/1.2",
min_interval_ms: int = 300, max_interval_ms: int | None = None) -> tuple[str | None, str]:
"""返回 (url, detail)。detail 用于日志:ok / code=xxx / http=xxx。"""
host = host.rstrip("/")
if not host.startswith("http"):
host = "http://" + host
params = song_params(tid, token, mac, sn, resolution, h265)
status, body = _get(f"{host}/music/do.php?{params}&sign={song_sign(params)}", timeout, ua,
min_interval_ms, max_interval_ms)
if status != 200 or not body:
return None, f"http={status}"
try:
data = json.loads(body)
except Exception:
return None, "非JSON"
code = data.get("code")
if code != 200:
return None, f"code={code} msg={data.get('msg', '')}"
url = str(data.get("data") or "")
if not url:
return None, "无 data"
ok, why = is_trusted_url(url, tid)
if not ok:
return None, f"地址不可信({why})"
return url, "ok"
class Resolver:
"""对上游源的取址器:**多个源自动选择**。
自动选择策略(需求③):
1. **健康度打分**:每次成功 +1、失败 -1(下限 -5),分数高的先试 ——
即"上次能用的源优先",与 App 的"成功源置顶"思路一致;
2. **失败冷却**:403(风控/额度)或连续失败 → 该源进入冷却(默认 10 分钟)并沉到队尾,
避免继续冲击它;冷却结束后自动回到候选;
3. 同分时按配置里的书写顺序,保证行为可预期;
4. 全部源都在冷却 / 全部失败 → 明确报错(下载任务转入重试,不会静默丢任务);
5. **取链限速**:每次 i.php / do.php 请求前等 3~5 秒随机间隔(可配),全局串行、
不随并发放大 —— 避免给上游造成拥堵或被判风控。
统计与冷却状态会持久化到 SQLite meta,重启后不丢。
"""
BLOCK_COOLDOWN_SEC = 10 * 60
def __init__(self, cfg, store=None):
self.cfg = cfg
self.store = store
self.tokens: dict[str, str] = {}
self._lock = threading.Lock()
self.last_ok_host = ""
self.last_order: list[str] = []
self.health: dict[str, dict] = {}
self._load_health()
# ── 健康度持久化 ──
def _load_health(self) -> None:
if not self.store:
return
try:
raw = self.store.get_meta("upstream_health", "")
self.health = json.loads(raw) if raw else {}
except Exception:
self.health = {}
def _save_health(self) -> None:
if not self.store:
return
try:
self.store.set_meta("upstream_health", json.dumps(self.health, ensure_ascii=False))
except Exception:
pass
def _h(self, host: str) -> dict:
return self.health.setdefault(host, {"ok": 0, "fail": 0, "score": 0, "last_ok": 0,
"last_error": "", "blocked_until": 0})
def note_ok(self, host: str) -> None:
with self._lock:
h = self._h(host)
h["ok"] += 1
h["score"] = min(50, h["score"] + 1)
h["last_ok"] = int(time.time())
h["last_error"] = ""
h["blocked_until"] = 0
self._save_health()
def note_fail(self, host: str, reason: str) -> None:
with self._lock:
h = self._h(host)
h["fail"] += 1
h["score"] = max(-5, h["score"] - 1)
h["last_error"] = str(reason)[:200]
if "403" in str(reason):
h["blocked_until"] = int(time.time()) + self.BLOCK_COOLDOWN_SEC
elif h["fail"] >= 5 and h["ok"] == 0:
h["blocked_until"] = int(time.time()) + self.BLOCK_COOLDOWN_SEC
self._save_health()
def host_report(self) -> list[dict]:
"""给管理界面看的源列表(含健康度与冷却剩余时间)。"""
now = int(time.time())
out = []
for i, host in enumerate(self._configured_hosts()):
h = dict(self.health.get(host) or {})
out.append({"host": host, "order": i, "ok": h.get("ok", 0), "fail": h.get("fail", 0),
"score": h.get("score", 0), "last_ok": h.get("last_ok", 0),
"last_error": h.get("last_error", ""),
"cooldown_left": max(0, int(h.get("blocked_until", 0)) - now),
"cooling": int(h.get("blocked_until", 0)) > now})
return out
def clear_health(self) -> None:
with self._lock:
self.health = {}
self._save_health()
def _configured_hosts(self) -> list[str]:
hs = self.cfg.get("upstream_hosts") or []
if isinstance(hs, str):
hs = [h.strip() for h in hs.split(",") if h.strip()]
return [h if h.startswith("http") else "http://" + h for h in hs if h]
def mac_sn(self) -> tuple[str, str]:
"""本机上报给上游的设备身份 `(mac, sn)` —— **与 whoami 心跳同一份**。
`ktv/identity.py` 的 `apply_to_cfg()` 已把"自动生成的盒子身份"写进
`upstream_mac`/`upstream_sn`(用户显式配置的优先),因此这里读配置即可:
`i.php` 的 `mac=<mac>_<sn>`、`music/do.php` 的 `device=<mac>_<sn>` 与
whoami 的 `_mac`/`androidid`/`_cid` 全部来自同一个 mac/sn,不会再各读各处。
"""
return str(self.cfg.get("upstream_mac") or ""), str(self.cfg.get("upstream_sn") or "")
def clear_tokens(self) -> None:
"""清掉已缓存的 token(**设备身份变了以后必须清**:旧 token 绑在旧 device 上)。"""
with self._lock:
self.tokens.clear()
def hosts(self) -> list[str]:
"""**自动选择顺序**:按 (是否冷却, 健康分, 配置顺序) 排序。"""
now = int(time.time())
items = []
for i, host in enumerate(self._configured_hosts()):
h = self.health.get(host) or {}
cooling = int(h.get("blocked_until", 0)) > now
items.append((1 if cooling else 0, -int(h.get("score", 0)), i, host))
items.sort()
ordered = [x[3] for x in items]
if ordered:
with self._lock:
if self.last_order != ordered:
self.last_order = ordered
if self.store:
self.store.event("INFO", "上游源自动排序:" + " > ".join(ordered))
return ordered
def resolve(self, tid: int) -> tuple[str | None, str, str]:
"""返回 (url, host, detail)。"""
mac, sn = self.mac_sn() # 与 whoami 心跳同一份设备身份
resolution = str(self.cfg.get("upstream_resolution"))
ua = str(self.cfg.get("user_agent"))
gap = int(self.cfg.get("upstream_interval_min_ms", self.cfg.get("upstream_request_interval_ms", 3000)))
gap_max = int(self.cfg.get("upstream_interval_max_ms", gap))
timeout = int(self.cfg.get("timeout_sec"))
last = "no-source"
for host in self.hosts():
with self._lock:
token = self.tokens.get(host)
if not token:
token = get_token(host, mac, sn, timeout, ua, gap, gap_max)
if token:
with self._lock:
self.tokens[host] = token
if not token:
last = f"{host}: token-fail"
# i.php 拿不到 token(上游限流/风控)→ 记失败并进入冷却,别每 3~5 秒死磕
self.note_fail(host, "token-fail")
with self._lock:
h = self._h(host)
if h.get("fail", 0) >= 3:
h["blocked_until"] = int(time.time()) + int(
self.cfg.get("upstream_token_fail_cooldown_sec") or 900)
self._save_health()
continue
url, detail = get_song_url(host, tid, token, mac, sn, resolution,
False, timeout, ua, gap, gap_max)
if url:
self.last_ok_host = host
self.note_ok(host)
return url, host, "ok"
last = f"{host}: {detail}"
self.note_fail(host, detail)
with self._lock: # token 失效:下次重新登录
self.tokens.pop(host, None)
if self.store:
self.store.event("WARN", f"取址失败 编号={tid} 详情={last}")
return None, "", last
def note_failure(self, tid: int, detail: str) -> None:
if self.store:
self.store.event("WARN", f"取址失败 编号={tid} {detail}")