/
/
/
1"""Tests for playlist import track matching and scoring logic."""
2
3from __future__ import annotations
4
5from typing import Any, cast
6from unittest.mock import AsyncMock, MagicMock
7
8from music_assistant_models.enums import ExternalID, ImageType, MediaType
9from music_assistant_models.media_items import (
10 Artist,
11 ItemMapping,
12 MediaItemImage,
13 MediaItemMetadata,
14 Playlist,
15 ProviderMapping,
16 Track,
17 UniqueList,
18)
19
20from music_assistant.helpers.playlists import (
21 PlaylistItem,
22 ProviderMappingInfo,
23 generate_m3u,
24)
25from music_assistant.providers.builtin import BuiltinProvider
26
27
28def _make_provider() -> BuiltinProvider:
29 """Create a minimal BuiltinProvider with mocked mass."""
30 mass = MagicMock()
31 prov = object.__new__(BuiltinProvider)
32 prov.mass = mass
33 prov.logger = MagicMock()
34 return prov
35
36
37def _make_track(
38 name: str,
39 artists: list[str] | None = None,
40 duration: int = 0,
41 album_name: str | None = None,
42 version: str = "",
43 isrc: str | None = None,
44 mbid: str | None = None,
45 media_type: MediaType = MediaType.TRACK,
46) -> Track:
47 """Build a Track for matching tests."""
48 artist_list: UniqueList[Artist | ItemMapping] = UniqueList()
49 for a in artists or []:
50 artist_list.append(
51 ItemMapping(item_id=a, provider="test", name=a, media_type=MediaType.ARTIST)
52 )
53 external_ids: set[tuple[ExternalID, str]] = set()
54 if isrc:
55 external_ids.add((ExternalID.ISRC, isrc))
56 if mbid:
57 external_ids.add((ExternalID.MB_RECORDING, mbid))
58 album_mapping = None
59 if album_name:
60 album_mapping = ItemMapping(
61 item_id=album_name, provider="test", name=album_name, media_type=MediaType.ALBUM
62 )
63 track = Track(
64 item_id="test123",
65 provider="test",
66 name=name,
67 version=version,
68 duration=duration,
69 artists=artist_list,
70 album=album_mapping,
71 external_ids=external_ids,
72 provider_mappings={
73 ProviderMapping(
74 item_id="test123",
75 provider_domain="test",
76 provider_instance="test",
77 )
78 },
79 )
80 track.media_type = media_type
81 return track
82
83
84def _make_playlist_item(
85 title: str | None = None,
86 length: str | None = None,
87 metadata: dict[str, str] | None = None,
88 providers: list[ProviderMappingInfo] | None = None,
89) -> PlaylistItem:
90 """Build a PlaylistItem for matching tests."""
91 return PlaylistItem(
92 path="spotify://track/original",
93 title=title,
94 length=length,
95 metadata=metadata,
96 providers=providers or [],
97 )
98
99
100def _make_playlist(name: str, image_url: str | None = None) -> Playlist:
101 """Build a Playlist for builtin provider tests."""
102 metadata = MediaItemMetadata()
103 if image_url:
104 metadata.images = UniqueList(
105 [
106 MediaItemImage(
107 type=ImageType.THUMB,
108 path=image_url,
109 provider="builtin",
110 remotely_accessible=True,
111 )
112 ]
113 )
114 return Playlist(
115 item_id="playlist_1",
116 provider="builtin",
117 name=name,
118 metadata=metadata,
119 provider_mappings={
120 ProviderMapping(
121 item_id="playlist_1",
122 provider_domain="builtin",
123 provider_instance="builtin",
124 )
125 },
126 )
127
128
129# --------------------------------------------------------------------------- #
130# _score_track_match #
131# --------------------------------------------------------------------------- #
132
133
134class TestScoreTrackMatch:
135 """Tests for the _score_track_match scoring method."""
136
137 def setup_method(self) -> None:
138 """Set up test fixtures."""
139 self.prov = _make_provider()
140
141 def test_isrc_match_returns_max_score(self) -> None:
142 """ISRC match should return 10 (maximum score)."""
143 candidate = _make_track("Song", artists=["Artist"], isrc="USRC17607839")
144 item = _make_playlist_item(
145 title="Artist - Song",
146 metadata={"isrc": "USRC17607839"},
147 )
148 assert self.prov._score_track_match(candidate, item) == 10
149
150 def test_isrc_match_case_insensitive(self) -> None:
151 """ISRC matching should be case-insensitive."""
152 candidate = _make_track("Song", artists=["Artist"], isrc="usrc17607839")
153 item = _make_playlist_item(
154 title="Artist - Song",
155 metadata={"isrc": "USRC17607839"},
156 )
157 assert self.prov._score_track_match(candidate, item) == 10
158
159 def test_mbid_match_returns_max_score(self) -> None:
160 """MusicBrainz recording ID match should return 10."""
161 candidate = _make_track(
162 "Song", artists=["Artist"], mbid="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
163 )
164 item = _make_playlist_item(
165 title="Artist - Song",
166 metadata={"mbid": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"},
167 )
168 assert self.prov._score_track_match(candidate, item) == 10
169
170 def test_title_match_only(self) -> None:
171 """Title-only match (no artist) should score 1."""
172 candidate = _make_track("Song Title")
173 item = _make_playlist_item(title="Song Title")
174 assert self.prov._score_track_match(candidate, item) == 1
175
176 def test_title_mismatch_returns_zero(self) -> None:
177 """Non-matching title should return 0."""
178 candidate = _make_track("Completely Different")
179 item = _make_playlist_item(title="Song Title")
180 assert self.prov._score_track_match(candidate, item) == 0
181
182 def test_title_and_artist_match(self) -> None:
183 """Title + artist match should score 3 (1 title + 2 artist)."""
184 candidate = _make_track("Song", artists=["Radiohead"])
185 item = _make_playlist_item(title="Radiohead - Song")
186 assert self.prov._score_track_match(candidate, item) == 3
187
188 def test_artist_mismatch_returns_zero(self) -> None:
189 """When artist is provided but doesn't match, return 0."""
190 candidate = _make_track("Song", artists=["Coldplay"])
191 item = _make_playlist_item(title="Radiohead - Song")
192 assert self.prov._score_track_match(candidate, item) == 0
193
194 def test_album_bonus(self) -> None:
195 """Matching album adds 1 to the score."""
196 candidate = _make_track("Song", artists=["Artist"], album_name="OK Computer")
197 item = _make_playlist_item(
198 title="Artist - Song",
199 metadata={"album": "OK Computer"},
200 )
201 # 1 (title) + 2 (artist) + 1 (album) = 4
202 assert self.prov._score_track_match(candidate, item) == 4
203
204 def test_duration_near_exact_bonus(self) -> None:
205 """Duration within 2 seconds adds 2 to the score."""
206 candidate = _make_track("Song", artists=["Artist"], duration=241)
207 item = _make_playlist_item(title="Artist - Song", length="240")
208 # 1 (title) + 2 (artist) + 2 (duration) = 5
209 assert self.prov._score_track_match(candidate, item) == 5
210
211 def test_duration_close_bonus(self) -> None:
212 """Duration within 5 seconds adds 1 to the score."""
213 candidate = _make_track("Song", artists=["Artist"], duration=245)
214 item = _make_playlist_item(title="Artist - Song", length="240")
215 # 1 (title) + 2 (artist) + 1 (duration close) = 4
216 assert self.prov._score_track_match(candidate, item) == 4
217
218 def test_duration_too_far_no_bonus(self) -> None:
219 """Duration beyond 5 seconds gets no bonus."""
220 candidate = _make_track("Song", artists=["Artist"], duration=260)
221 item = _make_playlist_item(title="Artist - Song", length="240")
222 # 1 (title) + 2 (artist) = 3
223 assert self.prov._score_track_match(candidate, item) == 3
224
225 def test_version_match_bonus(self) -> None:
226 """Matching version adds 1."""
227 candidate = _make_track("Song", artists=["Artist"], version="Remastered")
228 item = _make_playlist_item(
229 title="Artist - Song",
230 metadata={"version": "Remastered"},
231 )
232 # 1 (title) + 2 (artist) + 1 (version) = 4
233 assert self.prov._score_track_match(candidate, item) == 4
234
235 def test_version_mismatch_penalty(self) -> None:
236 """Mismatched version subtracts 1."""
237 candidate = _make_track("Song", artists=["Artist"], version="Live")
238 item = _make_playlist_item(
239 title="Artist - Song",
240 metadata={"version": "Remastered"},
241 )
242 # 1 (title) + 2 (artist) - 1 (version mismatch) = 2
243 assert self.prov._score_track_match(candidate, item) == 2
244
245 def test_media_type_gate(self) -> None:
246 """Mismatched media type should return 0."""
247 candidate = _make_track("Song", artists=["Artist"])
248 item = _make_playlist_item(
249 title="Artist - Song",
250 metadata={"media_type": "podcast_episode"},
251 )
252 assert self.prov._score_track_match(candidate, item) == 0
253
254 def test_none_title_returns_zero(self) -> None:
255 """PlaylistItem with no title should score 0."""
256 candidate = _make_track("Song")
257 item = _make_playlist_item(title=None)
258 assert self.prov._score_track_match(candidate, item) == 0
259
260 def test_full_metadata_high_score(self) -> None:
261 """Track with all metadata matching should get a high score."""
262 candidate = _make_track(
263 "Everything In Its Right Place",
264 artists=["Radiohead"],
265 duration=240,
266 album_name="Kid A",
267 version="Remastered",
268 )
269 item = _make_playlist_item(
270 title="Radiohead - Everything In Its Right Place",
271 length="240",
272 metadata={
273 "media_type": "track",
274 "album": "Kid A",
275 "version": "Remastered",
276 },
277 )
278 # 1 (title) + 2 (artist) + 1 (album) + 1 (version) + 2 (duration exact) = 7
279 assert self.prov._score_track_match(candidate, item) == 7
280
281
282async def test_import_playlist_preserves_playlist_image() -> None:
283 """Test that importing an M3U keeps the playlist-level image."""
284 prov = _make_provider()
285 prov_any = cast("Any", prov)
286 prov_any.create_playlist = AsyncMock(return_value=_make_playlist("Imported Playlist"))
287 prov_any.get_playlist = AsyncMock(
288 return_value=_make_playlist("Imported Playlist", "https://img.example.com/cover.jpg")
289 )
290 prov_any._write_m3u_file = AsyncMock()
291
292 m3u_data = generate_m3u(
293 "Imported Playlist",
294 [PlaylistItem(path="spotify://track/abc123", title="Test", length="120")],
295 "https://img.example.com/cover.jpg",
296 )
297
298 result = await prov.import_playlist(m3u_data)
299
300 assert prov_any._write_m3u_file.await_args is not None
301 args = prov_any._write_m3u_file.await_args.args
302 assert args[0] == "playlist_1"
303 assert args[1] == "Imported Playlist"
304 assert args[3] == "https://img.example.com/cover.jpg"
305 assert result.image is not None
306 assert result.image.path == "https://img.example.com/cover.jpg"
307
308
309async def test_remove_playlist_tracks_preserves_playlist_image() -> None:
310 """Test that rewriting a playlist after track removal keeps the playlist image."""
311 prov = _make_provider()
312 prov._playlist_locks = {}
313 prov_any = cast("Any", prov)
314 prov_any._read_m3u_file = AsyncMock(
315 return_value=generate_m3u(
316 "My Playlist",
317 [
318 PlaylistItem(path="spotify://track/one", title="One", length="120"),
319 PlaylistItem(path="spotify://track/two", title="Two", length="180"),
320 ],
321 "https://img.example.com/cover.jpg",
322 )
323 )
324 prov_any.get_playlist = AsyncMock(
325 return_value=_make_playlist("My Playlist", "https://img.example.com/cover.jpg")
326 )
327 prov_any._write_m3u_file = AsyncMock()
328
329 await prov.remove_playlist_tracks("playlist_1", (1,))
330
331 assert prov_any._write_m3u_file.await_args is not None
332 args = prov_any._write_m3u_file.await_args.args
333 assert args[0] == "playlist_1"
334 assert args[1] == "My Playlist"
335 assert len(args[2]) == 1
336 assert args[3] == "https://img.example.com/cover.jpg"
337