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

185 lines
9.5 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 请求接口**(与真实歌曲源同协议)+ 媒体分发 + 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