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

373 lines
21 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
"""歌曲表 CSV 导入的**编码 / 分隔符 / 表头 / 编号**容错测试(纯内存字节夹具,不联网、不碰真实数据)。
背景(真实用户事故):用户把服务器导出的 `keeps.csv` 用 **Office 2019 中文版**编辑后另存,
编码变成 GB18030(无 BOM)、分隔符从逗号变成 TAB、行尾 CRLF;导入端当时固定按
`utf-8-sig + 逗号` 解析 → 整行被当成一个字段、中文全乱码,这就是用户看到的"表格错乱"。
本文件用**用户那份文件的真实字节**钉死修复(base64 内嵌,逐字节比对见
`KEEPS_GBK_TAB_B64`):不经任何手工转换也必须能直接导入。
覆盖点:
1) GB18030 + TAB + CRLF + 无 BOM(用户真实字节);
2) 分号 `;` + UTF-8 无 BOM;
3) UTF-16(带 BOM,LE/BE)+ TAB;
4) 没有表头(首行就是编号);
5) 带引号字段里内嵌分隔符 / 换行;
6) 科学计数法 / 非整数编号 → 进 `errors` 且有中文提示,其它行照常导入;
7) 空文件 / 只有表头 → merge 不报错、replace 返回 `replace_cancelled=True`;
8) 表头别名(编号/tid/TID/ID/id/歌曲编号、全半角、空白、BOM、零宽字符);
9) HTTP 层 `/api/keeps/import` 直接 POST 原始 GBK 字节也能导入(**不能在 HTTP 层丢字节**)。
"""
import base64
import codecs
import json
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 admin
from ktv.config import Config
from ktv.store import CSV_TID_EXCEL_HINT, Store, decode_csv_bytes, detect_csv_delimiter, parse_keep_tid
from ktv.web import Request
# ★ 用户上传的那份 keeps.csv 的真实字节(224 字节):
# GB18030/GBK 无 BOM + TAB 分隔 + CRLF 行尾,4 个编号 4100081 / 4100293 / 9999999 / 8897358。
KEEPS_GBK_TAB_B64 = (
"seC6xQm46MP7CdPv0dQJ16i8rQm46MrWCc7EvP7D+wnIyLbICc/C1NjXtMysCdfWvdoNCjQxMDAw"
"ODEJucPE78Tj1NrO0tDEyc8JufrT7wkJzfu6o7jfuOgJNDEwMDA4MS50cwkyMTQ3NDgzNjQ3CbT9"
"z8LU2AkwDQo0MTAwMjkzCcHos78JufrT7wkJzfu6o7jfuOgJNDEwMDI5My50cwkyMTQ3NDgzNjQ3"
"CbT9z8LU2AkwDQo5OTk5OTk5CQkJCQkJMAm0/c/C1NgJMA0KODg5NzM1OAkJCQkJCQkJDQo="
)
# 原始附件位置:本机还在时逐字节比对(证明内嵌夹具就是用户那份文件,不在时跳过)
REAL_ATTACHMENT = ("/home/zijun/.dsh/attachments/v1/files/15/"
"152b1268054c52292a943bc962ca829f602ae9b99c8ac6b07fd202b5490a8c37/keeps (1).csv")
def req(method, path, query=None, body=b""):
return Request(method, path, query or {}, {}, body)
def jbody(data: dict) -> bytes:
return json.dumps(data, ensure_ascii=False).encode()
class ImportCase(unittest.TestCase):
"""公共夹具:空库 + 一个进程内 SQLite(全在临时目录,不碰真实数据目录)。"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.cfg.update({"data_dir": self.tmp, "media_dir": os.path.join(self.tmp, "media"),
"tmp_dir": os.path.join(self.tmp, "t")})
self.store = Store(os.path.join(self.tmp, "t.db"))
def tearDown(self):
try:
self.store.conn.close()
except Exception:
pass
def lib(self, *specs):
"""往曲库放几首((编号, 歌名, 语言, 歌手, 热度)),用于验证导入后字段能自动补全。"""
self.store.import_library([(tid, name, lang, "", singers, f"{tid}.ts", hot, "")
for tid, name, lang, singers, hot in specs], "v1")
def tids(self) -> list[int]:
return sorted(r["tid"] for r in self.store.keep_rows(page_size=100)["items"])
def keeps_state(self, tid: int) -> str:
row = self.store.q1("SELECT state FROM keeps WHERE tid=?", (tid,))
return row["state"] if row else ""
def note_of(self, tid: int) -> str:
return self.store.q1("SELECT note FROM keeps WHERE tid=?", (tid,))["note"]
class TestUserExcelFile(ImportCase):
"""1) 用户那份文件的**真实字节**:GB18030 + TAB + CRLF + 无 BOM。"""
def test_fixture_matches_real_attachment_when_present(self):
raw = base64.b64decode(KEEPS_GBK_TAB_B64)
self.assertEqual(len(raw), 224)
self.assertFalse(raw.startswith(codecs.BOM_UTF8), "用户文件没有 BOM")
with self.assertRaises(UnicodeDecodeError):
raw.decode("utf-8") # 严格 UTF-8 解不了 → 必须回退 gb18030
self.assertEqual((raw[0:4], b"\t" in raw, b"\r\n" in raw), (b"\xb1\xe0\xba\xc5", True, True))
if os.path.isfile(REAL_ATTACHMENT): # 本机留着原始附件 → 逐字节比对
with open(REAL_ATTACHMENT, "rb") as fh:
self.assertEqual(fh.read(), raw, "内嵌夹具必须与用户上传的原始文件完全一致")
def test_gbk_tab_crlf_imports_without_manual_conversion(self):
raw = base64.b64decode(KEEPS_GBK_TAB_B64)
# 编码识别:中文歌名/歌手要从 GBK 字节里正确解出来(不是 U+FFFD)
text, enc = decode_csv_bytes(raw)
self.assertEqual(enc, "gb18030")
self.assertIn("姑娘你在我心上", text)
self.assertIn("望海高歌", text)
self.assertNotIn("\ufffd", text)
self.assertEqual(detect_csv_delimiter(text), "\t")
# 导入:4 个编号全在,没有跳过、没有错误
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual(res["detected"], {"encoding": "gb18030", "delimiter": "\t"})
self.assertEqual((res["updated"], res["skipped"], res["errors"], res["replace_cancelled"]),
(4, 0, [], False))
self.assertEqual(self.tids(), [4100081, 4100293, 8897358, 9999999])
for tid in (4100081, 4100293, 9999999, 8897358):
self.assertEqual(self.keeps_state(tid), "want") # 「待下载」/空状态都按待下载
# 曲库有这两个编号时,中文歌名立刻补全(证明 TAB 被正确切列、GBK 被正确解码)
self.lib((4100081, "姑娘你在我心上", "国语", "望海高歌", 2147483647),
(4100293, "凌晨", "国语", "望海高歌", 2147483647))
items = {r["tid"]: r for r in self.store.keep_rows(page_size=100)["items"]}
self.assertEqual(items[4100081]["name"], "姑娘你在我心上")
self.assertEqual(items[4100081]["singers"], "望海高歌")
self.assertEqual(items[4100081]["lang"], "国语")
self.assertEqual(items[4100293]["name"], "凌晨")
self.assertIs(items[4100081]["matched"], True)
self.assertIs(items[9999999]["matched"], False)
# 导出格式**保持不变**(utf-8-sig + 逗号由 HTTP 层负责;这里是逗号 + 中文表头)
self.assertEqual(self.store.keep_csv().splitlines()[0],
"编号,歌名,语言,专辑,歌手,文件名,热度,下载状态,字节")
def test_admin_endpoint_keeps_raw_bytes(self):
"""★ 回归:HTTP 层若先按 UTF-8 `errors='replace'` 解码,GBK 字节就永久丢了。"""
router = admin.build_router(self.cfg, self.store, None)
raw = base64.b64decode(KEEPS_GBK_TAB_B64)
resp = router.dispatch(req("POST", "/api/keeps/import", {"mode": "merge"}, raw))
d = json.loads(resp.body)
self.assertTrue(d["ok"])
self.assertEqual(d["detected"], {"encoding": "gb18030", "delimiter": "\t"})
self.assertEqual((d["updated"], d["errors"]), (4, []))
self.assertEqual(d["stats"]["total"], 4)
# JSON 信封(管理台/脚本)仍然可用,返回里同样带 detected
d = json.loads(router.dispatch(req("POST", "/api/keeps/import", {"mode": "replace"},
jbody({"text": "编号\n7\n", "mode": "replace"}))).body)
self.assertEqual((d["mode"], d["removed"], d["updated"]), ("replace", 4, 1))
self.assertEqual(d["detected"], {"encoding": "utf-8", "delimiter": ","})
class TestDelimiterAndEncoding(ImportCase):
"""2)3) 分隔符与编码的其它组合。"""
def test_semicolon_utf8_without_bom(self):
raw = "编号;下载状态\n1;待下载\n2;已下\n".encode("utf-8")
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual(res["detected"], {"encoding": "utf-8", "delimiter": ";"})
self.assertEqual((res["updated"], res["errors"]), (2, []))
self.assertEqual((self.keeps_state(1), self.keeps_state(2)), ("want", "done"))
def test_pipe_utf8_sig(self):
raw = "\ufeff编号|下载状态\r\n1|已下\r\n".encode("utf-8")
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual(res["detected"], {"encoding": "utf-8-sig", "delimiter": "|"})
self.assertEqual((res["updated"], self.keeps_state(1)), (1, "done"))
def test_utf16_tab_with_bom(self):
text = "编号\t下载状态\n1\t待下载\n2\t已下\n"
for enc, name in (("utf-16", "utf-16"), ("utf-16-le", "utf-16"), ("utf-16-be", "utf-16")):
with self.subTest(enc=enc):
self.store.ex("DELETE FROM keeps")
if enc == "utf-16":
raw = text.encode(enc) # 自带 BOM
elif enc == "utf-16-le":
raw = codecs.BOM_UTF16_LE + text.encode(enc)
else:
raw = codecs.BOM_UTF16_BE + text.encode(enc)
res = self.store.keep_import_csv(raw, mode="replace")
self.assertEqual(res["detected"], {"encoding": name, "delimiter": "\t"})
self.assertEqual(res["updated"], 2)
self.assertEqual(self.keeps_state(2), "done")
def test_gb18030_comma_and_gbk_semicolon(self):
res = self.store.keep_import_csv("编号,下载状态\n11,已下\n".encode("gb18030"), mode="merge")
self.assertEqual(res["detected"], {"encoding": "gb18030", "delimiter": ","})
res = self.store.keep_import_csv("编号;下载状态\n12;已下\n".encode("gbk"), mode="merge")
self.assertEqual(res["detected"], {"encoding": "gb18030", "delimiter": ";"})
self.assertEqual(sorted(self.tids()), [11, 12])
def test_line_endings_cr_lf_crlf(self):
for text in ("编号\n1\n2\n", "编号\r\n1\r\n2\r\n", "编号\r1\r2\r"):
with self.subTest(text=text[-6:]):
self.store.ex("DELETE FROM keeps")
self.assertEqual(self.store.keep_import_csv(text, mode="replace")["updated"], 2)
class TestHeaderAndTidTolerance(ImportCase):
"""4)8) 无表头 / 表头别名 / 不可见字符;编号的容错解析。"""
def test_no_header_first_row_is_tid(self):
res = self.store.keep_import_csv(b"1\n2\n3\n", mode="replace")
self.assertEqual((res["detected"]["delimiter"], res["updated"], res["errors"]), (",", 3, []))
self.assertEqual(self.tids(), [1, 2, 3])
# 无表头 + TAB + 多余列:第 1 列是编号,其余列忽略(无表头时无法知道哪列是备注)
self.store.ex("DELETE FROM keeps")
res = self.store.keep_import_csv("5\t备注A\t多余列\n6\t备注B\n".encode("utf-8"), mode="merge")
self.assertEqual((res["detected"]["delimiter"], res["updated"], res["errors"]), ("\t", 2, []))
self.assertEqual(self.tids(), [5, 6])
# 无表头 + 最前面留了空行:仍然要能认出来(Excel 另存偶尔会多一个空行)
self.store.ex("DELETE FROM keeps")
res = self.store.keep_import_csv(b"\n\n7\n8\n", mode="merge")
self.assertEqual((res["updated"], res["errors"]), (2, []))
self.assertEqual(self.tids(), [7, 8])
def test_no_header_with_decimal_tid(self):
res = self.store.keep_import_csv("4100081.0\n4.100293E+06\n".encode("utf-8"), mode="merge")
self.assertEqual((res["updated"], res["errors"]), (2, []))
self.assertEqual(self.tids(), [4100081, 4100293])
def test_header_aliases_and_invisible_chars(self):
for header in ("\ufeff编号", " 编号 ", "TID", "tid", "ID", "id", "歌曲编号",
"TID", "\u200b编号\u3000"):
with self.subTest(header=header):
self.store.ex("DELETE FROM keeps")
raw = f"{header},下载状态\n 1 ,待下载\n".encode("utf-8")
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual((res["updated"], res["errors"]), (1, []), f"表头 {header!r} 没认出来")
self.assertEqual(self.tids(), [1])
# 状态/备注/用户列的别名 + 字段前后空白与不可见字符清理
self.store.ex("DELETE FROM keeps")
raw = "编号,状态,备注,用户\n\u200b1\u3000,已下, 客户点的 ,\ufeff前台\n".encode("utf-8")
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual(res["errors"], [])
self.assertEqual((self.keeps_state(1), self.note_of(1)), ("done", "客户点的"))
self.assertEqual(self.store.q1("SELECT user FROM keeps WHERE tid=1")["user"], "前台")
def test_header_only_and_empty_file(self):
res = self.store.keep_import_csv(b"", mode="merge")
self.assertEqual(res["updated"], 0)
self.assertTrue(res["errors"])
res = self.store.keep_import_csv(b"\xef\xbb\xbf", mode="merge") # 只有 BOM
self.assertEqual(res["updated"], 0)
self.assertTrue(res["errors"])
res = self.store.keep_import_csv("编号\n", mode="replace") # 只有表头
self.assertTrue(res["replace_cancelled"])
self.assertEqual((res["updated"], res["removed"]), (0, 0))
def test_only_header_with_extra_columns_is_still_cancelled(self):
self.store.keep_add([1, 2])
res = self.store.keep_import_csv("编号\t歌名\t歌手\n".encode("gb18030"), mode="replace")
self.assertTrue(res["replace_cancelled"])
self.assertEqual(self.store.keep_stats()["total"], 2) # 一行都没丢
class TestQuotedFields(ImportCase):
"""5) 带引号字段里内嵌分隔符 / 换行。"""
def test_quoted_embedded_delimiter_and_newline(self):
raw = '编号,备注\n1,"含,逗号"\n2,"跨\n行备注"\n3,"含""引号"""\n'.encode("utf-8")
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual((res["detected"]["delimiter"], res["updated"], res["skipped"], res["errors"]),
(",", 3, 0, []))
self.assertEqual(self.tids(), [1, 2, 3])
self.assertEqual(self.note_of(1), "含,逗号")
self.assertEqual(self.note_of(2), "跨\n行备注")
self.assertEqual(self.note_of(3), '含"引号"')
def test_quoted_embedded_tab_when_delimiter_is_tab(self):
raw = '编号\t备注\n1\t"含\tTAB 的备注"\n'.encode("utf-8")
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual((res["detected"]["delimiter"], res["updated"]), ("\t", 1))
self.assertEqual(self.note_of(1), "含\tTAB 的备注")
class TestTidNumericTolerance(ImportCase):
"""6) 科学计数法 / 非整数编号:进 errors 且有中文提示,其它行照常导入。"""
def test_excel_numeric_rewrite(self):
raw = ("编号,下载状态\n"
"1.0,待下载\n" # 整数值 → 接受
"2,已下\n" # 纯数字 → 接受
"4.100081E+06,待下载\n" # 有效数字 7 位 → 精确整数,接受
"4.10008E+06,待下载\n" # Excel 按显示精度改写 → 拒绝 + 中文提示
"1.5,待下载\n" # 非整数 → 拒绝
"abc,待下载\n" # 文本 → 拒绝
"3,啥玩意\n").encode("utf-8") # 状态无法识别 → 拒绝
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual((res["updated"], res["skipped"]), (3, 4))
self.assertEqual(self.tids(), [1, 2, 4100081])
self.assertEqual((self.keeps_state(1), self.keeps_state(2)), ("want", "done"))
self.assertTrue(any(CSV_TID_EXCEL_HINT in e for e in res["errors"]),
f"缺少 Excel 改写的中文提示:{res['errors']}")
self.assertTrue(any("第 5 行" in e and "4.10008E+06" in e for e in res["errors"]))
self.assertTrue(any("第 6 行" in e and "不是整数" in e for e in res["errors"]))
self.assertTrue(any("第 7 行" in e and "不是数字" in e for e in res["errors"]))
self.assertTrue(any("第 8 行" in e and "状态无法识别" in e for e in res["errors"]))
# 提示里必须告诉用户怎么办(把该列设为"文本"后重新另存)
self.assertIn("文本", CSV_TID_EXCEL_HINT)
def test_error_list_is_capped_but_other_rows_still_import(self):
raw = ("编号\n" + "".join(f"x{i}\n" for i in range(20)) + "123\n").encode("utf-8")
res = self.store.keep_import_csv(raw, mode="merge")
self.assertEqual((res["updated"], res["skipped"]), (1, 20))
self.assertLessEqual(len(res["errors"]), 5)
def test_replace_refused_when_all_tids_unusable(self):
self.store.keep_add([1, 2])
res = self.store.keep_import_csv("编号\n4.10008E+06\n".encode("utf-8"), mode="replace")
self.assertTrue(res["replace_cancelled"])
self.assertTrue(any(CSV_TID_EXCEL_HINT in e for e in res["errors"]))
self.assertEqual(sorted(self.tids()), [1, 2]) # 旧行保留
class TestDetectionHelpers(unittest.TestCase):
"""识别函数本身的行为(模块级,便于单独回归)。"""
def test_decode_csv_bytes_strict_then_gb18030(self):
self.assertEqual(decode_csv_bytes("编号".encode("gb18030")), ("编号", "gb18030"))
self.assertEqual(decode_csv_bytes("编号".encode("utf-8")), ("编号", "utf-8"))
self.assertEqual(decode_csv_bytes("\ufeff编号".encode("utf-8")), ("编号", "utf-8-sig"))
self.assertEqual(decode_csv_bytes("编号".encode("utf-16")), ("编号", "utf-16"))
self.assertEqual(decode_csv_bytes(b""), ("", "utf-8"))
self.assertEqual(decode_csv_bytes(b"\xff\xfe\x00"), ("\ufffd", "utf-16")) # BOM + 残字节 → 兜底
def test_detect_csv_delimiter_defaults_to_comma(self):
self.assertEqual(detect_csv_delimiter("编号\n1\n"), ",")
self.assertEqual(detect_csv_delimiter(""), ",")
self.assertEqual(detect_csv_delimiter("编号;歌名;语言\n"), ";")
self.assertEqual(detect_csv_delimiter("编号\t歌名\n"), "\t")
self.assertEqual(detect_csv_delimiter("编号|歌名\n"), "|")
self.assertEqual(detect_csv_delimiter("\n\n编号,歌名;语言\n"), ",") # 跳过空行;并列取逗号
def test_parse_keep_tid(self):
self.assertEqual(parse_keep_tid("4100081"), (4100081, ""))
self.assertEqual(parse_keep_tid(" 4100081.0 "), (4100081, ""))
self.assertEqual(parse_keep_tid("4.100081E+06"), (4100081, ""))
self.assertEqual(parse_keep_tid("4100081e0"), (4100081, ""))
self.assertEqual(parse_keep_tid("4.10008E+06")[0], None)
self.assertEqual(parse_keep_tid("4.10008E+06")[1], CSV_TID_EXCEL_HINT)
self.assertEqual(parse_keep_tid("1.5")[0], None)
self.assertEqual(parse_keep_tid("abc")[0], None)
self.assertEqual(parse_keep_tid("")[0], None)
self.assertEqual(parse_keep_tid("nan")[0], None)
self.assertEqual(parse_keep_tid("inf")[0], None)
self.assertEqual(parse_keep_tid("\u200b100\u3000"), (100, "")) # 不可见字符
class TestUiImportHint(unittest.TestCase):
"""6) 前端提示:导入区说明支持的编码/分隔符;导入成功提示显示识别结果 + 上传原始字节。"""
def test_import_area_documents_encodings_and_delimiters(self):
from ktv import ui
self.assertIn("支持 UTF-8/GBK 等编码,逗号/分号/TAB 分隔均可(Excel 另存后可直接导入)", ui.PAGE)
def test_import_uploads_raw_bytes_and_shows_detected(self):
from ktv import ui
js = ui.PAGE
fn = js[js.index("async function importKeeps(el)"):]
fn = fn[:fn.index("// ── 下载队列 ──")]
code = "\n".join(l for l in fn.splitlines() if not l.strip().startswith("//")) # 去掉注释行
self.assertIn("f.arrayBuffer()", code) # ★ 必须传原始字节:f.text() 会按 UTF-8 吞掉 GBK 字节
self.assertNotIn("f.text()", code)
self.assertIn("raw:true", code)
self.assertIn("r.detected", fn)
self.assertIn("识别到编码", fn)
self.assertIn("raw === true", js) # api() 支持原始字节上传
self.assertIn("'text/csv'", js)
if __name__ == "__main__":
unittest.main(verbosity=2)