"""服务端口:**App 请求接口**(与真实歌曲源同协议)+ 媒体分发 + App 点歌推送。 POST /api.php action=get_config&device_id= → 配置(本服务器不限试用) GET /i.php appid&mac=_&sn&time&ver&vn&sign → {"code":200,"token":…} GET /music/do.php appid&device&ish265&ls&musicno&resolution&sn&time&token&sign → {"code":200,"msg":"SUCCESS","data":"<媒体地址>","limit":2000} GET /media/.ts → 媒体文件(支持 Range/206,供 App 断点续传与即下即播) GET /token → App 令牌(随机、默认 10 分钟时效、可绑客户端 IP;见 ktv/apptoken.py) GET /token/status → 令牌计数(签发/校验失败/限流;运维核对) POST /push_download → App 点歌拿到的下载地址同步入队(鉴权/限流/闸门/去重,见 ktv/pushdl.py) GET /push_download/status → 推送计数(accepted/duplicate/rejected/rate_limited/last_at) GET /healthz → 探活(**纯内存快照**,给 IIS/负载均衡用;见 ktv/health.py) GET /api/client_ip → 排错:服务端认定的客户端 IP / 是否来自可信代理 / 命中的头 / 宿主判定 反向代理(IIS/ARR,见 `deploy/iis/`)拆**两个站点**: * API 站(`mdmuse.kasugano.cn`):`token` / `api.php` / `i.php` / `music/do.php` / `push_download` / `push_download/status` / `healthz`; * 媒体站(`md.kasugano.cn:4433`):**只**有 `media/*`,只下文件; * 后端按可信的 `X-Forwarded-Host`(不可信时退回 `Host`)做宿主隔离,配置项 `media_hosts` / `api_hosts`(默认都空 = 不校验,行为与单站点一致)。 兼容性红线(**别动**):已发布的官方 App 直接调 `/i.php` 与 `/music/do.php`,**不带**令牌。 因此 `app_token_enforce=false`(默认)时这两个接口只"带了令牌才校验", 参数与签名算法一个字都不能改(已用抓包逐字节复刻)。 注意(与 App 闸门保持一致): * 返回的媒体地址**必须是域名**(裸 IP 会被 App 拒收),故用 `public_media_base`; * **不要用 403 表达"这首歌还没下载"** —— App 会把它当源站风控;用 `code:404`。 """ from __future__ import annotations import time from . import apptoken from . import health as health_mod from . import proxy, pushdl, web def build_router(cfg, store, downloader=None, health=None) -> web.Router: r = web.Router() snap = health if health is not None else health_mod.HealthSnapshot(cfg, store, downloader) # ── POST /api.php ── @r.post("/api.php") def api_php(req: web.Request): store.event("INFO", f"App 请求 api.php device_id={req.form().get('device_id', '')}") return web.json_response({ "status": "success", "config": { "network_verification": False, "dialog_title": "", "buy_enabled": False, "buy_mode": "url", "buy_url": "", "buy_qrcode": "", "heartbeat_interval": 3600, "trial_enabled": False, "trial_times": 0, "trial_duration": 0, "server_time": int(time.time()), }, }) # ── GET /i.php ── @r.get("/i.php") def i_php(req: web.Request): tid = "" token = cfg.get("service_token") or "local" store.event("INFO", f"App 请求 i.php mac={req.q('mac')}") return web.json_response({ "code": 200, "msg": "ok", "retry": 5, "ordertype": 0, "end_date": "2035-01-01 00:00:00", "heart_time": 5400, "token": token, }) # ── GET /music/do.php ── @r.get("/music/do.php") def do_php(req: web.Request): musicno = req.q("musicno").strip() if not musicno.isdigit(): return web.json_response({"code": 400, "msg": "bad musicno"}) tid = int(musicno) row = store.q1("SELECT state, bytes FROM downloads WHERE tid=?", (tid,)) song = store.q1("SELECT name FROM songs WHERE tid=?", (tid,)) # ⚠️ 媒体地址走 proxy:经**可信**代理时用 X-Forwarded-Proto/Host 拼 https + 公网域名 # (否则 HTTPS 站点会给 App 返回 http 内网地址 —— App 拒收,也泄露内网拓扑) url = proxy.public_media_url(cfg, req, tid) if row and row["state"] == "done": store.event("INFO", f"取址成功 编号={tid} {song['name'] if song else ''}") return web.json_response({"code": 200, "msg": "SUCCESS", "data": url, "limit": 2000}) if song is None: # 曲库里没有这个编号:用 404,绝不用 403(403 会被 App 当成源站风控) store.event("WARN", f"取址失败 编号={tid} 本服务器曲库无此编号") return web.json_response({"code": 404, "msg": "not in library"}) state = row["state"] if row else "pending" store.event("WARN", f"取址失败 编号={tid} 尚未下载完成(state={state})") return web.json_response({"code": 404, "msg": f"not ready: {state}"}) # ── GET /media/.ts ── @r.get(r"/media/(?P\d+)\.ts") def media(req: web.Request): import os tid = req.params.get("tid", "") path = os.path.join(cfg.media_dir, f"{tid}.ts") resp = web.serve_file(path, req) if resp.status == 404: store.event("WARN", f"媒体缺失 编号={tid}(App 请求了未下载的文件)") return resp @r.get("/healthz") def healthz(req: web.Request): """探活(IIS / 负载均衡):**纯内存快照 + 微秒级返回**,不查库、不碰媒体目录。 数字(library/keeps/downloader)由后台线程每 30 秒刷新一次(见 ktv/health.py); 这里的 `snapshot_age_sec` = 快照距今多少秒(-1 = 还没刷新过),运维一眼能看出新鲜度。 兼容老字段:`songs`/`done`/`pending`/`concurrency`。 """ return web.json_response(snap.snapshot_compat()) @r.get("/api/client_ip") def client_ip(req: web.Request): """排错用:服务端**认定的客户端 IP** + 它从哪来(反代后核对 XFF/Proto/Host 是否生效)。 `source`:`tcp` = 用了直连对端(代理头未被采信)/ `x-forwarded-for` / `x-real-ip`; `via_proxy` = 直连对端命中 `trusted_proxies`;`trusted_proxies`/`trust_proxy_headers` 是当前生效的配置;`ignored_reason` 说明为什么没用代理头(没开、没配、对端不可信)。 """ nets = proxy.trusted_networks(cfg) enabled = bool(cfg.get("trust_proxy_headers")) if not enabled: reason = "trust_proxy_headers=false(默认):代理头一律不采信" elif not nets: reason = "trusted_proxies 为空:没有可信代理,代理头一律不采信" elif not req.via_proxy: reason = f"直连对端 {req.peer_ip or '未知'} 不在 trusted_proxies 里:代理头一律不采信" else: reason = "" # 宿主判定(双站点反代隔离用):与守卫里**同一套**口径(proxy.effective_host), # 排查"为什么媒体站/API 站互相 404"时对照这两个布尔值即可。 media_hosts = proxy.normalize_hosts(cfg.get("media_hosts")) api_hosts = proxy.normalize_hosts(cfg.get("api_hosts")) host, host_source = proxy.effective_host(req) return web.json_response({ "ok": True, "client_ip": req.client_ip, "peer_ip": req.peer_ip, "via_proxy": bool(req.via_proxy), "source": req.proxy_source or "tcp", "trusted": bool(req.via_proxy), "ignored_reason": reason, "x_forwarded_for": req.forwarded_for, "x_real_ip": req.header("x-real-ip"), "x_forwarded_proto": req.forwarded_proto, "x_forwarded_host": req.forwarded_host, "host": host, "host_source": host_source, "media_host_match": proxy.host_matches(host, media_hosts), "api_host_match": proxy.host_matches(host, api_hosts), "media_hosts": media_hosts, "api_hosts": api_hosts, "trust_proxy_headers": enabled, "trusted_proxies": [str(n) for n in nets], }) @r.get("/") def root(req: web.Request): st = store.stats() return web.html_response( f"

麦动KTV 自建歌曲源(服务端口)

" f"

曲库 {st['songs']} 首,已就绪 {st['done']} 首,待下载 {st['pending']} 首。

" f"

管理界面请访问管理端口。

") # App 点歌推送(POST /push_download + GET /push_download/status)—— 见 ktv/pushdl.py for route in pushdl.build_router(cfg, store, downloader).routes: r.routes.append(route) # App 令牌(GET/POST /token + GET /token/status)—— 见 ktv/apptoken.py for route in apptoken.build_router(cfg, store).routes: r.routes.append(route) # **前置守卫**(路由匹配之前):双站点宿主隔离 + App 令牌校验。 # 放在这里(而不是 web.py)是因为它需要 cfg/store,且必须在**直接 dispatch** 的 # 单测里也生效 —— 守卫写在 Router 上,走 HTTP 与走 router.dispatch 行为完全一致。 r.guard = apptoken.make_guard(cfg, store) return r