diff --git a/app/modules/acoustid/__init__.py b/app/modules/acoustid/__init__.py index 1348d3cad..09fe79002 100644 --- a/app/modules/acoustid/__init__.py +++ b/app/modules/acoustid/__init__.py @@ -34,9 +34,17 @@ class AcoustIdModule(_ModuleBase): self._cache: OrderedDict[tuple[str, int, int], Optional[str]] = OrderedDict() self._cache_lock = threading.Lock() + @staticmethod + def _resolve_fpcalc() -> Optional[str]: + """在 PATH 中定位可执行的 fpcalc,返回其绝对路径,缺失则返回 None。 + + shutil.which 在 POSIX 上已校验文件可执行,足以判定本地依赖是否就绪。 + """ + return shutil.which("fpcalc") or None + def init_module(self) -> None: """定位 fpcalc 工具并清空可能过期的文件识别缓存。""" - self._fpcalc_path = shutil.which("fpcalc") + self._fpcalc_path = self._resolve_fpcalc() with self._cache_lock: self._cache.clear() if not self._fpcalc_path: @@ -52,11 +60,18 @@ class AcoustIdModule(_ModuleBase): self._cache.clear() def test(self) -> Tuple[bool, str]: - """检查 API Key、fpcalc 和 AcoustID API 的基础连通性。""" + """检查 API Key、本地 fpcalc 依赖与 AcoustID API 的基础连通性。 + + 本地依赖检测独立于 init_module 的缓存快照,重新定位 fpcalc,确保即便 + 模块初始化早于 fpcalc 安装,或运行期依赖被移除,测试也能如实反映本地 + 依赖状态,而不是只校验网络连通性。 + """ if not str(settings.ACOUSTID_API_KEY or "").strip(): return False, "AcoustID API Key 未配置" - if not self._fpcalc_path: + fpcalc_path = self._resolve_fpcalc() + if not fpcalc_path: return False, "未找到 fpcalc,请先安装 Chromaprint" + self._fpcalc_path = fpcalc_path response = RequestUtils( ua=settings.USER_AGENT, proxies=settings.PROXY, diff --git a/tests/test_acoustid_module.py b/tests/test_acoustid_module.py index b40cf3306..55bd2b6fd 100644 --- a/tests/test_acoustid_module.py +++ b/tests/test_acoustid_module.py @@ -129,6 +129,56 @@ def test_identify_music_by_fingerprint_skips_missing_fpcalc(tmp_path, monkeypatc post_res.assert_not_called() +def test_test_fails_without_local_fpcalc_and_skips_network(monkeypatch): + """缺少本地 fpcalc 时,测试应直接失败且不发起任何网络请求。""" + monkeypatch.setattr("app.modules.acoustid.shutil.which", lambda _: None) + monkeypatch.setattr("app.modules.acoustid.settings.ACOUSTID_API_KEY", "client-key") + get_res = Mock() + monkeypatch.setattr(RequestUtils, "get_res", get_res) + + module = AcoustIdModule() + ok, message = module.test() + + assert ok is False + assert "fpcalc" in message + get_res.assert_not_called() + + +def test_test_passes_with_local_fpcalc_and_network(monkeypatch): + """fpcalc 存在且网络可达时,测试应成功并刷新本地依赖快照。""" + monkeypatch.setattr( + "app.modules.acoustid.shutil.which", + lambda _: "/usr/bin/fpcalc", + ) + monkeypatch.setattr("app.modules.acoustid.settings.ACOUSTID_API_KEY", "client-key") + monkeypatch.setattr(RequestUtils, "get_res", Mock(return_value=FakeResponse({}))) + + module = AcoustIdModule() + ok, message = module.test() + + assert ok is True + assert message == "" + assert module._fpcalc_path == "/usr/bin/fpcalc" + + +def test_test_resolves_fpcalc_independently_of_init(monkeypatch): + """即便 init_module 早期未定位到 fpcalc,测试也应重新检测本地依赖。""" + monkeypatch.setattr( + "app.modules.acoustid.shutil.which", + lambda _: "/usr/bin/fpcalc", + ) + monkeypatch.setattr("app.modules.acoustid.settings.ACOUSTID_API_KEY", "client-key") + monkeypatch.setattr(RequestUtils, "get_res", Mock(return_value=FakeResponse({}))) + + module = AcoustIdModule() + module._fpcalc_path = None + + ok, _ = module.test() + + assert ok is True + assert module._fpcalc_path == "/usr/bin/fpcalc" + + def test_async_identify_music_by_fingerprint_uses_async_process_and_http( tmp_path, monkeypatch,