版本号:
- 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 例)
185 lines
9.5 KiB
Python
185 lines
9.5 KiB
Python
"""服务端口:**App 请求接口**(与真实歌曲源同协议)+ 媒体分发 + App 点歌推送。
|
||
|
||
POST /api.php action=get_config&device_id=<mac> → 配置(本服务器不限试用)
|
||
GET /i.php appid&mac=<mac>_<sn>&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/<tid>.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/<tid>.ts ──
|
||
@r.get(r"/media/(?P<tid>\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"<html><body style='font-family:sans-serif'><h3>麦动KTV 自建歌曲源(服务端口)</h3>"
|
||
f"<p>曲库 {st['songs']} 首,已就绪 {st['done']} 首,待下载 {st['pending']} 首。</p>"
|
||
f"<p>管理界面请访问管理端口。</p></body></html>")
|
||
|
||
# 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
|