music-assistant-server

14.7 KBPY
test_provider_utils.py
14.7 KB370 lines • python
1"""Unit tests for QQ Music provider utility helpers."""
2
3# mypy: ignore-errors
4
5from __future__ import annotations
6
7from types import SimpleNamespace
8
9import pytest
10from music_assistant_models.enums import ProviderFeature
11from music_assistant_models.errors import MediaNotFoundError
12from music_assistant_models.media_items import Album
13from qqmusic_api.modules.song import SongFileType
14
15from music_assistant.providers.qqmusic import (
16    SUPPORTED_FEATURES,
17    QQMusicProvider,
18)
19from music_assistant.providers.qqmusic.constants import (
20    CONF_QUALITY,
21    QUALITY_HI_RES,
22)
23
24
25def test_parse_playlist_id_composite() -> None:
26    """Composite playlist id should parse into dissid/dirid."""
27    provider = QQMusicProvider.__new__(QQMusicProvider)
28    dissid, dirid = provider._parse_playlist_id("12345:678")
29    assert dissid == 12345
30    assert dirid == 678
31
32
33def test_parse_playlist_id_legacy_single() -> None:
34    """Single numeric playlist id should fallback to dirid 0."""
35    provider = QQMusicProvider.__new__(QQMusicProvider)
36    dissid, dirid = provider._parse_playlist_id("12345")
37    assert dissid == 12345
38    assert dirid == 0
39
40
41def test_parse_playlist_id_invalid_raises() -> None:
42    """Invalid playlist id should raise ValueError."""
43    provider = QQMusicProvider.__new__(QQMusicProvider)
44    with pytest.raises(ValueError, match="invalid literal"):
45        provider._parse_playlist_id("invalid:value")
46
47
48def test_supported_features_include_phase1_capabilities() -> None:
49    """QQ Music should expose phase-1 capabilities in provider features."""
50    assert ProviderFeature.SIMILAR_TRACKS in SUPPORTED_FEATURES
51    assert ProviderFeature.PLAYLIST_CREATE in SUPPORTED_FEATURES
52    assert ProviderFeature.PLAYLIST_TRACKS_EDIT in SUPPORTED_FEATURES
53    assert ProviderFeature.LYRICS in SUPPORTED_FEATURES
54
55
56def test_extract_song_id_from_track_payload() -> None:
57    """Track parser helper should extract numeric song id from common keys."""
58    provider = QQMusicProvider.__new__(QQMusicProvider)
59    assert provider._extract_song_id({"id": 123}) == 123
60    assert provider._extract_song_id({"songid": "456"}) == 456
61    assert provider._extract_song_id({"songID": "789"}) == 789
62    assert provider._extract_song_id({"song_id": "bad"}) is None
63
64
65def test_get_candidate_file_types_hires_with_fallback_chain() -> None:
66    """Hi-Res preference should fall back to FLAC -> MP3 320 -> MP3 128."""
67    provider = QQMusicProvider.__new__(QQMusicProvider)
68    provider.config = SimpleNamespace(  # type: ignore[attr-defined]
69        get_value=lambda key: QUALITY_HI_RES if key == CONF_QUALITY else None
70    )
71
72    candidates = provider._get_candidate_file_types()
73    assert candidates == [
74        SongFileType.MASTER,
75        SongFileType.FLAC,
76        SongFileType.MP3_320,
77        SongFileType.MP3_128,
78    ]
79
80
81def test_get_stream_audio_format_for_master() -> None:
82    """MASTER stream type should map to 24-bit/192kHz FLAC format."""
83    provider = QQMusicProvider.__new__(QQMusicProvider)
84
85    stream_format = provider._get_stream_audio_format(SongFileType.MASTER)
86    assert stream_format.content_type.value == "flac"
87    assert stream_format.bit_depth == 24
88    assert stream_format.sample_rate == 192000
89
90
91def test_get_artist_mapping_with_string_singer_returns_none() -> None:
92    """String-only singer payload should not create clickable artist mapping."""
93    provider = QQMusicProvider.__new__(QQMusicProvider)
94    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
95    mapping = provider._get_artist_mapping("王力宏")
96    assert mapping is None
97
98
99def test_parse_artist_with_singer_name_and_highlight() -> None:
100    """Artist parser should map singerName and strip search highlight tags."""
101    provider = QQMusicProvider.__new__(QQMusicProvider)
102    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
103    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
104    artist = provider._parse_artist(
105        {
106            "singerMid": "003Nz2So3XXYek",
107            "singerName": "<em>王力宏</em>",
108            "subtitle": "华语流行男歌手",
109        }
110    )
111    assert artist.item_id == "003Nz2So3XXYek"
112    assert artist.name == "王力宏"
113    assert artist.metadata.description == "华语流行男歌手"
114
115
116def test_parse_artist_uses_avatar_and_description() -> None:
117    """Artist parser should accept avatar url fields."""
118    provider = QQMusicProvider.__new__(QQMusicProvider)
119    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
120    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
121    artist = provider._parse_artist(
122        {
123            "singerMid": "003Nz2So3XXYek",
124            "Name": "王力宏",
125            "Avatar": "//y.qq.com/music/photo_new/T001R500x500M000003Nz2So3XXYek.jpg",
126            "desc": "华语流行歌手",
127        }
128    )
129    assert artist.name == "王力宏"
130    assert artist.metadata.images
131    assert artist.metadata.images[0].path.startswith("https://")
132
133
134def test_parse_playlist_name_strips_highlight_tags() -> None:
135    """Playlist parser should strip highlight tags from search title."""
136    provider = QQMusicProvider.__new__(QQMusicProvider)
137    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
138    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
139    playlist = provider._parse_playlist({"id": 123, "title": "<em>王力宏</em>精选"})
140    assert playlist.name == "王力宏精选"
141
142
143def test_parse_playlist_detail_fields() -> None:
144    """Playlist parser should support dirname, normalized cover and description fields."""
145    provider = QQMusicProvider.__new__(QQMusicProvider)
146    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
147    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
148    playlist = provider._parse_playlist(
149        {
150            "dissid": 7843129912,
151            "dirid": 10,
152            "dirname": "我的收藏",
153            "desc": "这是歌单简介",
154            "picurl": "//y.qq.com/music/photo_new/T003R500x500M0007843129912.jpg",
155            "creator": {"nick": "Alice"},
156        }
157    )
158    assert playlist.name == "我的收藏"
159    assert playlist.owner == "Alice"
160    assert playlist.metadata.description == "这是歌单简介"
161    assert playlist.metadata.images
162    assert playlist.metadata.images[0].path.startswith("https://")
163
164
165def test_parse_track_album_mapping_accepts_album_mid_variants() -> None:
166    """Track parser should map album when album dict uses albumMid-style fields."""
167    provider = QQMusicProvider.__new__(QQMusicProvider)
168    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
169    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
170
171    track = provider._parse_track(
172        {
173            "mid": "003aAYrm3GE0Ac",
174            "title": "稻香",
175            "singer": [{"mid": "0025NhlN2yWrP4", "name": "周杰伦"}],
176            "album": {"albumMid": "001qu4I30eVFYb", "title": "魔杰座"},
177        }
178    )
179    assert track.album is not None
180    assert isinstance(track.album, Album)
181    assert track.album.item_id == "001qu4I30eVFYb"
182    assert track.album.name == "魔杰座"
183
184
185def test_parse_track_sets_version_and_description() -> None:
186    """Track parser should map subtitle to version and desc to metadata description."""
187    provider = QQMusicProvider.__new__(QQMusicProvider)
188    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
189    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
190
191    track = provider._parse_track(
192        {
193            "mid": "003aAYrm3GE0Ac",
194            "title": "唯一",
195            "title_extra": "Live",
196            "desc": "热门现场版",
197            "singer": [{"mid": "003Nz2So3XXYek", "name": "王力宏"}],
198        }
199    )
200    assert track.name == "唯一"
201    assert track.version == "Live"
202    assert track.metadata.description == "热门现场版"
203
204
205def test_parse_track_sets_max_quality_from_file_info() -> None:
206    """Track parser should expose max supported quality on provider mapping."""
207    provider = QQMusicProvider.__new__(QQMusicProvider)
208    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
209    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
210
211    track = provider._parse_track(
212        {
213            "mid": "003aAYrm3GE0Ac",
214            "title": "唯一",
215            "singer": [{"mid": "003Nz2So3XXYek", "name": "王力宏"}],
216            "file": {
217                "size_flac": 51200000,
218                "size_320mp3": 12000000,
219                "size_128mp3": 4000000,
220            },
221        }
222    )
223    provider_mapping = next(iter(track.provider_mappings))
224    assert provider_mapping.audio_format.content_type.value == "flac"
225    assert provider_mapping.audio_format.bit_depth == 16
226    assert provider_mapping.audio_format.sample_rate == 44100
227    assert provider_mapping.details is None
228
229
230def test_parse_track_sets_master_quality_when_available() -> None:
231    """Track parser should prefer master quality when QQ reports it in size_new."""
232    provider = QQMusicProvider.__new__(QQMusicProvider)
233    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
234    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
235
236    track = provider._parse_track(
237        {
238            "mid": "003aAYrm3GE0Ac",
239            "title": "唯一",
240            "singer": [{"mid": "003Nz2So3XXYek", "name": "王力宏"}],
241            "file": {
242                "size_new": [123456789, 0, 0, 0, 0, 0],
243            },
244        }
245    )
246    provider_mapping = next(iter(track.provider_mappings))
247    assert provider_mapping.audio_format.content_type.value == "flac"
248    assert provider_mapping.audio_format.bit_depth == 24
249    assert provider_mapping.audio_format.sample_rate == 192000
250    assert provider_mapping.details == "Hi-Res"
251
252
253def test_parse_track_sets_ogg_quality_when_mp3_fields_missing() -> None:
254    """Track parser should still expose quality label from OGG-only file metadata."""
255    provider = QQMusicProvider.__new__(QQMusicProvider)
256    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
257    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
258
259    track = provider._parse_track(
260        {
261            "mid": "003aAYrm3GE0Ac",
262            "title": "唯一",
263            "singer": [{"mid": "003Nz2So3XXYek", "name": "王力宏"}],
264            "file": {
265                "size_192ogg": 8000000,
266                "size_96ogg": 4000000,
267            },
268        }
269    )
270    provider_mapping = next(iter(track.provider_mappings))
271    assert provider_mapping.audio_format.content_type.value == "ogg"
272    assert provider_mapping.details is None
273
274
275def test_parse_album_sets_version_and_description() -> None:
276    """Album parser should map album subtitle/version and description fields."""
277    provider = QQMusicProvider.__new__(QQMusicProvider)
278    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
279    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
280
281    album = provider._parse_album(
282        {
283            "albumMid": "001qu4I30eVFYb",
284            "title": "唯一",
285            "albumTranName": "纪念版",
286            "description": "经典专辑",
287            "singer": [{"singerMid": "003Nz2So3XXYek", "singerName": "王力宏"}],
288        }
289    )
290    assert album.name == "唯一"
291    assert album.version == "纪念版"
292    assert album.metadata.description == "经典专辑"
293
294
295def test_parse_track_album_mapping_accepts_album_id_fallback() -> None:
296    """Track parser should map album from track-level albumID/albumTitle fields."""
297    provider = QQMusicProvider.__new__(QQMusicProvider)
298    provider.manifest = SimpleNamespace(domain="qqmusic")  # type: ignore[attr-defined]
299    provider.config = SimpleNamespace(instance_id="qqmusic_instance")  # type: ignore[attr-defined]
300
301    track = provider._parse_track(
302        {
303            "mid": "003aAYrm3GE0Ac",
304            "title": "稻香",
305            "albumID": 123456,
306            "albumTitle": "魔杰座",
307            "singerMid": "0025NhlN2yWrP4",
308            "singerName": "周杰伦",
309        }
310    )
311    assert track.album is not None
312    assert isinstance(track.album, Album)
313    assert track.album.item_id == "123456"
314    assert track.album.name == "魔杰座"
315    assert track.artists
316    assert track.artists[0].name == "周杰伦"
317
318
319@pytest.mark.asyncio
320async def test_get_config_entries_exposes_quality_option_without_actions() -> None:
321    """Migrated config entries expose only the quality option and no auth/QR actions."""
322    provider = QQMusicProvider.__new__(QQMusicProvider)
323    entries = await provider.get_config_entries()
324    quality_entry = next(entry for entry in entries if entry.key == CONF_QUALITY)
325    assert [option.value for option in quality_entry.options] == [
326        "mp3_128",
327        "mp3_320",
328        "flac",
329        "hi_res",
330    ]
331    # the action-driven QR/auth pseudo-flow entries are gone after the setup-flow migration
332    assert all(entry.action is None for entry in entries)
333
334
335@pytest.mark.asyncio
336async def test_get_artist_albums_fallback_uses_album_list_on_tab_error() -> None:
337    """Artist albums should fallback to album_list when AlbumTab endpoint fails."""
338    provider = QQMusicProvider.__new__(QQMusicProvider)
339
340    class _DummyTabType:
341        ALBUM = "album"
342
343    async def _raise_not_found(*_args, **_kwargs):
344        raise MediaNotFoundError("not found")
345
346    async def _get_album_list(*_args, **_kwargs):
347        return {
348            "albumList": [
349                {"albumMID": "alb_mid_1", "name": "专辑A"},
350                {"mid": "alb_mid_2", "name": "专辑B"},
351            ]
352        }
353
354    provider._qq_singer = SimpleNamespace(  # type: ignore[attr-defined]
355        TabType=_DummyTabType,
356        get_tab_detail=_raise_not_found,
357        get_album_list=_get_album_list,
358        get_album_list_all=lambda *_args, **_kwargs: [],
359    )
360
361    async def _run_with_session(coro):
362        return await coro
363
364    provider._run_with_session = _run_with_session  # type: ignore[attr-defined]
365    provider._extract_items = QQMusicProvider._extract_items.__get__(provider, QQMusicProvider)  # type: ignore[attr-defined]
366    provider._parse_album = lambda item: str(item.get("albumMID") or item.get("mid"))  # type: ignore[attr-defined]
367
368    albums = await QQMusicProvider.get_artist_albums.__wrapped__(provider, "003Nz2So3XXYek")
369    assert albums == ["alb_mid_1", "alb_mid_2"]
370