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

165 lines
7.0 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.
#!/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)