合并自建歌曲源支线(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,406 @@
|
||||
"""取址:**完全按 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}")
|
||||
Reference in New Issue
Block a user