import unittest class MultiSourceSongResolverTests(unittest.TestCase): def test_resolver_prefers_preferred_source_exact_candidate_before_cross_platform_search(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeClient: def __init__(self, search_results=None, on_search=None): self.search_results = list(search_results or []) self.on_search = on_search def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): if self.on_search is not None: self.on_search(keyword) return list(self.search_results) stale_song_info = SongInfo( source="NeteaseMusicClient", identifier="song-c", song_name="Song C", singers="Singer A / Singer B", ext="mp3", raw_data={"search": {"id": "song-c"}}, download_url=None, download_url_status={}, ) netease_candidate = SongInfo( source="NeteaseMusicClient", identifier="song-c", song_name="Song C", singers="Singer A / Singer B", ext="mp3", file_size_bytes=1024, file_size="1.00 MB", raw_data={"quality": "standard"}, download_url="https://example.com/song-c.mp3", download_url_status={"ok": True}, ) qq_candidate = SongInfo( source="QQMusicClient", identifier="qq-song-c", song_name="Song C", singers="Singer A / Singer B", ext="flac", file_size_bytes=4096, file_size="4.00 MB", raw_data={"quality": "lossless"}, download_url="https://example.com/song-c.flac", download_url_status={"ok": True}, ) searched_sources: list[str] = [] resolver = MultiSourceSongResolver( client_factory=lambda platform: { "netease": FakeClient([netease_candidate], on_search=lambda keyword: searched_sources.append("netease")), "qq": FakeClient([qq_candidate], on_search=lambda keyword: searched_sources.append("qq")), }[platform] ) resolved_song_info = resolver.resolve_song_info( row={ "platform": "netease", "name": "Song C", "singers": "Singer A / Singer B", "remote_song_id": "song-c", }, snapshot_song_info=stale_song_info, download_sources=["netease", "qq"], ) self.assertEqual(["netease"], searched_sources) self.assertEqual("NeteaseMusicClient", resolved_song_info.source) self.assertEqual("mp3", resolved_song_info.ext) self.assertEqual("song-c", resolved_song_info.identifier) def test_resolver_stops_after_preferred_source_refresh_returns_downloadable_song(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class PreferredClient: def __init__(self, refreshed_song): self.refreshed_song = refreshed_song self.search_called = False def _parsewithofficialapiv1(self, search_result, request_overrides=None): return self.refreshed_song def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): self.search_called = True return [] class FallbackClient: def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): raise AssertionError("fallback source should not be searched") snapshot_song_info = SongInfo( source="QQMusicClient", identifier="song-e", song_name="Song E", singers="Singer E", ext="flac", raw_data={"search": {"id": "song-e"}}, download_url=None, download_url_status={}, ) refreshed_song = SongInfo( source="QQMusicClient", identifier="song-e", song_name="Song E", singers="Singer E", ext="flac", file_size_bytes=4096, raw_data={"quality": "lossless"}, download_url="https://example.com/song-e.flac", download_url_status={"ok": True}, ) preferred_client = PreferredClient(refreshed_song) resolver = MultiSourceSongResolver( client_factory=lambda platform: { "qq": preferred_client, "kuwo": FallbackClient(), }[platform] ) resolved_song_info = resolver.resolve_song_info( row={ "platform": "qq", "name": "Song E", "singers": "Singer E", "remote_song_id": "song-e", }, snapshot_song_info=snapshot_song_info, download_sources=["qq", "kuwo"], ) self.assertFalse(preferred_client.search_called) self.assertEqual("QQMusicClient", resolved_song_info.source) self.assertEqual("song-e", resolved_song_info.identifier) def test_resolver_attempts_preferred_source_first_even_when_not_in_download_sources(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeClient: def __init__(self, source, search_results, calls): self.source = source self.search_results = list(search_results or []) self.calls = calls def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): self.calls.append(self.source) return list(self.search_results) snapshot_song_info = SongInfo( source="NeteaseMusicClient", identifier="song-pref", song_name="Song Preferred", singers="Singer Preferred", ext="mp3", raw_data={"search": {"id": "song-pref"}}, download_url=None, download_url_status={}, ) netease_candidate = SongInfo( source="NeteaseMusicClient", identifier="song-pref", song_name="Song Preferred", singers="Singer Preferred", ext="mp3", file_size_bytes=2048, raw_data={"quality": "standard"}, download_url="https://example.com/song-pref.mp3", download_url_status={"ok": True}, ) search_calls = [] resolver = MultiSourceSongResolver( client_factory=lambda platform: { "netease": FakeClient("netease", [netease_candidate], search_calls), "qq": FakeClient("qq", [], search_calls), "kuwo": FakeClient("kuwo", [], search_calls), }[platform] ) resolved_song_info = resolver.resolve_song_info( row={ "platform": "netease", "name": "Song Preferred", "singers": "Singer Preferred", "remote_song_id": "song-pref", }, snapshot_song_info=snapshot_song_info, download_sources=["qq", "kuwo"], ) self.assertEqual(["netease"], search_calls) self.assertEqual("NeteaseMusicClient", resolved_song_info.source) self.assertEqual("song-pref", resolved_song_info.identifier) def test_resolver_reports_source_attempts_to_progress_callback(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeClient: def __init__(self, search_results=None): self.search_results = list(search_results or []) def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): return list(self.search_results) snapshot_song_info = SongInfo( source="NeteaseMusicClient", identifier="song-d", song_name="Song D", singers="Singer D", ext="mp3", raw_data={"search": {"id": "song-d"}}, download_url=None, download_url_status={}, ) kuwo_candidate = SongInfo( source="KuwoMusicClient", identifier="kuwo-song-d", song_name="Song D", singers="Singer D", ext="flac", file_size_bytes=4096, raw_data={"quality": "lossless"}, download_url="https://example.com/song-d.flac", download_url_status={"ok": True}, ) resolver = MultiSourceSongResolver( client_factory=lambda platform: { "netease": FakeClient([]), "qq": FakeClient([]), "kuwo": FakeClient([kuwo_candidate]), }[platform] ) progress_messages: list[str] = [] resolved_song_info = resolver.resolve_song_info( row={ "platform": "netease", "name": "Song D", "singers": "Singer D", "remote_song_id": "song-d", }, snapshot_song_info=snapshot_song_info, download_sources=["qq", "kuwo"], progress_callback=progress_messages.append, ) self.assertEqual( [ "resolving source netease (1/3)", "resolving source qq (2/3)", "resolving source kuwo (3/3)", ], progress_messages, ) self.assertEqual("KuwoMusicClient", resolved_song_info.source) self.assertEqual("kuwo-song-d", resolved_song_info.identifier) def test_resolver_uses_ranked_top_two_fallback_sources_after_warmup(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeStatsRepo: def __init__(self): self.rank_call = None self.records = [] def rank_fallback_sources(self, origin_source, fallback_sources, warmup_attempts=1000): self.rank_call = (origin_source, list(fallback_sources), warmup_attempts) return ["migu", "kuwo", "qianqian"] def record_fallback_result(self, origin_source, candidate_source, *, succeeded): self.records.append((origin_source, candidate_source, succeeded)) class FakeClient: def __init__(self, source, search_results, calls): self.source = source self.search_results = list(search_results or []) self.calls = calls def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): self.calls.append(self.source) return list(self.search_results) snapshot = SongInfo( source="QQMusicClient", identifier="song-1", song_name="Song 1", singers="Singer 1", raw_data={"search": {"id": "song-1"}}, download_url=None, download_url_status={}, ) migu_hit = SongInfo( source="MiguMusicClient", identifier="migu-song-1", song_name="Song 1", singers="Singer 1", ext="mp3", download_url="https://example.com/song-1.mp3", download_url_status={"ok": True}, ) search_calls = [] stats_repo = FakeStatsRepo() resolver = MultiSourceSongResolver( client_factory=lambda platform: { "qq": FakeClient("qq", [], search_calls), "kuwo": FakeClient("kuwo", [], search_calls), "migu": FakeClient("migu", [migu_hit], search_calls), "qianqian": FakeClient("qianqian", [], search_calls), }[platform], resolver_stats_repo=stats_repo, ) resolved = resolver.resolve_song_info( row={ "platform": "qq", "name": "Song 1", "singers": "Singer 1", "remote_song_id": "song-1", }, snapshot_song_info=snapshot, download_sources=["qq", "kuwo", "migu", "qianqian"], ) self.assertEqual( ("qq", ["kuwo", "migu", "qianqian"], 1000), stats_repo.rank_call, ) self.assertEqual(["qq", "migu"], search_calls) self.assertEqual([("qq", "migu", True)], stats_repo.records) self.assertEqual("MiguMusicClient", resolved.source) def test_resolver_continues_after_ranked_top_two_fail(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeStatsRepo: def __init__(self): self.records = [] def rank_fallback_sources(self, origin_source, fallback_sources, warmup_attempts=1000): return ["migu", "kuwo", "qianqian"] def record_fallback_result(self, origin_source, candidate_source, *, succeeded): self.records.append((origin_source, candidate_source, succeeded)) class FakeClient: def __init__(self, source, search_results, calls): self.source = source self.search_results = list(search_results or []) self.calls = calls def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): self.calls.append(self.source) return list(self.search_results) snapshot = SongInfo( source="QQMusicClient", identifier="song-2", song_name="Song 2", singers="Singer 2", raw_data={"search": {"id": "song-2"}}, download_url=None, download_url_status={}, ) qianqian_hit = SongInfo( source="QianqianMusicClient", identifier="qianqian-song-2", song_name="Song 2", singers="Singer 2", ext="mp3", download_url="https://example.com/song-2.mp3", download_url_status={"ok": True}, ) search_calls = [] stats_repo = FakeStatsRepo() resolver = MultiSourceSongResolver( client_factory=lambda platform: { "qq": FakeClient("qq", [], search_calls), "migu": FakeClient("migu", [], search_calls), "kuwo": FakeClient("kuwo", [], search_calls), "qianqian": FakeClient("qianqian", [qianqian_hit], search_calls), }[platform], resolver_stats_repo=stats_repo, ) resolved = resolver.resolve_song_info( row={ "platform": "qq", "name": "Song 2", "singers": "Singer 2", "remote_song_id": "song-2", }, snapshot_song_info=snapshot, download_sources=["qq", "kuwo", "migu", "qianqian"], ) self.assertEqual(["qq", "migu", "kuwo", "qianqian"], search_calls) self.assertEqual( [ ("qq", "migu", False), ("qq", "kuwo", False), ("qq", "qianqian", True), ], stats_repo.records, ) self.assertEqual("QianqianMusicClient", resolved.source) def test_resolver_continues_to_fallback_when_preferred_client_factory_raises(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeClient: def __init__(self, source, search_results, calls): self.source = source self.search_results = list(search_results or []) self.calls = calls def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): self.calls.append(self.source) return list(self.search_results) snapshot_song_info = SongInfo( source="QQMusicClient", identifier="song-pref-fail", song_name="Song Preferred Fail", singers="Singer Preferred Fail", raw_data={"search": {"id": "song-pref-fail"}}, download_url=None, download_url_status={}, ) migu_hit = SongInfo( source="MiguMusicClient", identifier="migu-song-pref-fail", song_name="Song Preferred Fail", singers="Singer Preferred Fail", ext="mp3", download_url="https://example.com/song-pref-fail.mp3", download_url_status={"ok": True}, ) search_calls = [] resolver = MultiSourceSongResolver( client_factory=lambda platform: { "kuwo": FakeClient("kuwo", [], search_calls), "migu": FakeClient("migu", [migu_hit], search_calls), }[platform] ) resolved_song_info = resolver.resolve_song_info( row={ "platform": "qq", "name": "Song Preferred Fail", "singers": "Singer Preferred Fail", "remote_song_id": "song-pref-fail", }, snapshot_song_info=snapshot_song_info, download_sources=["kuwo", "migu"], ) self.assertEqual(["kuwo", "migu"], search_calls) self.assertEqual("MiguMusicClient", resolved_song_info.source) self.assertEqual("migu-song-pref-fail", resolved_song_info.identifier) def test_resolver_uses_configured_fallback_order_when_rank_lookup_raises(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeStatsRepo: def __init__(self): self.records = [] def rank_fallback_sources(self, origin_source, fallback_sources, warmup_attempts=1000): raise RuntimeError("rank unavailable") def record_fallback_result(self, origin_source, candidate_source, *, succeeded): self.records.append((origin_source, candidate_source, succeeded)) class FakeClient: def __init__(self, source, search_results, calls): self.source = source self.search_results = list(search_results or []) self.calls = calls def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): self.calls.append(self.source) return list(self.search_results) snapshot_song_info = SongInfo( source="QQMusicClient", identifier="song-rank-fail", song_name="Song Rank Fail", singers="Singer Rank Fail", raw_data={"search": {"id": "song-rank-fail"}}, download_url=None, download_url_status={}, ) kuwo_hit = SongInfo( source="KuwoMusicClient", identifier="kuwo-song-rank-fail", song_name="Song Rank Fail", singers="Singer Rank Fail", ext="mp3", download_url="https://example.com/song-rank-fail.mp3", download_url_status={"ok": True}, ) search_calls = [] stats_repo = FakeStatsRepo() resolver = MultiSourceSongResolver( client_factory=lambda platform: { "qq": FakeClient("qq", [], search_calls), "kuwo": FakeClient("kuwo", [kuwo_hit], search_calls), "migu": FakeClient("migu", [], search_calls), }[platform], resolver_stats_repo=stats_repo, ) resolved_song_info = resolver.resolve_song_info( row={ "platform": "qq", "name": "Song Rank Fail", "singers": "Singer Rank Fail", "remote_song_id": "song-rank-fail", }, snapshot_song_info=snapshot_song_info, download_sources=["qq", "kuwo", "migu"], ) self.assertEqual(["qq", "kuwo"], search_calls) self.assertEqual([("qq", "kuwo", True)], stats_repo.records) self.assertEqual("KuwoMusicClient", resolved_song_info.source) self.assertEqual("kuwo-song-rank-fail", resolved_song_info.identifier) def test_resolver_continues_when_record_fallback_result_raises(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeStatsRepo: def rank_fallback_sources(self, origin_source, fallback_sources, warmup_attempts=1000): return ["migu", "kuwo"] def record_fallback_result(self, origin_source, candidate_source, *, succeeded): raise RuntimeError("record unavailable") class FakeClient: def __init__(self, source, search_results, calls): self.source = source self.search_results = list(search_results or []) self.calls = calls def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): self.calls.append(self.source) return list(self.search_results) snapshot_song_info = SongInfo( source="QQMusicClient", identifier="song-record-fail", song_name="Song Record Fail", singers="Singer Record Fail", raw_data={"search": {"id": "song-record-fail"}}, download_url=None, download_url_status={}, ) migu_hit = SongInfo( source="MiguMusicClient", identifier="migu-song-record-fail", song_name="Song Record Fail", singers="Singer Record Fail", ext="mp3", download_url="https://example.com/song-record-fail.mp3", download_url_status={"ok": True}, ) search_calls = [] resolver = MultiSourceSongResolver( client_factory=lambda platform: { "qq": FakeClient("qq", [], search_calls), "migu": FakeClient("migu", [migu_hit], search_calls), "kuwo": FakeClient("kuwo", [], search_calls), }[platform], resolver_stats_repo=FakeStatsRepo(), ) resolved_song_info = resolver.resolve_song_info( row={ "platform": "qq", "name": "Song Record Fail", "singers": "Singer Record Fail", "remote_song_id": "song-record-fail", }, snapshot_song_info=snapshot_song_info, download_sources=["qq", "kuwo", "migu"], ) self.assertEqual(["qq", "migu"], search_calls) self.assertEqual("MiguMusicClient", resolved_song_info.source) self.assertEqual("migu-song-record-fail", resolved_song_info.identifier) def test_resolver_continues_when_first_fallback_client_factory_raises(self): from musicdl.catalogsync.resolver import MultiSourceSongResolver from musicdl.modules.utils.data import SongInfo class FakeStatsRepo: def rank_fallback_sources(self, origin_source, fallback_sources, warmup_attempts=1000): return ["migu", "kuwo"] def record_fallback_result(self, origin_source, candidate_source, *, succeeded): return None class FakeClient: def __init__(self, source, search_results, calls): self.source = source self.search_results = list(search_results or []) self.calls = calls def search(self, keyword, num_threadings=1, request_overrides=None, rule=None, main_process_context=None): self.calls.append(self.source) return list(self.search_results) snapshot_song_info = SongInfo( source="QQMusicClient", identifier="song-fallback-factory-fail", song_name="Song Fallback Factory Fail", singers="Singer Fallback Factory Fail", raw_data={"search": {"id": "song-fallback-factory-fail"}}, download_url=None, download_url_status={}, ) kuwo_hit = SongInfo( source="KuwoMusicClient", identifier="kuwo-song-fallback-factory-fail", song_name="Song Fallback Factory Fail", singers="Singer Fallback Factory Fail", ext="mp3", download_url="https://example.com/song-fallback-factory-fail.mp3", download_url_status={"ok": True}, ) search_calls = [] resolver = MultiSourceSongResolver( client_factory=lambda platform: { "qq": FakeClient("qq", [], search_calls), "kuwo": FakeClient("kuwo", [kuwo_hit], search_calls), }[platform], resolver_stats_repo=FakeStatsRepo(), ) resolved_song_info = resolver.resolve_song_info( row={ "platform": "qq", "name": "Song Fallback Factory Fail", "singers": "Singer Fallback Factory Fail", "remote_song_id": "song-fallback-factory-fail", }, snapshot_song_info=snapshot_song_info, download_sources=["qq", "migu", "kuwo"], ) self.assertEqual(["qq", "kuwo"], search_calls) self.assertEqual("KuwoMusicClient", resolved_song_info.source) self.assertEqual("kuwo-song-fallback-factory-fail", resolved_song_info.identifier) if __name__ == "__main__": unittest.main()