""" 验证 LRCLIB 歌词查询、匹配和返回值映射。 """ from app.domain.context import MusicInfo from app.modules.lrclib import LrclibModule class _FakeLrclibResponse: """ 模拟 LRCLIB HTTP 响应,供解析、缓存和限流测试复用。 """ def __init__(self, payload, status_code=200, headers=None): self._payload = payload self.status_code = status_code self.headers = headers or {} self.text = "" self.closed = False def json(self): """ 返回预设 JSON 负载。 """ return self._payload def close(self): """ 记录响应已关闭。 """ self.closed = True def __bool__(self): """ 复现 requests.Response 对 4xx/5xx 响应返回 False 的行为。 """ return self.status_code < 400 def test_music_lyrics_prefers_exact_signature_and_synced_lyrics(monkeypatch) -> None: """ 元数据完整时应调用精确接口并优先返回同步歌词。 """ module = LrclibModule() requested = [] def fake_request(path, params=None): """ 记录精确查询参数并返回同步歌词。 """ requested.append((path, params)) return { "id": 3396226, "instrumental": False, "plainLyrics": "plain", "syncedLyrics": "[00:01.00]synced", } monkeypatch.setattr(module, "_request_json", fake_request) lyrics = module.music_lyrics( MusicInfo( title="I Want to Live", artists=["Borislav Slavov"], album="Baldur's Gate 3", duration=233, ) ) assert lyrics is not None assert lyrics.provider == "lrclib" assert lyrics.provider_id == "3396226" assert lyrics.content == "[00:01.00]synced" assert lyrics.extension == ".lrc" assert requested == [ ( "/api/get", { "track_name": "I Want to Live", "artist_name": "Borislav Slavov", "album_name": "Baldur's Gate 3", "duration": 233, }, ) ] def test_music_lyrics_search_fallback_rejects_wrong_duration(monkeypatch) -> None: """ 精确接口未命中后只能选取标题、艺术家和时长均可信的搜索结果。 """ module = LrclibModule() def fake_request(path, params=None): """ 精确查询返回未命中,搜索返回一条错误版本和一条正确版本。 """ if path == "/api/get": return {} return [ { "id": 1, "trackName": "晴天", "artistName": "周杰伦", "albumName": "演唱会", "duration": 310, "plainLyrics": "wrong", }, { "id": 2, "trackName": "晴天", "artistName": "周杰伦", "albumName": "叶惠美", "duration": 269, "plainLyrics": "correct", }, ] monkeypatch.setattr(module, "_request_json", fake_request) lyrics = module.music_lyrics( MusicInfo( title="晴天", artists=["周杰伦"], album="叶惠美", duration=270, ) ) assert lyrics is not None assert lyrics.provider_id == "2" assert lyrics.content == "correct" assert lyrics.extension == ".txt" def test_music_lyrics_queries_simplified_metadata(monkeypatch) -> None: """ 繁体音乐信息应使用简体标题、艺术家和专辑查询 LRCLIB。 """ module = LrclibModule() requested = [] def fake_request(path, params=None): """ 记录归一后的精确查询参数并返回歌词。 """ requested.append((path, params)) return { "id": 3, "plainLyrics": "安静的歌词", } monkeypatch.setattr(module, "_request_json", fake_request) lyrics = module.music_lyrics( MusicInfo( title="安靜", artists=["周杰倫"], album="范特西", duration=334, ) ) assert lyrics is not None assert lyrics.provider_id == "3" assert requested == [ ( "/api/get", { "track_name": "安静", "artist_name": "周杰伦", "album_name": "范特西", "duration": 334, }, ) ] def test_music_lyrics_search_matches_traditional_candidate(monkeypatch) -> None: """ 搜索结果使用繁体艺术家或标题时仍应匹配简体查询身份。 """ module = LrclibModule() def fake_request(path, params=None): """ 模拟精确接口未命中和繁体搜索结果。 """ if path == "/api/get": return {} return [ { "id": 4, "trackName": "安靜", "artistName": "周杰倫", "albumName": "范特西", "duration": 334, "plainLyrics": "安静的歌词", } ] monkeypatch.setattr(module, "_request_json", fake_request) lyrics = module.music_lyrics( MusicInfo(title="安靜", artists=["周杰倫"], album="范特西", duration=334) ) assert lyrics is not None assert lyrics.provider_id == "4" def test_request_json_honors_retry_after_once(monkeypatch) -> None: """ LRCLIB 返回带 Retry-After 的过载响应时应等待并串行重试一次。 """ responses = iter( [ _FakeLrclibResponse(None, status_code=503, headers={"Retry-After": "2"}), _FakeLrclibResponse([{"id": 1}], status_code=200), ] ) sleeps = [] monkeypatch.setattr(LrclibModule, "_request_once", lambda *_args, **_kwargs: next(responses)) monkeypatch.setattr("app.modules.lrclib.time.sleep", lambda seconds: sleeps.append(seconds)) LrclibModule._request_json.cache_clear() result = LrclibModule._request_json( "/api/search", params={"track_name": "晴天", "artist_name": "周杰伦"}, ) assert result == [{"id": 1}] assert sleeps == [2.0] def test_request_json_retry_network_failure_is_not_cached(monkeypatch) -> None: """ 过载重试遇到网络失败时应安全返回且不把失败写入缓存。 """ responses = iter( [ _FakeLrclibResponse(None, status_code=429, headers={"Retry-After": "0"}), None, _FakeLrclibResponse([{"id": 3}], status_code=200), ] ) monkeypatch.setattr(LrclibModule, "_request_once", lambda *_args, **_kwargs: next(responses)) monkeypatch.setattr("app.modules.lrclib.time.sleep", lambda _seconds: None) LrclibModule._request_json.cache_clear() params = {"track_name": "retry"} first = LrclibModule._request_json("/api/search", params=params) second = LrclibModule._request_json("/api/search", params=params) assert first is None assert second == [{"id": 3}] def test_request_json_caches_not_found_response(monkeypatch) -> None: """ 未匹配结果也应缓存,避免整库重复刮削持续请求同一首歌。 """ calls = {"count": 0} def fake_request(*_args, **_kwargs): """ 记录请求次数并返回 404。 """ calls["count"] += 1 return _FakeLrclibResponse(None, status_code=404) monkeypatch.setattr(LrclibModule, "_request_once", fake_request) LrclibModule._request_json.cache_clear() first = LrclibModule._request_json("/api/get", params={"track_name": "missing"}) second = LrclibModule._request_json("/api/get", params={"track_name": "missing"}) assert first == second == {} assert calls["count"] == 1 def test_request_json_caches_success_response(monkeypatch) -> None: """ 成功歌词响应应进入有界 TLRU 缓存,相同签名只访问一次外部 API。 """ calls = {"count": 0} def fake_request(*_args, **_kwargs): """ 记录请求次数并返回固定歌词。 """ calls["count"] += 1 return _FakeLrclibResponse( {"id": 2, "syncedLyrics": "[00:01.00]晴天"}, status_code=200, ) monkeypatch.setattr(LrclibModule, "_request_once", fake_request) LrclibModule._request_json.cache_clear() params = { "track_name": "晴天", "artist_name": "周杰伦", "album_name": "叶惠美", "duration": 269, } first = LrclibModule._request_json("/api/get", params=params) second = LrclibModule._request_json("/api/get", params=params) assert first == second == {"id": 2, "syncedLyrics": "[00:01.00]晴天"} assert calls["count"] == 1