Files
maidong-ktv/selfhost-source/server/tests/test_autoretry_scan.py
T
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

721 lines
34 KiB
Python
Raw 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
"""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)