/
/
/
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, ProviderMapping, 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_album_enrichment_backfills_external_ids() -> None:
67 """External ids fetched from the provider item (e.g. a barcode) reach the album."""
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.providers = [] # type: ignore[misc]
74 enrichment.mass.music.albums.update_item_in_library = AsyncMock()
75
76 prov_item = Album(
77 item_id="prov1",
78 provider="spotify_1",
79 name="Test Album",
80 provider_mappings=set(),
81 external_ids={(ExternalID.BARCODE, "0602577915181")},
82 metadata=MediaItemMetadata(),
83 )
84 enrichment.mass.music.albums.get_provider_item = AsyncMock(return_value=prov_item)
85
86 album = Album(
87 item_id="1",
88 provider="library",
89 name="Test Album",
90 provider_mappings={
91 ProviderMapping(
92 item_id="prov1", provider_domain="spotify", provider_instance="spotify_1"
93 )
94 },
95 metadata=MediaItemMetadata(),
96 )
97
98 await enrichment._update_album_metadata(album, force_refresh=True)
99
100 assert (ExternalID.BARCODE, "0602577915181") in album.external_ids
101
102
103@pytest.mark.asyncio
104async def test_track_enrichment_survives_provider_error() -> None:
105 """A raising track metadata provider is logged and skipped; later providers still run."""
106 enrichment = MetadataEnrichmentMixin()
107 enrichment.logger = MagicMock()
108 enrichment.mass = MagicMock()
109 enrichment.config = MagicMock()
110 enrichment.config.get_value = _online_metadata_only
111 enrichment.mass.music.tracks.update_item_in_library = AsyncMock()
112
113 boom = _metadata_provider("boom", ProviderFeature.TRACK_METADATA)
114 boom.get_track_metadata = AsyncMock(side_effect=aiohttp.ClientError("network down"))
115 good = _metadata_provider("good", ProviderFeature.TRACK_METADATA)
116 good.get_track_metadata = AsyncMock(return_value=None)
117 enrichment.providers = [boom, good] # type: ignore[misc]
118
119 track = Track(
120 item_id="1",
121 provider="library",
122 name="Test Track",
123 provider_mappings=set(),
124 metadata=MediaItemMetadata(),
125 )
126
127 await enrichment._update_track_metadata(track, force_refresh=True)
128
129 boom.get_track_metadata.assert_awaited_once()
130 good.get_track_metadata.assert_awaited_once()
131 enrichment.logger.warning.assert_called_once()
132
133
134@pytest.mark.asyncio
135async def test_artist_enrichment_survives_provider_error() -> None:
136 """A raising artist metadata provider is logged and skipped; later providers still run."""
137 enrichment = MetadataEnrichmentMixin()
138 enrichment.logger = MagicMock()
139 enrichment.mass = MagicMock()
140 enrichment.config = MagicMock()
141 enrichment.config.get_value = _online_metadata_only
142 enrichment.preferred_language = "en" # type: ignore[misc]
143 enrichment.mass.music.artists.update_item_in_library = AsyncMock()
144
145 boom = _metadata_provider("boom", ProviderFeature.ARTIST_METADATA)
146 boom.get_artist_metadata = AsyncMock(side_effect=aiohttp.ClientError("network down"))
147 good = _metadata_provider("good", ProviderFeature.ARTIST_METADATA)
148 good.get_artist_metadata = AsyncMock(return_value=None)
149 enrichment.providers = [boom, good] # type: ignore[misc]
150
151 artist = Artist(
152 item_id="1",
153 provider="library",
154 name="Test Artist",
155 provider_mappings=set(),
156 external_ids={(ExternalID.MB_ARTIST, "11111111-1111-1111-1111-111111111111")},
157 metadata=MediaItemMetadata(),
158 )
159
160 await enrichment._update_artist_metadata(artist, force_refresh=True)
161
162 boom.get_artist_metadata.assert_awaited_once()
163 good.get_artist_metadata.assert_awaited_once()
164 enrichment.logger.warning.assert_called_once()
165