mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 10:14:36 +08:00
187 lines
6.1 KiB
Python
187 lines
6.1 KiB
Python
import asyncio
|
||
import json
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, Mock
|
||
|
||
from app.core.config import ConfigModel
|
||
from app.modules.acoustid import AcoustIdModule
|
||
from app.utils.http import AsyncRequestUtils, RequestUtils
|
||
|
||
|
||
RECORDING_ID = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
|
||
|
||
|
||
class FakeResponse:
|
||
"""提供 AcoustID 模块测试所需的最小 HTTP 响应接口。"""
|
||
|
||
def __init__(self, payload: dict, status_code: int = 200) -> None:
|
||
"""保存响应数据并记录资源是否被关闭。"""
|
||
self.payload = payload
|
||
self.status_code = status_code
|
||
self.closed = False
|
||
|
||
def json(self) -> dict:
|
||
"""返回预设 JSON 响应。"""
|
||
return self.payload
|
||
|
||
def close(self) -> None:
|
||
"""记录响应资源已释放。"""
|
||
self.closed = True
|
||
|
||
|
||
class FakeAsyncResponse(FakeResponse):
|
||
"""提供异步 AcoustID 查询所需的响应关闭接口。"""
|
||
|
||
async def aclose(self) -> None:
|
||
"""记录异步响应资源已释放。"""
|
||
self.closed = True
|
||
|
||
|
||
def test_acoustid_api_key_has_built_in_default():
|
||
"""系统应内置可用 AcoustID 应用 Key,同时允许运行配置覆盖。"""
|
||
assert ConfigModel.model_fields["ACOUSTID_API_KEY"].default == "b1auxfOzAg"
|
||
|
||
|
||
def test_identify_music_by_fingerprint_queries_acoustid_and_caches_result(
|
||
tmp_path,
|
||
monkeypatch,
|
||
):
|
||
"""有效指纹应请求 recordingids,并按文件状态缓存 MusicBrainz ID。"""
|
||
audio_path = tmp_path / "track.flac"
|
||
audio_path.write_bytes(b"audio")
|
||
module = AcoustIdModule()
|
||
module._fpcalc_path = "/usr/bin/fpcalc"
|
||
fpcalc = Mock(return_value=SimpleNamespace(
|
||
returncode=0,
|
||
stdout=json.dumps({"duration": 243.4, "fingerprint": "AQADtM..."}),
|
||
))
|
||
response = FakeResponse({
|
||
"status": "ok",
|
||
"results": [{
|
||
"score": 0.98,
|
||
"recordings": [{"id": RECORDING_ID}],
|
||
}],
|
||
})
|
||
post_res = Mock(return_value=response)
|
||
monkeypatch.setattr("app.modules.acoustid.subprocess.run", fpcalc)
|
||
monkeypatch.setattr(RequestUtils, "post_res", post_res)
|
||
monkeypatch.setattr(module, "_wait_for_rate_limit", lambda: None)
|
||
monkeypatch.setattr("app.modules.acoustid.settings.ACOUSTID_API_KEY", "client-key")
|
||
|
||
first = module.identify_music_by_fingerprint(audio_path)
|
||
second = module.identify_music_by_fingerprint(audio_path)
|
||
|
||
assert first == RECORDING_ID
|
||
assert second == RECORDING_ID
|
||
fpcalc.assert_called_once_with(
|
||
["/usr/bin/fpcalc", "-json", str(audio_path)],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=60,
|
||
)
|
||
post_res.assert_called_once()
|
||
assert post_res.call_args.kwargs["url"] == "https://api.acoustid.org/v2/lookup"
|
||
assert post_res.call_args.kwargs["data"] == {
|
||
"client": "client-key",
|
||
"duration": 243,
|
||
"fingerprint": "AQADtM...",
|
||
"meta": "recordingids",
|
||
"format": "json",
|
||
}
|
||
assert response.closed is True
|
||
|
||
|
||
def test_select_recording_id_requires_high_score_and_valid_uuid():
|
||
"""低置信结果和异常外部 ID 不得进入 MusicBrainz 详情查询。"""
|
||
payload = {
|
||
"status": "ok",
|
||
"results": [
|
||
{"score": 0.99, "recordings": [{"id": "not-a-uuid"}]},
|
||
{"score": 0.91, "recordings": [{"id": RECORDING_ID}]},
|
||
{
|
||
"score": 0.89,
|
||
"recordings": [{"id": "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"}],
|
||
},
|
||
],
|
||
}
|
||
|
||
assert AcoustIdModule._select_recording_id(payload) == RECORDING_ID
|
||
assert AcoustIdModule._select_recording_id({
|
||
"status": "ok",
|
||
"results": [{
|
||
"score": 0.89,
|
||
"recordings": [{"id": RECORDING_ID}],
|
||
}],
|
||
}) is None
|
||
|
||
|
||
def test_identify_music_by_fingerprint_skips_missing_fpcalc(tmp_path, monkeypatch):
|
||
"""缺少 fpcalc 时应静默跳过指纹层,不发起 AcoustID 请求。"""
|
||
audio_path = tmp_path / "track.mp3"
|
||
audio_path.write_bytes(b"audio")
|
||
module = AcoustIdModule()
|
||
post_res = Mock()
|
||
monkeypatch.setattr(RequestUtils, "post_res", post_res)
|
||
|
||
assert module.identify_music_by_fingerprint(Path(audio_path)) is None
|
||
post_res.assert_not_called()
|
||
|
||
|
||
def test_async_identify_music_by_fingerprint_uses_async_process_and_http(
|
||
tmp_path,
|
||
monkeypatch,
|
||
):
|
||
"""异步接口应异步执行 fpcalc 与 HTTP 查询,并复用同一响应筛选规则。"""
|
||
audio_path = tmp_path / "track.flac"
|
||
audio_path.write_bytes(b"audio")
|
||
module = AcoustIdModule()
|
||
module._fpcalc_path = "/usr/bin/fpcalc"
|
||
|
||
class FakeProcess:
|
||
"""模拟已成功执行的异步 fpcalc 子进程。"""
|
||
|
||
returncode = 0
|
||
|
||
async def communicate(self):
|
||
"""返回 fpcalc JSON 标准输出和空错误输出。"""
|
||
payload = json.dumps({
|
||
"duration": 243.4,
|
||
"fingerprint": "AQADtM...",
|
||
})
|
||
return payload.encode(), b""
|
||
|
||
create_process = AsyncMock(return_value=FakeProcess())
|
||
response = FakeAsyncResponse({
|
||
"status": "ok",
|
||
"results": [{
|
||
"score": 0.98,
|
||
"recordings": [{"id": RECORDING_ID}],
|
||
}],
|
||
})
|
||
post_res = AsyncMock(return_value=response)
|
||
wait_rate_limit = AsyncMock()
|
||
monkeypatch.setattr(
|
||
"app.modules.acoustid.asyncio.create_subprocess_exec",
|
||
create_process,
|
||
)
|
||
monkeypatch.setattr(AsyncRequestUtils, "post_res", post_res)
|
||
monkeypatch.setattr(module, "_async_wait_for_rate_limit", wait_rate_limit)
|
||
monkeypatch.setattr("app.modules.acoustid.settings.ACOUSTID_API_KEY", "client-key")
|
||
|
||
result = asyncio.run(module.async_identify_music_by_fingerprint(audio_path))
|
||
|
||
assert result == RECORDING_ID
|
||
create_process.assert_awaited_once_with(
|
||
"/usr/bin/fpcalc",
|
||
"-json",
|
||
str(audio_path),
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
)
|
||
wait_rate_limit.assert_awaited_once()
|
||
post_res.assert_awaited_once()
|
||
assert post_res.await_args.kwargs["data"]["meta"] == "recordingids"
|
||
assert response.closed is True
|