合并自建歌曲源支线(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:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""接口级自测:直接调用路由处理函数(**不监听端口、不启动服务**),验证 App 契约。
覆盖:
* POST /api.php → status=success,且 trial_times=0(App 不会因额度限制源数量)
* GET /i.php → code=200 + token
* GET /music/do.php → 已就绪:code=200 且 data 以 /<编号>.ts 结尾 + 域名(非裸 IP)
未下载:code=404(**绝不能用 403**——那会被 App 当成源站风控)
* GET /media/<编号>.ts → 200 与 Range/206
"""
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ktv import service
from ktv.config import Config
from ktv.store import Store
from ktv.web import Request
def req(method, path, query=None, body=b"", headers=None):
return Request(method, path, query or {}, headers or {}, body)
class TestAppContract(unittest.TestCase):
def tearDown(self):
# 显式关连接:否则 ResourceWarning(unclosed database)会污染验收输出
try:
self.store.conn.close()
except Exception:
pass
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.media = os.path.join(self.tmp, "media")
os.makedirs(os.path.join(self.media, "tmp"), exist_ok=True)
self.cfg.update({"public_media_base": "http://ktv.example.com:6677/media",
"media_dir": self.media, "data_dir": self.tmp})
self.store = Store(os.path.join(self.tmp, "t.db"))
self.store.import_library([(7009126, "一帘好梦", "国语", "", "胡云鹏", "7009126.ts", 1060, "")],
"test")
self.router = service.build_router(self.cfg, self.store, None)
def call(self, r):
return self.router.dispatch(r)
def test_api_php(self):
resp = self.call(req("POST", "/api.php", {"action": "get_config"}, b"action=get_config&device_id=abc"))
self.assertEqual(resp.status, 200)
import json
data = json.loads(resp.body)
self.assertEqual(data["status"], "success")
self.assertEqual(data["config"]["trial_times"], 0)
self.assertFalse(data["config"]["network_verification"])
def test_i_php(self):
import json
resp = self.call(req("GET", "/i.php", {"appid": "x", "mac": "800b510bf4fd_2d7bb4a7d7f7"}))
data = json.loads(resp.body)
self.assertEqual(data["code"], 200)
self.assertTrue(data["token"])
def test_do_php_ready(self):
import json
# 先登记为已下载完成
self.store.enqueue([7009126])
self.store.claim_one(7009126)
self.store.finish(7009126, True, bytes_=7 << 20, path=os.path.join(self.media, "7009126.ts"))
resp = self.call(req("GET", "/music/do.php", {"musicno": "7009126", "ls": "1", "resolution": "720"}))
data = json.loads(resp.body)
self.assertEqual(data["code"], 200)
self.assertEqual(data["msg"], "SUCCESS")
url = data["data"]
self.assertTrue(url.endswith("/media/7009126.ts"), url)
self.assertFalse(url.split("//", 1)[1].split("/")[0].replace(":", "").replace(".", "").isdigit(),
"媒体地址不能是裸 IP(App 会拒收)")
def test_do_php_not_ready_returns_404_not_403(self):
import json
resp = self.call(req("GET", "/music/do.php", {"musicno": "7009126"}))
data = json.loads(resp.body)
self.assertEqual(data["code"], 404) # 关键:不能是 403(403=源站风控语义)
self.assertNotEqual(data["code"], 403)
def test_do_php_unknown_tid(self):
import json
resp = self.call(req("GET", "/music/do.php", {"musicno": "9999999"}))
self.assertEqual(json.loads(resp.body)["code"], 404)
def test_media_range(self):
path = os.path.join(self.media, "7009126.ts")
with open(path, "wb") as fh:
fh.write(b"\x47" + b"0" * 375 + b"\x47" + b"0" * 100)
full = self.call(req("GET", "/media/7009126.ts"))
self.assertEqual(full.status, 200)
self.assertEqual(full.headers.get("Accept-Ranges"), "bytes")
# 媒体改为**流式发送**:断言长度/偏移字段而不是 body(避免 75MB 整块读进内存)
part = self.call(req("GET", "/media/7009126.ts", headers={"range": "bytes=0-99"}))
self.assertEqual(part.status, 206)
self.assertEqual(part.file_len, 100)
self.assertEqual(part.file_start, 0)
self.assertTrue(part.file_path.endswith("7009126.ts"))
self.assertIn("bytes 0-99/", part.headers.get("Content-Range", ""))
# 请求头必须大小写不敏感(否则 App 的 Range 断点续传会退化成全量下载)
upper = self.call(req("GET", "/media/7009126.ts", headers={"Range": "bytes=10-19"}))
self.assertEqual(upper.status, 206)
self.assertEqual(upper.file_start, 10)
self.assertEqual(upper.file_len, 10)
def test_media_missing(self):
self.assertEqual(self.call(req("GET", "/media/1234567.ts")).status, 404)
class TestAdminContract(unittest.TestCase):
def tearDown(self):
# 显式关连接:否则 ResourceWarning(unclosed database)会污染验收输出
try:
self.store.conn.close()
except Exception:
pass
def setUp(self):
from ktv import admin
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.cfg.update({"media_dir": os.path.join(self.tmp, "media"), "data_dir": self.tmp})
self.store = Store(os.path.join(self.tmp, "t.db"))
self.store.import_library([(1, "A", "国语", "", "s", "1.ts", 5, ""),
(2, "B", "粤语", "", "s", "2.ts", 9, "")], "v")
self.router = admin.build_router(self.cfg, self.store, None)
def test_stats(self):
import json
d = json.loads(self.router.dispatch(req("GET", "/api/stats")).body)
self.assertEqual(d["songs"], 2)
def test_songs_list_and_filter(self):
import json
d = json.loads(self.router.dispatch(req("GET", "/api/songs", {"order": "tid", "desc": "0"})).body)
self.assertEqual(d["total"], 2)
self.assertEqual(d["items"][0]["tid"], 1)
d2 = json.loads(self.router.dispatch(req("GET", "/api/songs", {"lang": "粤语"})).body)
self.assertEqual(d2["total"], 1)
def test_enqueue_rejects_unknown(self):
import json
r = json.loads(self.router.dispatch(req("POST", "/api/download/enqueue")).body)
self.assertFalse(r["ok"])
def test_settings_concurrency_cap(self):
import json
d = json.loads(self.router.dispatch(
req("POST", "/api/settings", body=b'{"concurrency": 50}')).body)
self.assertTrue(d["ok"])
self.assertEqual(d["config"]["concurrency"], 10)
self.assertTrue(any("10" in w for w in d["warnings"]))
if __name__ == "__main__":
unittest.main(verbosity=2)