"""取址:**完全按 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=_`、`music/do.php` 的 `device=_` 与 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}")