合并自建歌曲源支线(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,720 @@
#!/usr/bin/env python3
"""2026 三项需求的专项测试(**不启动服务、不联网、不真睡 10 秒**):
需求① **下载队列失败任务自动重试**(间隔 10 分钟):
配置默认值、`FailedRetry` 跳过条件(人工暂停 / 水位不足 / 关闭 / N==0 不写日志)、
`force` 强制、`/api/download/status` 的 `auto_retry`、`/api/download/retry_failed_now`。
需求② **单曲取址之间 5~10 秒缓冲**:
默认 5000/10000、`_pace()` 实际等待 ≥ min(**虚拟时钟**,不真睡)、
i.php 与 do.php 都走同一个 `_pace()`、全局串行(多线程不叠加)、
一次性批量取址路径(`/api/upstream/probe`)不许绕过节奏。
需求③ **扫描媒体目录,发现新文件按文件名自动登记进歌曲表**:
合法且够大的 → 新增登记/更新;非法名/过小 → 跳过;**绝不删除任何文件**;
超时路径(打桩成"会睡很久的列举函数")→ `ok=false` 且状态未变、耗时受控;
接口 `/api/keeps/scan`、`/api/keeps/scan/status`、`/api/keeps/stats` 的 `scan` 字段。
另外用源码级守卫钉死"新的守护线程必须在端口监听之后启动"(main.py 的顺序)。
"""
import json
import os
import sys
import tempfile
import threading
import time
import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ktv import admin, main
from ktv import resolver as R
from ktv import store as store_mod
from ktv.config import Config
from ktv.downloader import Downloader, FailedRetry, StorageGuard
from ktv.store import Store
from ktv.web import Request
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()
def rows(*specs):
"""(tid, name, lang, singers, hot) → import_library 需要的一行。"""
return [(tid, name, lang, "", singers, f"{tid}.ts", hot, "")
for tid, name, lang, singers, hot in specs]
class _FakeStop:
"""假停止位:让调度器循环只跑一轮就退出(避免真的等 600 秒)。"""
def __init__(self):
self.rounds = 0
self.waits: list = []
def is_set(self) -> bool:
return self.rounds >= 1
def wait(self, timeout) -> bool:
self.waits.append(timeout)
self.rounds += 1
return False
class Case(unittest.TestCase):
"""公共夹具:临时数据/媒体目录 + 4 首小曲库(显式关连接,避免 ResourceWarning 污染输出)。"""
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")
self.cfg.update({"data_dir": self.tmp, "media_dir": self.media,
"tmp_dir": os.path.join(self.tmp, "t")})
os.makedirs(self.media, exist_ok=True)
self.store = Store(os.path.join(self.tmp, "t.db"))
self.store.import_library(rows((1, "A歌", "国语", "s1", 10),
(2, "B歌", "粤语", "s2", 9),
(3, "C歌", "英语", "s3", 8),
(4, "D歌", "国语", "s4", 7)), "v1")
def tearDown(self):
try:
self.store.conn.close()
except Exception:
pass
def dl_state(self, tid: int) -> str:
row = self.store.q1("SELECT state FROM downloads WHERE tid=?", (tid,))
return row["state"] if row else ""
# ──────────────────────────── 需求① 失败任务自动重试 ────────────────────────────
class TestRetryFailedConfig(Case):
"""配置项默认值与"沿用 max_attempts"的取值口径。"""
def test_defaults(self):
self.assertTrue(self.cfg.get("retry_failed_enabled"))
self.assertEqual(self.cfg.get("retry_failed_interval_sec"), 600) # 10 分钟
# 默认沿用 max_attempts(默认 3)
self.assertEqual(self.cfg.get("retry_failed_max_attempts"), self.cfg.get("max_attempts"))
self.assertEqual(self.cfg.retry_failed_max_attempts(), 3)
def test_zero_follows_max_attempts(self):
"""填 0 = 跟随 max_attempts 的当前值(改 max_attempts 立刻生效)。"""
self.cfg.update({"max_attempts": 7, "retry_failed_max_attempts": 0})
self.assertEqual(self.cfg.retry_failed_max_attempts(), 7)
def test_interval_is_clamped(self):
warns = self.cfg.update({"retry_failed_interval_sec": 5})
self.assertNotIn(self.cfg.get("retry_failed_interval_sec"), (0, 5))
self.assertGreaterEqual(self.cfg.get("retry_failed_interval_sec"), 60)
self.assertTrue(any("自动重试间隔" in w for w in warns), warns)
class TestLegacyConfigMigration(unittest.TestCase):
"""老机器的配置文件里写的是"当年的默认值"(save() 会把所有默认值落盘)→ 必须能升级到新语义。
不迁移的话:取址间隔一直是 3~5 秒,本版本的策略变更(5~10 秒、避免被上游判定为攻击)
在老机器上等于没做。
"""
def test_old_defaults_are_migrated(self):
path = os.path.join(tempfile.mkdtemp(), "c.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump({"upstream_interval_min_ms": 3000, "upstream_interval_max_ms": 5000,
"upstream_resolution": "720"}, fh)
cfg = Config(path)
self.assertEqual(cfg.get("upstream_interval_min_ms"), 5000)
self.assertEqual(cfg.get("upstream_interval_max_ms"), 10000)
self.assertEqual(sorted(cfg.migrated_keys),
["upstream_interval_max_ms", "upstream_interval_min_ms"])
# 迁移结果已落盘(下次启动不会再判一次)
with open(path, encoding="utf-8") as fh:
saved = json.load(fh)
self.assertEqual(saved["upstream_interval_min_ms"], 5000)
self.assertEqual(Config(path).migrated_keys, [])
self.assertEqual(cfg.get("upstream_resolution"), "720") # 其它键原样保留
# 迁移只做一次:之后用户**显式**把间隔改回去也会被尊重
cfg.update({"upstream_interval_min_ms": 3000})
again = Config(path)
self.assertEqual(again.get("upstream_interval_min_ms"), 3000)
self.assertEqual(again.migrated_keys, [])
def test_user_values_are_not_touched(self):
path = os.path.join(tempfile.mkdtemp(), "c.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump({"upstream_interval_min_ms": 8000, "upstream_interval_max_ms": 12000}, fh)
cfg = Config(path)
self.assertEqual((cfg.get("upstream_interval_min_ms"), cfg.get("upstream_interval_max_ms")),
(8000, 12000))
self.assertEqual(cfg.migrated_keys, [])
class RetryCase(Case):
"""夹具:3 首入队,其中 2 首标成 failed(模拟"已重试到上限")。"""
def setUp(self):
super().setUp()
self.store.keep_add([1, 2, 3])
self.store.enqueue([1, 2, 3])
self.store.set_state(1, "failed", "取址失败 code=403")
self.store.set_state(2, "failed", "下载不完整")
self.downloader = Downloader(self.cfg, self.store)
self.guard = StorageGuard(self.cfg, self.store, self.downloader)
self.downloader.guard = self.guard
self.retry = FailedRetry(self.cfg, self.store, self.downloader, self.guard)
self.downloader.retry = self.retry
def retry_events(self) -> list[str]:
return [e["text"] for e in self.store.events(200)
if "下载队列自动重试:重置" in e["text"]]
class TestFailedRetry(RetryCase):
def test_run_once_resets_failed_and_writes_event(self):
res = self.retry.run_once()
self.assertTrue(res["ok"])
self.assertFalse(res["skipped"])
self.assertEqual(res["reset"], 2)
self.assertEqual(self.dl_state(1), "pending")
self.assertEqual(self.dl_state(2), "pending")
self.assertEqual(self.dl_state(3), "pending") # 本来就在队列里,不受影响
# 事件日志形如:下载队列自动重试:重置 2 首失败任务(间隔 600s)
evs = self.retry_events()
self.assertEqual(len(evs), 1, evs)
self.assertIn("重置 2 首失败任务", evs[0])
self.assertIn("间隔 600s", evs[0])
st = self.retry.status()
self.assertEqual((st["last_count"], st["total_count"]), (2, 2))
self.assertGreater(st["last_at"], 0)
self.assertEqual(st["skipped_reason"], "")
self.assertEqual(st["interval_sec"], 600)
self.assertTrue(st["enabled"])
def test_no_log_when_nothing_to_reset(self):
"""N == 0 时**不写日志**(否则每 10 分钟刷一条无意义日志)。"""
self.retry.run_once() # 第一次重置 2 首
before = len(self.retry_events())
res = self.retry.run_once()
self.assertEqual(res["reset"], 0)
self.assertEqual(len(self.retry_events()), before) # 第二次一行都没多
def test_skip_when_user_paused(self):
"""下载池被**人工暂停**时不重试(force=True 才绕过)。"""
self.downloader.pause(by="user")
res = self.retry.run_once()
self.assertTrue(res["skipped"])
self.assertIn("人工暂停", res["skipped_reason"])
self.assertEqual(self.dl_state(1), "failed")
self.assertEqual(self.retry.status()["skipped_reason"], res["skipped_reason"])
# 强制:手工接口用
res2 = self.retry.run_once(force=True)
self.assertEqual(res2["reset"], 2)
self.assertEqual(self.dl_state(1), "pending")
def test_skip_when_storage_low(self):
"""存储守卫**水位不足**时不重试。"""
self.guard.state = {"ok": False, "reason": "本地盘剩余 1.0GB < 20.0GB"}
res = self.retry.run_once()
self.assertTrue(res["skipped"])
self.assertIn("水位不足", res["skipped_reason"])
self.assertEqual(self.dl_state(1), "failed")
def test_skip_when_disabled(self):
self.cfg.update({"retry_failed_enabled": False})
res = self.retry.run_once()
self.assertTrue(res["skipped"])
self.assertIn("已关闭", res["skipped_reason"])
self.assertFalse(self.retry.status()["enabled"])
self.assertEqual(self.dl_state(1), "failed")
def test_run_lock_prevents_overlap(self):
"""防重入:同一个调度器不会两处同时重试(第二次直接说"还在进行中")。"""
self.assertTrue(self.retry._run_lock.acquire(blocking=False))
try:
res = self.retry.run_once()
self.assertTrue(res["skipped"])
self.assertIn("还在进行中", res["skipped_reason"])
finally:
self.retry._run_lock.release()
def test_loop_returns_immediately_when_disabled(self):
"""interval=0 / enabled=false 时后台循环立刻退出(不留空转线程)。"""
self.cfg.update({"retry_failed_interval_sec": 0})
th = threading.Thread(target=self.retry.loop, daemon=True)
th.start()
th.join(2)
self.assertFalse(th.is_alive())
def test_loop_runs_and_swallows_errors(self):
"""循环真的按间隔调 run_once;run_once 抛异常时**只记日志**、线程不崩。"""
stop = _FakeStop()
self.retry._stop = stop # 只跑一轮就退出
self.retry.loop()
self.assertEqual(stop.waits, [600]) # 先等一个间隔再重试
self.assertEqual(self.dl_state(1), "pending")
self.store.set_state(1, "failed", "x")
self.retry._stop = _FakeStop()
self.store.retry_failed = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("库被锁"))
self.retry.loop() # 第二轮:异常被吞掉
evs = [e["level"] + ":" + e["text"] for e in self.store.events(20)]
self.assertTrue(any("下载队列自动重试失败" in x for x in evs), evs)
class TestFailedRetryEndpoints(RetryCase):
def setUp(self):
super().setUp()
self.router = admin.build_router(self.cfg, self.store, self.downloader)
def call(self, method, path, body=b""):
return self.router.dispatch(req(method, path, body=body))
def test_download_status_exposes_auto_retry(self):
d = json.loads(self.call("GET", "/api/download/status").body)
ar = d["auto_retry"]
for k in ("enabled", "interval_sec", "last_at", "last_count", "total_count", "skipped_reason"):
self.assertIn(k, ar, f"auto_retry 缺少 {k}")
self.assertTrue(ar["enabled"])
self.assertEqual(ar["interval_sec"], 600)
self.assertEqual(ar["last_count"], 0)
def test_retry_failed_now(self):
d = json.loads(self.call("POST", "/api/download/retry_failed_now").body)
self.assertTrue(d["ok"])
self.assertEqual(d["reset"], 2)
self.assertFalse(d["skipped"])
self.assertIn("auto_retry", d)
self.assertEqual(self.dl_state(1), "pending")
# 下载状态里也能看到"上次重置 2 首"
st = json.loads(self.call("GET", "/api/download/status").body)["auto_retry"]
self.assertEqual(st["last_count"], 2)
def test_retry_failed_now_reports_skip_and_force(self):
self.downloader.pause(by="user")
d = json.loads(self.call("POST", "/api/download/retry_failed_now").body)
self.assertTrue(d["skipped"])
self.assertIn("人工暂停", d["skipped_reason"])
self.assertEqual(self.dl_state(1), "failed")
d2 = json.loads(self.call("POST", "/api/download/retry_failed_now",
body=jbody({"force": True})).body)
self.assertEqual(d2["reset"], 2)
self.assertEqual(self.dl_state(1), "pending")
def test_status_without_downloader_still_has_auto_retry(self):
"""未装配下载器(只读/测试)时也不能 500:按配置给一份空状态。"""
router = admin.build_router(self.cfg, self.store, None)
d = json.loads(router.dispatch(req("GET", "/api/download/status")).body)
ar = d["auto_retry"]
for k in ("enabled", "interval_sec", "last_at", "last_count", "total_count", "skipped_reason"):
self.assertIn(k, ar)
self.assertEqual(ar["interval_sec"], 600)
# ──────────────────────────── 需求② 取址节奏 5~10 秒 ────────────────────────────
class _FakeClock:
"""虚拟时钟:`_pace()` 用它测"等了多少秒",**不真睡**(否则一次测试要 10 秒)。"""
def __init__(self, t: float = 1000.0):
self.t = float(t)
self.sleeps: list[float] = []
def time(self) -> float:
return self.t
def sleep(self, seconds: float) -> None:
self.sleeps.append(float(seconds))
self.t += float(seconds)
class TestPacing(Case):
def test_defaults_are_5s_to_10s(self):
self.assertEqual(self.cfg.get("upstream_interval_min_ms"), 5000)
self.assertEqual(self.cfg.get("upstream_interval_max_ms"), 10000)
warns = self.cfg.update({"upstream_interval_min_ms": 1000})
self.assertTrue(any("攻击" in w for w in warns), warns) # 说明里写明"避免被判定为攻击"
def test_pace_waits_at_least_min_and_at_most_max(self):
"""两次取址之间实际等待 ≥ min(且 ≤ max):用虚拟时钟精确断言。"""
clock = _FakeClock()
with mock.patch.object(R, "time", clock):
R._last_request[0] = clock.t # 清掉真实时钟留下的时间戳
R._pace(5000, 10000)
R._pace(5000, 10000)
self.assertEqual(len(clock.sleeps), 2, clock.sleeps)
for s in clock.sleeps:
self.assertGreaterEqual(s, 5.0, f"等待小于 min: {s}")
self.assertLessEqual(s, 10.0, f"等待大于 max: {s}")
self.assertGreaterEqual(sum(clock.sleeps), 10.0) # 串行:两次不会重叠
def test_pace_uses_config_values(self):
lo = int(self.cfg.get("upstream_interval_min_ms"))
hi = int(self.cfg.get("upstream_interval_max_ms"))
clock = _FakeClock()
with mock.patch.object(R, "time", clock):
R._last_request[0] = clock.t
R._pace(lo, hi)
self.assertGreaterEqual(clock.sleeps[0], lo / 1000.0)
self.assertLessEqual(clock.sleeps[0], hi / 1000.0)
def test_pace_is_global_serial_under_threads(self):
"""**全局串行**:多线程共用同一个节奏,不会叠加成并发洪水(总时长 ≈ 次数 × 间隔)。"""
R._last_request[0] = time.time()
n = 8
ths = [threading.Thread(target=R._pace, args=(50, 50)) for _ in range(n)]
t0 = time.time()
for t in ths:
t.start()
for t in ths:
t.join(timeout=5)
elapsed = time.time() - t0
self.assertGreaterEqual(elapsed, n * 0.05 * 0.75, f"没有串行限速:{elapsed:.3f}s")
def test_get_paces_with_its_args(self):
"""`_get` 在真正发请求**之前**先 `_pace(min,max)`(i.php/do.php 都经它出去)。"""
paced: list[tuple] = []
def fake_pace(lo, hi=None):
paced.append((lo, hi))
return 0.0
def no_net(_req, timeout=None): # 立刻失败,不联网
raise OSError("no network in test")
with mock.patch.object(R, "_pace", fake_pace), \
mock.patch.object(R.urllib.request, "urlopen", no_net):
status, body = R._get("http://up.example.com/i.php?x=1", 5, "ua", 5000, 10000)
self.assertEqual((status, body), (0, ""))
self.assertEqual(paced, [(5000, 10000)])
def test_iphp_and_dophp_both_go_through_pace(self):
"""i.php(取 token)与 do.php(取地址)**都**把配置节奏交给 `_get` → `_pace()`。"""
token_body = json.dumps({"code": 200, "token": "TK"})
song_body = json.dumps({"code": 200,
"data": "http://cdn.example.com/480p/7200088.ts?token=x"})
seen: list[tuple] = []
def fake_get(url, timeout=20, ua="", min_interval_ms=300, max_interval_ms=None):
seen.append(("i.php" in url, min_interval_ms, max_interval_ms))
return (200, token_body) if "/i.php" in url else (200, song_body)
with mock.patch.object(R, "_get", fake_get):
tok = R.get_token("http://up.example.com", "m", "s",
min_interval_ms=5000, max_interval_ms=10000)
url, detail = R.get_song_url("http://up.example.com", 7200088, "TK", "m", "s",
min_interval_ms=5000, max_interval_ms=10000)
self.assertEqual(tok, "TK")
self.assertEqual(detail, "ok")
self.assertTrue(url.endswith("7200088.ts?token=x"))
# i.php + do.php 各一次,且都带 5000/10000(上一条测试已证明 _get 会用它们调 _pace)
self.assertEqual(seen, [(True, 5000, 10000), (False, 5000, 10000)], seen)
def test_resolver_passes_config_pacing(self):
"""下载线程实际取址(Resolver.resolve)用的是配置里的 5~10 秒,不是调用点的默认值。"""
seen: dict = {}
def fake_token(host, mac, sn, timeout=20, ua="", min_interval_ms=300, max_interval_ms=None):
seen["token"] = (min_interval_ms, max_interval_ms)
return "TK"
def fake_url(host, tid, token, mac, sn, resolution="720", h265=False, timeout=20,
ua="", min_interval_ms=300, max_interval_ms=None):
seen["url"] = (min_interval_ms, max_interval_ms)
return "http://cdn.example.com/480p/1.ts", "ok"
self.cfg.update({"upstream_hosts": ["http://up.example.com"]})
r = R.Resolver(self.cfg, None) # 不传 store:纯逻辑
with mock.patch.object(R, "get_token", fake_token), \
mock.patch.object(R, "get_song_url", fake_url):
url, host, detail = r.resolve(1)
self.assertEqual(detail, "ok")
self.assertEqual(seen["token"], (5000, 10000))
self.assertEqual(seen["url"], (5000, 10000))
def test_upstream_probe_does_not_bypass_pacing(self):
"""**一次性批量取址路径**(/api/upstream/probe 循环各源取 token)也不许绕过节奏。"""
self.cfg.update({"upstream_hosts": ["http://a.example.com", "http://b.example.com"]})
router = admin.build_router(self.cfg, self.store, None)
seen: list[tuple] = []
def fake_token(host, mac, sn, timeout=20, ua="", min_interval_ms=300, max_interval_ms=None):
seen.append((min_interval_ms, max_interval_ms))
return "TK"
with mock.patch.object(R, "fetch_config", lambda *a, **k: {}), \
mock.patch.object(R, "get_token", fake_token):
d = json.loads(router.dispatch(req("POST", "/api/upstream/probe")).body)
self.assertTrue(d["ok"])
self.assertEqual(len(seen), 2) # 两个源各探一次
self.assertEqual(seen, [(5000, 10000), (5000, 10000)], seen)
# ──────────────────────────── 需求③ 媒体目录扫描(认领,不删除)────────────────────────────
class ScanCase(Case):
MIN = 100
def write(self, name: str, size: int) -> str:
path = os.path.join(self.media, name)
with open(path, "wb") as fh:
fh.write(b"\x47" * size)
return path
def keeps_row(self, tid: int):
return self.store.q1("SELECT state,bytes,user,note FROM keeps WHERE tid=?", (tid,))
def scan(self, **kw):
kw.setdefault("min_bytes", self.MIN)
kw.setdefault("timeout_sec", 5)
return self.store.keep_adopt_media(self.media, **kw)
class TestMediaScanConfig(Case):
def test_defaults(self):
self.assertTrue(self.cfg.get("media_scan_enabled"))
self.assertEqual(self.cfg.get("media_scan_interval_sec"), 600)
self.assertEqual(self.cfg.get("media_scan_timeout_sec"), 30)
# 默认沿用 min_file_bytes
self.assertEqual(self.cfg.get("media_scan_min_bytes"), self.cfg.get("min_file_bytes"))
self.assertEqual(self.cfg.media_scan_min_bytes(), self.cfg.get("min_file_bytes"))
def test_zero_follows_min_file_bytes(self):
self.cfg.update({"min_file_bytes": 4096, "media_scan_min_bytes": 0})
self.assertEqual(self.cfg.media_scan_min_bytes(), 4096)
class TestMediaScanAdopt(ScanCase):
def test_adopts_new_unknown_and_updates_existing(self):
"""① 合法且够大的文件被登记/更新;② 未知编号也登记;③ 非法名/过小跳过;④ **不删任何文件**。"""
self.store.keep_add([1]) # 在表里但不是 done → 应被更新
self.write("1.ts", 4096)
self.write("1234567.ts", 5000) # 曲库里没有 → 新增登记
self.write("7654321.ts", 5000) # 曲库里也没有 → 新增登记
self.write("bad.ts", 5000) # 文件名非法 → 跳过(且不删)
self.write("8888888.ts", 10) # 过小 → 跳过(且不删)
before = sorted(os.listdir(self.media))
res = self.scan()
self.assertTrue(res["ok"], res)
self.assertEqual(res["scanned"], 5) # 目录里 5 个 .ts
self.assertEqual(res["adopted_count"], 2)
self.assertEqual(sorted(res["adopted"]), [1234567, 7654321]) # 最多 10 个、升序
self.assertEqual(res["updated"], 1)
self.assertEqual(res["skipped"], 2)
self.assertEqual(res["freed"], 0) # 本方法不删文件
self.assertLess(res["elapsed_ms"], 5000)
for tid, size in ((1, 4096), (1234567, 5000), (7654321, 5000)):
row = self.keeps_row(tid)
self.assertIsNotNone(row, f"{tid} 没有被登记进歌曲表")
self.assertEqual((row["state"], row["bytes"]), ("done", size))
row = self.keeps_row(1234567)
self.assertEqual(row["user"], "scan")
self.assertEqual(row["note"], "媒体扫描自动登记")
# 未知编号照常登记,只是 matched=false(曲库同步后自动补全)
unmatched = self.store.keep_rows(state="unmatched", page_size=50)["items"]
self.assertEqual(sorted(x["tid"] for x in unmatched), [1234567, 7654321])
self.assertIs(unmatched[0]["matched"], False)
# ★ 没有任何文件被删除(目录内容一个字节都没变)
self.assertEqual(sorted(os.listdir(self.media)), before)
for name in before:
self.assertTrue(os.path.isfile(os.path.join(self.media, name)), name)
def test_already_done_is_not_counted_again(self):
self.store.keep_add([1])
self.store.keep_mark_done(1, 4096, os.path.join(self.media, "1.ts"))
self.write("1.ts", 4096)
res = self.scan()
self.assertTrue(res["ok"])
self.assertEqual((res["adopted_count"], res["updated"]), (0, 0))
self.assertEqual(res["scanned"], 1)
def test_empty_dir_is_ok(self):
res = self.scan()
self.assertTrue(res["ok"])
self.assertEqual((res["scanned"], res["adopted_count"], res["updated"], res["skipped"]),
(0, 0, 0, 0))
def test_missing_dir_returns_error_without_state_change(self):
res = self.store.keep_adopt_media(os.path.join(self.tmp, "nope"),
min_bytes=self.MIN, timeout_sec=2)
self.assertFalse(res["ok"])
self.assertIn("不可用", res["error"])
self.assertEqual(self.store.keep_stats()["total"], 0) # 状态未变
def test_timeout_returns_error_and_changes_nothing(self):
"""★超时路径:打桩一个"会睡很久的列举函数" → ok=false、状态未变、耗时受控。"""
self.write("1234567.ts", 5000)
def slow_listing(_media_dir):
time.sleep(0.6)
return [("1234567.ts", 5000)]
t0 = time.time()
with mock.patch.object(store_mod, "_scan_media_entries", slow_listing):
res = self.store.keep_adopt_media(self.media, min_bytes=self.MIN, timeout_sec=0.1)
elapsed = time.time() - t0
self.assertFalse(res["ok"])
self.assertIn("超时", res["error"])
self.assertLess(elapsed, 0.5, f"调用方被阻塞了 {elapsed:.2f}s(必须按 timeout 放弃)")
self.assertEqual(self.store.keep_stats()["total"], 0) # 一行都没登记
self.assertTrue(os.path.isfile(os.path.join(self.media, "1234567.ts"))) # 文件也没删
def test_listing_exception_returns_error_without_state_change(self):
def boom(_media_dir):
raise OSError("FUSE 掉线")
with mock.patch.object(store_mod, "_scan_media_entries", boom):
res = self.store.keep_adopt_media(self.media, min_bytes=self.MIN, timeout_sec=1)
self.assertFalse(res["ok"])
self.assertIn("列举失败", res["error"])
self.assertEqual(self.store.keep_stats()["total"], 0)
class TestMediaScanEndpoints(ScanCase):
def setUp(self):
super().setUp()
# 接口用的是**配置里的**最小字节数(默认 6MiB);测试里调小,避免造 6MB 大文件
self.cfg.update({"media_scan_min_bytes": self.MIN})
self.router = admin.build_router(self.cfg, self.store, None)
admin.MEDIA_SCAN_STATE.update({"running": False, "last_at": 0, "ok": None, "error": "",
"scanned": 0, "adopted": 0, "adopted_sample": [],
"updated": 0, "skipped": 0, "freed": 0, "elapsed_ms": 0})
def call(self, method, path, body=b""):
return self.router.dispatch(req(method, path, body=body))
def test_scan_endpoint_and_status(self):
self.write("1234567.ts", 5000)
self.write("7654321.ts", 5000)
self.write("bad.ts", 5000)
self.write("8888888.ts", 10)
before = sorted(os.listdir(self.media))
d = json.loads(self.call("POST", "/api/keeps/scan").body)
self.assertTrue(d["ok"])
self.assertEqual(sorted(d["adopted"]), [1234567, 7654321])
self.assertEqual((d["updated"], d["skipped"]), (0, 2))
self.assertIn("scan", d)
self.assertEqual(d["stats"]["total"], 2)
# 上次扫描结果通过 /api/keeps/scan/status 暴露(时间/新增/更新/耗时/错误)
st = json.loads(self.call("GET", "/api/keeps/scan/status").body)["scan"]
self.assertEqual((st["adopted"], st["updated"], st["skipped"]), (2, 0, 2))
self.assertGreater(st["last_at"], 0)
self.assertTrue(st["ok"])
self.assertEqual(st["error"], "")
self.assertGreaterEqual(st["elapsed_ms"], 0)
self.assertEqual(st["interval_sec"], 600)
# /api/keeps/stats 也带 scan 字段
s = json.loads(self.call("GET", "/api/keeps/stats").body)
self.assertIn("scan", s)
self.assertEqual(s["total"], 2)
# 绝不删除文件
self.assertEqual(sorted(os.listdir(self.media)), before)
def test_scan_endpoint_reports_timeout(self):
self.cfg.update({"media_scan_timeout_sec": 1}) # 配置里最低 1 秒
def slow_listing(_media_dir):
time.sleep(2.0)
return []
t0 = time.time()
with mock.patch.object(store_mod, "_scan_media_entries", slow_listing):
d = json.loads(self.call("POST", "/api/keeps/scan").body)
self.assertFalse(d["ok"])
self.assertIn("超时", d["error"])
self.assertLess(time.time() - t0, 1.8, "接口必须按配置超时返回,不能一直挂着")
st = json.loads(self.call("GET", "/api/keeps/scan/status").body)["scan"]
self.assertFalse(st["ok"])
self.assertIn("超时", st["error"])
self.assertFalse(st["running"])
def test_scheduler_exits_when_disabled(self):
for patch in ({"media_scan_enabled": False}, {"media_scan_interval_sec": 0}):
self.cfg.update(patch)
stop = threading.Event()
th = threading.Thread(target=main.media_scan_scheduler,
args=(self.cfg, self.store, stop), daemon=True)
th.start()
th.join(2)
self.assertFalse(th.is_alive(), f"未按 {patch} 退出")
def test_scheduler_runs_scan_once_and_swallows_errors(self):
"""守护线程真的按间隔调 run_media_scan;失败只记日志(不打断服务)。"""
self.write("1234567.ts", 5000)
main.media_scan_scheduler(self.cfg, self.store, _FakeStop()) # 只跑一轮
row = self.keeps_row(1234567)
self.assertIsNotNone(row)
self.assertEqual(row["state"], "done")
with mock.patch.object(admin, "run_media_scan",
side_effect=RuntimeError("FUSE 掉线")):
main.media_scan_scheduler(self.cfg, self.store, _FakeStop())
evs = [e["text"] for e in self.store.events(20)]
self.assertTrue(any("媒体目录扫描失败" in x for x in evs), evs)
# ──────────────────────────── 管理界面契约(源码级)────────────────────────────
class TestUiContractForNewFeatures(unittest.TestCase):
"""界面必须能看到/操作到这三件事(否则后端做了用户也不知道)。"""
def setUp(self):
from ktv import ui
self.html = ui.PAGE
def test_keeps_tab_has_scan_button_and_last_result(self):
self.assertIn("扫描媒体目录", self.html)
self.assertIn("scanMedia(this)", self.html)
self.assertIn('id="kScanLine"', self.html) # 上次扫描结果一行
self.assertIn("/api/keeps/scan/status", self.html)
def test_tasks_tab_has_auto_retry_line_and_button(self):
self.assertIn('id="retryLine"', self.html) # 「失败任务每 10 分钟自动重试…」
self.assertIn("失败任务每 ", self.html)
self.assertIn("/api/download/retry_failed_now", self.html)
self.assertIn("立即自动重试一次", self.html)
def test_settings_expose_all_new_keys(self):
for key in ("media_scan_interval_sec", "media_scan_min_bytes", "media_scan_timeout_sec",
"retry_failed_interval_sec", "retry_failed_max_attempts"):
self.assertIn(f"'{key}'", self.html, f"设置页缺少 {key}")
for sel in ("s_media_scan_enabled", "s_retry_failed_enabled"):
self.assertIn(f'id="{sel}"', self.html, f"设置页缺少 {sel}")
# 设置项说明里写明目的:避免被上游判定为攻击
self.assertIn("避免被上游判定为攻击", self.html)
self.assertIn("5000~10000", self.html)
# ──────────────────────────── 启动顺序(源码级守卫)────────────────────────────
class TestStartupOrderAfterListen(unittest.TestCase):
"""媒体的守护线程必须在**端口监听之后**启动(历史事故:进程活着但不监听端口)。"""
def test_new_daemons_start_after_listen(self):
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"ktv", "main.py")
with open(path, encoding="utf-8") as fh:
src = fh.read()
i_s1 = src.index("s1 = web.start_server(")
i_s2 = src.index("s2 = web.start_server(")
i_whoami = src.index("whoami.start()")
i_retry = src.index("retry.start()")
i_scan = src.index("target=media_scan_scheduler")
for name, idx in (("whoami", i_whoami), ("retry", i_retry), ("media_scan", i_scan)):
self.assertGreater(idx, i_s1, f"{name} 在服务端口监听之前启动了")
self.assertGreater(idx, i_s2, f"{name} 在管理端口监听之前启动了")
# 自动重试对象在监听之前只装配、不 start
self.assertLess(src.index("retry = FailedRetry("), i_s2)
if __name__ == "__main__":
unittest.main(verbosity=2)