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

323 lines
16 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
"""纯逻辑自测(不启动任何网络服务):DER/RSA、可信地址闸门、下载登记去重、配置校验。
python3 -m unittest discover -s tests -v
或 python3 tests/test_basic.py
"""
import contextlib
import os
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ktv import musesync, resolver
from ktv.config import Config
from ktv.store import Store
class TestRsa(unittest.TestCase):
def test_der_extracts_rsa_numbers(self):
n, e = musesync.rsa_public_numbers()
self.assertEqual(e, 65537)
# 2048 位密钥
self.assertTrue(2 ** 2047 < n < 2 ** 2048)
def test_pkcs1v15_layout(self):
n, e = musesync.rsa_public_numbers()
k = (n.bit_length() + 7) // 8
msg = b'{"device_id":"s00e04b021b2d"}'
ct = musesync.rsa_pkcs1v15_encrypt(msg, n, e)
self.assertEqual(len(ct), k)
# 无法解密(没有私钥),但可验证密文长度与 base64 往返
import base64
blob = musesync.encrypt_device_id("s00e04b021b2d")
self.assertEqual(len(base64.b64decode(blob)), k)
def test_random_padding_differs(self):
n, e = musesync.rsa_public_numbers()
a = musesync.rsa_pkcs1v15_encrypt(b"x", n, e)
b = musesync.rsa_pkcs1v15_encrypt(b"x", n, e)
self.assertNotEqual(a, b) # PKCS#1 v1.5 随机填充
class TestGate(unittest.TestCase):
"""可信地址闸门:必须与 App 的 isDemoUrl / isValidSongUrl 一致。"""
def test_accepts_real_cdn(self):
ok, _ = resolver.is_trusted_url(
"http://txog.ktvsky.com/264/lvbr/480/1735508576056/E/ts/83.1/202408120244/480p/7009126.ts?token=x", 7009126)
self.assertTrue(ok)
def test_rejects_bare_ip(self):
ok, why = resolver.is_trusted_url("http://1.2.3.4/media/7009126.ts", 7009126)
self.assertFalse(ok)
self.assertIn("裸 IP", why)
def test_rejects_wrong_filename(self):
ok, _ = resolver.is_trusted_url("http://cdn.example.com/x/719.ts", 7009126)
self.assertFalse(ok)
def test_rejects_ad_hosts_and_markers(self):
for url in ("http://gz.ac16.vip/ad_files/my_ad_video.ts",
"https://wb66.cn/f/d/7b7c9/10188555.ts",
"https://pub-abc123.r2.dev/ss/demo.ts"):
ok, _ = resolver.is_trusted_url(url, 7009126)
self.assertFalse(ok, url)
def test_accepts_own_domain(self):
ok, _ = resolver.is_trusted_url("http://ktv.example.com:6677/media/7009126.ts", 7009126)
self.assertTrue(ok)
class TestQueryStringGate(unittest.TestCase):
"""⚠️ 带 query 的媒体地址(2026-10-11 抓包形态)必须判为合法。
真源地址恒为 `…/<编号>.ts?token=…&t=…`(`txog.ktvsky.com` / `download.origjoy.com`)。
这里锁住三件事:
1. `.ts` 文件名比对**先剥离 query/fragment**(否则要么误杀真源、要么漏放广告);
2. 广告黑名单(wb66/demo/ac16/ad_files/r2.dev)**在带 query 时依然生效**;
3. query 里的随机串(如 `note=demo`)**不再误杀**合法地址 —— 只在 host+path 上判特征。
"""
HAR_URL = ("http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/202309070206/480p/"
"7200088.ts?token=432fe2b3345588df379f5e2da3100617&t=1791706901")
def test_accepts_exact_har_url(self):
"""D:新返回域名 txog.ktvsky.com + query 串 → 必须通过(我们没有域名白名单,只有黑名单)。"""
ok, why = resolver.is_trusted_url(self.HAR_URL, 7200088)
self.assertTrue(ok, why)
self.assertEqual(resolver.ts_basename(self.HAR_URL), "7200088")
def test_accepts_origjoy_url_with_query(self):
ok, _ = resolver.is_trusted_url(
"http://download.origjoy.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7445418.ts"
"?token=abc&t=1791706901", 7445418)
self.assertTrue(ok)
def test_filename_check_works_with_query_and_fragment(self):
"""带 query/fragment 时文件名不符**必须**被拦(旧实现在 `#` 上是漏放的)。"""
for url in ("http://cdn.example.com/x/9999999.ts?token=x",
"http://cdn.example.com/x/9999999.ts#frag",
"http://cdn.example.com/x/9999999.ts?token=x&t=1#frag"):
ok, why = resolver.is_trusted_url(url, 7200088)
self.assertFalse(ok, url)
self.assertIn("9999999", why)
def test_matching_name_with_query_or_fragment_is_ok(self):
for url in ("http://cdn.example.com/x/7200088.ts?token=x&t=1",
"http://cdn.example.com/x/7200088.ts#frag",
"http://cdn.example.com/x/7200088.ts?a=1#frag"):
ok, why = resolver.is_trusted_url(url, 7200088)
self.assertTrue(ok, f"{url} {why}")
def test_ad_targets_still_rejected_with_query(self):
"""C:带 query 时黑名单依旧生效(wb66 / demo / ac16 / ad_files / r2.dev)。"""
for url, tid in (("https://wb66.cn/f/d/7b7c9/10188555.ts?token=x", 7200088),
("https://wb66.cn/f/d/7b7c9/7200088.ts?token=x", 7200088),
("http://demo.example.com/x/7200088.ts?token=x", 7200088),
("http://gz.ac16.vip/ad_files/my_ad_video.ts?token=x", 7200088),
("http://cdn.ac16.vip/x/7200088.ts?token=x", 7200088),
("https://pub-abc123.r2.dev/ss/7200088.ts?token=x", 7200088)):
ok, why = resolver.is_trusted_url(url, tid)
self.assertFalse(ok, f"{url} → {why}")
def test_query_value_with_demo_word_is_not_a_false_reject(self):
"""query 里的随机串(签名/参数)不参与广告特征匹配 —— 只判 host+path。"""
ok, why = resolver.is_trusted_url(
"http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/x/480p/7200088.ts?note=demo&token=wb66",
7200088)
self.assertTrue(ok, why)
class TestCanonicalSign(unittest.TestCase):
"""A:用**现实现的拼串**复算 2026-10-11 抓包的 sign(逐字节相等 = 取歌参数没变)。
抓包(Reqable,`zk.ktv1.top:6677`):
* `GET /i.php?appid=…&mac=ae60e9547958_2d7bb4a7d7f8&sn=2d7bb4a7d7f8&time=1791702376
&ver=2.0&vn=4.1.3.03281430&sign=5ff87db502fb1d4f5336a190210722d6`
* `GET /music/do.php?appid=…&device=ae60e9547958_2d7bb4a7d7f8&ish265=0&ls=1&musicno=7200088
&resolution=720&sn=2d7bb4a7d7f8&time=1791702377&token=NjI1…&sign=4522dcbe72c0f583029f6f1b6fa7850b`
"""
HAR_APPID = "d4eeacc6cec3434fbc8c41608a3056a0"
HAR_MAC = "ae60e9547958"
HAR_SN = "2d7bb4a7d7f8"
HAR_TOKEN = "NjI1YjUyY2ZmYzExYjhjNDI2MjVkMDQwMzI4MmJjZGU="
HAR_I_TIME = 1791702376
HAR_D_TIME = 1791702377
HAR_I_SIGN = "5ff87db502fb1d4f5336a190210722d6"
HAR_D_SIGN = "4522dcbe72c0f583029f6f1b6fa7850b"
HAR_I_PARAMS = ("appid=d4eeacc6cec3434fbc8c41608a3056a0&mac=ae60e9547958_2d7bb4a7d7f8"
"&sn=2d7bb4a7d7f8&time=1791702376&ver=2.0&vn=4.1.3.03281430")
HAR_D_PARAMS = ("appid=d4eeacc6cec3434fbc8c41608a3056a0&device=ae60e9547958_2d7bb4a7d7f8"
"&ish265=0&ls=1&musicno=7200088&resolution=720&sn=2d7bb4a7d7f8"
"&time=1791702377&token=NjI1YjUyY2ZmYzExYjhjNDI2MjVkMDQwMzI4MmJjZGU=")
HAR_I_URL = "http://zk.ktv1.top:6677/i.php?" + "appid=d4eeacc6cec3434fbc8c41608a3056a0&mac=ae60e9547958_2d7bb4a7d7f8&sn=2d7bb4a7d7f8&time=1791702376&ver=2.0&vn=4.1.3.03281430&sign=5ff87db502fb1d4f5336a190210722d6"
HAR_D_URL = ("http://zk.ktv1.top:6677/music/do.php?appid=d4eeacc6cec3434fbc8c41608a3056a0"
"&device=ae60e9547958_2d7bb4a7d7f8&ish265=0&ls=1&musicno=7200088&resolution=720"
"&sn=2d7bb4a7d7f8&time=1791702377"
"&token=NjI1YjUyY2ZmYzExYjhjNDI2MjVkMDQwMzI4MmJjZGU="
"&sign=4522dcbe72c0f583029f6f1b6fa7850b")
def test_keys_unchanged(self):
self.assertEqual(resolver.APP_ID, self.HAR_APPID)
self.assertEqual(resolver.APP_KEY, "024210cba40d4385a93e6c2d3249bfb5")
self.assertEqual(resolver.SDK_KEY, "19042303a8374f67ae3fe1e25c97936f")
self.assertEqual(resolver.VER, "2.0")
self.assertEqual(resolver.VN, "4.1.3.03281430")
def test_token_params_and_sign_match_har_byte_for_byte(self):
params = resolver.token_params(self.HAR_MAC, self.HAR_SN, ts=self.HAR_I_TIME)
self.assertEqual(params, self.HAR_I_PARAMS)
self.assertEqual(resolver.token_sign(params), self.HAR_I_SIGN)
self.assertEqual(resolver.md5(params + resolver.APP_KEY), self.HAR_I_SIGN)
def test_song_params_and_sign_match_har_byte_for_byte(self):
params = resolver.song_params(7200088, self.HAR_TOKEN, self.HAR_MAC, self.HAR_SN,
"720", False, ts=self.HAR_D_TIME)
self.assertEqual(params, self.HAR_D_PARAMS)
self.assertEqual(resolver.song_sign(params), self.HAR_D_SIGN)
# token 里的 `=` 必须原样参与签名(不能再编码)
self.assertIn("token=" + self.HAR_TOKEN, params)
def test_live_get_token_builds_exact_har_url(self):
"""**现网代码路径**(get_token)在抓包时间戳下必须产出与 HAR 完全一致的 URL。"""
seen: dict = {}
with self._capture(seen), mock.patch.object(resolver.time, "time", return_value=self.HAR_I_TIME):
token = resolver.get_token("http://zk.ktv1.top:6677", self.HAR_MAC, self.HAR_SN)
self.assertEqual(token, self.HAR_TOKEN)
self.assertEqual(seen["url"], self.HAR_I_URL)
def test_live_get_song_url_builds_exact_har_url(self):
seen: dict = {}
with self._capture(seen), mock.patch.object(resolver.time, "time", return_value=self.HAR_D_TIME):
url, detail = resolver.get_song_url("http://zk.ktv1.top:6677", 7200088, self.HAR_TOKEN,
self.HAR_MAC, self.HAR_SN, "720")
self.assertEqual(seen["url"], self.HAR_D_URL)
self.assertEqual(detail, "ok")
self.assertEqual(url, "http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/202309070206/"
"480p/7200088.ts?token=432fe2b3345588df379f5e2da3100617&t=1791706901")
# ── 辅助 ──
@contextlib.contextmanager
def _capture(self, seen: dict):
"""把 `_get` 换成"记录 URL 并返回抓包响应"的假实现(不联网、不限速)。"""
def fake_get(url, *args, **kwargs):
seen["url"] = url
if "/i.php" in url:
return 200, ('{"code":200,"end_date":"2026-10-05 14:58:21","ordertype":0,'
'"online_state":0,"token":"NjI1YjUyY2ZmYzExYjhjNDI2MjVkMDQwMzI4MmJjZGU=",'
'"retry":5,"msg":"ok","heart_time":5400}')
return 200, ('{"msg":"SUCCESS","data":"http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/'
'202309070206/480p/7200088.ts?token=432fe2b3345588df379f5e2da3100617'
'&t=1791706901","code":200,"limit":2000}')
with mock.patch.object(resolver, "_get", fake_get):
yield
class TestStoreDedupe(unittest.TestCase):
"""下载登记与去重:这是"避免重复下载"的核心。"""
def tearDown(self):
# 显式关连接:否则 ResourceWarning(unclosed database)会污染验收输出
try:
self.store.conn.close()
except Exception:
pass
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.store = Store(os.path.join(self.tmp, "t.db"))
rows = [(7009126, "一帘好梦", "国语", "", "胡云鹏", "7009126.ts", 1060, ""),
(7303235, "测试歌", "粤语", "", "某人", "7303235.ts", 500, "")]
self.store.import_library(rows, "test-version")
def test_claim_is_atomic(self):
self.store.enqueue([7009126])
self.assertTrue(self.store.claim_one(7009126, backoff_sec=0)) # 第一次抢到
self.assertFalse(self.store.claim_one(7009126, backoff_sec=0)) # 第二次抢不到(已在下载)
self.store.finish(7009126, True, bytes_=123, path="/tmp/x.ts")
self.assertFalse(self.store.claim_one(7009126, backoff_sec=0)) # 已完成,永不重复下载
def test_enqueue_skips_done(self):
self.store.enqueue([7009126])
self.store.claim_one(7009126, backoff_sec=0)
self.store.finish(7009126, True, bytes_=999)
res = self.store.enqueue([7009126])
self.assertEqual(res["skipped_done"], 1)
self.assertEqual(res["added"], 0)
def test_enqueue_unknown_tid(self):
res = self.store.enqueue([9999999])
self.assertEqual(res["skipped_unknown"], 1)
def test_stats_and_states(self):
self.store.enqueue([7009126, 7303235])
self.assertEqual(self.store.stats()["pending"], 2)
self.store.claim_one(7009126)
self.store.finish(7009126, False, error="取址失败")
self.assertEqual(self.store.stats()["pending"], 2) # 失败后回到 pending(还有重试)
# 显式 backoff=0:验证"达到重试上限后落到 failed"(退避行为由 test_tasks 覆盖)
for _ in range(5):
if self.store.claim_one(7009126, max_attempts=3, backoff_sec=0):
self.store.finish(7009126, False, error="x")
self.assertEqual(self.store.stats()["failed"], 1)
def test_media_scan_requeues_missing_and_adopts(self):
"""媒体目录扫描(唯一的一致性入口):done 但文件缺失 → 回队;磁盘上有文件 → 认领。"""
media = os.path.join(self.tmp, "media")
os.makedirs(media, exist_ok=True)
big = os.urandom(1024)
# 歌曲表里登记 done 但文件不存在 → 回退为待下载
self.store.keep_add([7009126])
self.store.keep_mark_done(7009126, len(big), os.path.join(media, "7009126.ts"))
res = self.store.keep_adopt_media(media, min_bytes=1, timeout_sec=5)
self.assertEqual(res["requeued"], 1)
self.assertEqual(self.store.q1("SELECT state FROM keeps WHERE tid=7009126")["state"], "want")
# 磁盘上有文件但歌曲表里还没下完 → 认领为 done(不再重复下载)
with open(os.path.join(media, "7303235.ts"), "wb") as fh:
fh.write(big)
self.store.keep_add([7303235])
res = self.store.keep_adopt_media(media, min_bytes=1, timeout_sec=5)
self.assertEqual(res["updated"], 1)
self.assertEqual(self.store.q1("SELECT state FROM keeps WHERE tid=7303235")["state"], "done")
class TestConfig(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
def test_concurrency_capped_at_10(self):
warns = self.cfg.update({"concurrency": 99})
self.assertEqual(self.cfg.get("concurrency"), 10)
self.assertTrue(any("10" in w for w in warns))
def test_warns_on_bare_ip_media_base(self):
warns = self.cfg.update({"public_media_base": "http://1.2.3.4/media"})
self.assertTrue(any("裸 IP" in w for w in warns))
def test_hosts_string_is_normalised(self):
self.cfg.update({"upstream_hosts": "http://a.example.com:6677, http://b.example.com"})
self.assertEqual(self.cfg.get("upstream_hosts"),
["http://a.example.com:6677", "http://b.example.com"])
def test_media_url(self):
self.cfg.update({"public_media_base": "http://ktv.example.com:6677/media"})
self.assertEqual(self.cfg.media_public_url(7009126), "http://ktv.example.com:6677/media/7009126.ts")
class TestLangNorm(unittest.TestCase):
def test_norm(self):
self.assertEqual(musesync.norm_lang("闽南"), "闽南语")
self.assertEqual(musesync.norm_lang("国语"), "国语")
self.assertEqual(musesync.norm_lang("夏天"), "其它")
self.assertEqual(musesync.norm_lang(""), "未标注")
if __name__ == "__main__":
unittest.main(verbosity=2)