/
/
1"""Tests for metadata enrichment resilience to provider failures."""
2
3from __future__ import annotations
4
5from typing import Any
6from unittest.mock import AsyncMock, MagicMock
7
8import aiohttp
9import pytest
10from music_assistant_models.enums import ExternalID, ProviderFeature
11from music_assistant_models.media_items import Album, Artist, Track
12from music_assistant_models.media_items.metadata import MediaItemMetadata
13
14from music_assistant.controllers.metadata.constants import CONF_ENABLE_ONLINE_METADATA
15from music_assistant.controllers.metadata.enrichment import MetadataEnrichmentMixin
16
17
18def _online_metadata_only(key: str, *_args: Any, **_kwargs: Any) -> bool:
19 """Config stub: only online metadata is enabled (prefer-local-genres off)."""
20 return key == CONF_ENABLE_ONLINE_METADATA
21
22
23def _metadata_provider(name: str, feature: ProviderFeature) -> MagicMock:
24 """Return a mock metadata provider advertising a single metadata feature."""
25 provider = MagicMock()
26 provider.name = name
27 provider.priority = 0
28 provider.supported_features = {feature}
29 return provider
30
31
32@pytest.mark.asyncio
33async def test_album_enrichment_survives_provider_error() -> None:
34 """A raising album metadata provider is logged and skipped; later providers still run."""
35 enrichment = MetadataEnrichmentMixin()
36 enrichment.logger = MagicMock()
37 enrichment.mass = MagicMock()
38 enrichment.config = MagicMock()
39 enrichment.config.get_value = _online_metadata_only
40 enrichment.mass.music.albums.update_item_in_library = AsyncMock()
41
42 boom = _metadata_provider("boom", ProviderFeature.ALBUM_METADATA)
43 boom.get_album_metadata = AsyncMock(side_effect=aiohttp.ClientError("network down"))
44 good = _metadata_provider("good", ProviderFeature.ALBUM_METADATA)
45 good.get_album_metadata = AsyncMock(return_value=None)
46 enrichment.providers = [boom, good] # type: ignore[misc]
47
48 album = Album(
49 item_id="1",
50 provider="library",
51 name="Test Album",
52 provider_mappings=set(),
53 metadata=MediaItemMetadata(),
54 )
55
56 # a transient provider error must not abort enrichment
57 await enrichment._update_album_metadata(album, force_refresh=True)
58
59 boom.get_album_metadata.assert_awaited_once()
60 good.get_album_metadata.assert_awaited_once() # loop continued past the failing provider
61 enrichment.logger.warning.assert_called_once()
62 enrichment.mass.music.albums.update_item_in_library.assert_awaited_once()
63
64
65@pytest.mark.asyncio
66async def test_track_enrichment_survives_provider_error() -> None:
67 """A raising track metadata provider is logged and skipped; later providers still run."""
68 enrichment = MetadataEnrichmentMixin()
69 enrichment.logger = MagicMock()
70 enrichment.mass = MagicMock()
71 enrichment.config = MagicMock()
72 enrichment.config.get_value = _online_metadata_only
73 enrichment.mass.music.tracks.update_item_in_library = AsyncMock()
74
75 boom = _metadata_provider("boom", ProviderFeature.TRACK_METADATA)
76 boom.get_track_metadata = AsyncMock(side_effect=aiohttp.ClientError("network down"))
77 good = _metadata_provider("good", ProviderFeature.TRACK_METADATA)
78 good.get_track_metadata = AsyncMock(return_value=None)
79 enrichment.providers = [boom, good] # type: ignore[misc]
80
81 track = Track(
82 item_id="1",
83 provider="library",
84 name="Test Track",
85 provider_mappings=set(),
86 metadata=MediaItemMetadata(),
87 )
88
89 await enrichment._update_track_metadata(track, force_refresh=True)
90
91 boom.get_track_metadata.assert_awaited_once()
92 good.get_track_metadata.assert_awaited_once()
93 enrichment.logger.warning.assert_called_once()
94
95
96@pytest.mark.asyncio
97async def test_artist_enrichment_survives_provider_error() -> None:
98 """A raising artist metadata provider is logged and skipped; later providers still run."""
99 enrichment = MetadataEnrichmentMixin()
100 enrichment.logger = MagicMock()
101 enrichment.mass = MagicMock()
102 enrichment.config = MagicMock()
103 enrichment.config.get_value = _online_metadata_only
104 enrichment.preferred_language = "en" # type: ignore[misc]
105 enrichment.mass.music.artists.update_item_in_library = AsyncMock()
106
107 boom = _metadata_provider("boom", ProviderFeature.ARTIST_METADATA)
108 boom.get_artist_metadata = AsyncMock(side_effect=aiohttp.ClientError("network down"))
109 good = _metadata_provider("good", ProviderFeature.ARTIST_METADATA)
110 good.get_artist_metadata = AsyncMock(return_value=None)
111 enrichment.providers = [boom, good] # type: ignore[misc]
112
113 artist = Artist(
114 item_id="1",
115 provider="library",
116 name="Test Artist",
117 provider_mappings=set(),
118 external_ids={(ExternalID.MB_ARTIST, "11111111-1111-1111-1111-111111111111")},
119 metadata=MediaItemMetadata(),
120 )
121
122 await enrichment._update_artist_metadata(artist, force_refresh=True)
123
124 boom.get_artist_metadata.assert_awaited_once()
125 good.get_artist_metadata.assert_awaited_once()
126 enrichment.logger.warning.assert_called_once()
127