/
/
/
1"""Tests for the Smart Playlist plugin provider."""
2
3from __future__ import annotations
4
5import asyncio
6import json
7import logging
8import time
9from typing import Any, cast
10from unittest.mock import AsyncMock, MagicMock
11
12import pytest
13from music_assistant_models.enums import AlbumType, ImageType, ProviderFeature, ProviderType
14from music_assistant_models.errors import InvalidDataError
15from music_assistant_models.media_items import (
16 Genre,
17 MediaItemImage,
18 Playlist,
19 ProviderMapping,
20 Track,
21)
22from music_assistant_models.media_items.metadata import MediaItemMetadata
23from music_assistant_models.unique_list import UniqueList
24
25from music_assistant.constants import DYNAMIC_PLAYLIST_SAMPLE_SIZE
26from music_assistant.helpers.track_filter import track_filter
27from music_assistant.models.plugin import AIEngine, PluginProvider
28from music_assistant.providers.smart_playlist import (
29 CONF_AI_DESCRIPTIONS,
30 CONF_AI_ENGINE,
31 SmartPlaylistProvider,
32)
33from music_assistant.providers.smart_playlist.helpers import (
34 LOGIC_AND,
35 LOGIC_OR,
36 RULES_FILENAME,
37 SmartPlaylistRules,
38 write_json,
39)
40from tests.common import use_real_create_task
41
42# ---------------------------------------------------------------------------
43# SmartPlaylistRules unit tests
44# ---------------------------------------------------------------------------
45
46
47class TestSmartPlaylistRules:
48 """Tests for the SmartPlaylistRules dataclass."""
49
50 def test_defaults(self) -> None:
51 """Rules are created with sensible defaults."""
52 rules = SmartPlaylistRules()
53 assert rules.genre_ids == []
54 assert rules.artist_ids == []
55 assert rules.album_ids == []
56 assert rules.favorites_only is False
57 assert rules.seed_track_uris == []
58 assert rules.seed_artist_uris == []
59 assert rules.seed_album_uris == []
60 assert rules.seed_playlist_uris == []
61 assert rules.min_popularity is None
62 assert rules.logic == LOGIC_AND
63 assert rules.limit == 100
64
65 def test_round_trip_serialization(self) -> None:
66 """to_dict / from_dict round-trip preserves all fields."""
67 original = SmartPlaylistRules(
68 genre_ids=[1, 2, 3],
69 artist_ids=[10],
70 album_ids=[],
71 favorites_only=True,
72 seed_track_uris=["library://track/42", "library://track/43"],
73 seed_artist_uris=["library://artist/7"],
74 seed_album_uris=["library://album/3"],
75 seed_playlist_uris=["library://playlist/9"],
76 seed_names={"library://track/42": "Some Track"},
77 min_popularity=50,
78 logic=LOGIC_OR,
79 limit=25,
80 )
81 recovered = SmartPlaylistRules.from_dict(original.to_dict())
82 assert recovered == original
83
84 def test_all_seed_uris_dedupes_across_lists(self) -> None:
85 """all_seed_uris() returns each URI once even when duplicated across lists."""
86 rules = SmartPlaylistRules(
87 seed_track_uris=["a", "b"],
88 seed_artist_uris=["b", "c"],
89 seed_album_uris=["d"],
90 seed_playlist_uris=[],
91 )
92 assert rules.all_seed_uris() == ["a", "b", "c", "d"]
93
94 def test_from_dict_partial(self) -> None:
95 """from_dict tolerates missing keys by using defaults."""
96 rules = SmartPlaylistRules.from_dict({"favorites_only": True})
97 assert rules.favorites_only is True
98 assert rules.genre_ids == []
99 assert rules.logic == LOGIC_AND
100
101 def test_human_readable_no_rules(self) -> None:
102 """human_readable for empty rules returns fallback message."""
103 rules = SmartPlaylistRules()
104 assert "No rules" in rules.human_readable()
105
106 def test_human_readable_with_rules(self) -> None:
107 """human_readable includes all active filter names."""
108 rules = SmartPlaylistRules(
109 genre_ids=[1],
110 favorites_only=True,
111 min_popularity=60,
112 logic=LOGIC_AND,
113 )
114 summary = rules.human_readable()
115 assert "Favorites only" in summary
116 assert "Genre" in summary
117 assert "popularity" in summary.lower()
118 assert LOGIC_AND in summary
119
120 def test_human_readable_or_logic(self) -> None:
121 """human_readable uses OR as connector when logic=OR."""
122 rules = SmartPlaylistRules(
123 genre_ids=[1],
124 artist_ids=[5],
125 logic=LOGIC_OR,
126 )
127 assert LOGIC_OR in rules.human_readable()
128
129 def test_from_dict_null_list_fields_treated_as_empty(self) -> None:
130 """from_dict treats null for list fields as empty list."""
131 rules = SmartPlaylistRules.from_dict(
132 {"genre_ids": None, "artist_ids": None, "album_ids": None}
133 )
134 assert rules.genre_ids == []
135 assert rules.artist_ids == []
136 assert rules.album_ids == []
137
138 def test_from_dict_null_dict_fields_treated_as_empty(self) -> None:
139 """from_dict treats null for dict fields as empty dict."""
140 rules = SmartPlaylistRules.from_dict(
141 {"genre_names": None, "artist_names": None, "album_names": None}
142 )
143 assert rules.genre_names == {}
144 assert rules.artist_names == {}
145 assert rules.album_names == {}
146
147 def test_from_dict_non_numeric_id_raises(self) -> None:
148 """from_dict raises InvalidDataError for non-numeric ids."""
149 with pytest.raises(InvalidDataError):
150 SmartPlaylistRules.from_dict({"genre_ids": ["abc"]})
151
152 def test_from_dict_wrong_type_for_names_dict_raises(self) -> None:
153 """from_dict raises InvalidDataError when a names field is not a dict."""
154 with pytest.raises(InvalidDataError):
155 SmartPlaylistRules.from_dict({"genre_names": "invalid"})
156
157 def test_from_dict_excluded_null_fields_treated_as_empty(self) -> None:
158 """from_dict treats null for excluded_* fields as empty."""
159 rules = SmartPlaylistRules.from_dict(
160 {
161 "excluded_artist_ids": None,
162 "excluded_album_ids": None,
163 "excluded_track_uris": None,
164 "excluded_artist_names": None,
165 "excluded_album_names": None,
166 }
167 )
168 assert rules.excluded_artist_ids == []
169 assert rules.excluded_album_ids == []
170 assert rules.excluded_track_uris == []
171 assert rules.excluded_artist_names == {}
172 assert rules.excluded_album_names == {}
173
174 def test_duration_fields_round_trip(self) -> None:
175 """min_duration and max_duration survive serialization."""
176 original = SmartPlaylistRules(min_duration=180, max_duration=600)
177 recovered = SmartPlaylistRules.from_dict(original.to_dict())
178 assert recovered.min_duration == 180
179 assert recovered.max_duration == 600
180
181 def test_last_played_field_round_trip(self) -> None:
182 """last_played_before_value and last_played_before_unit survive serialization."""
183 original = SmartPlaylistRules(last_played_before_value=30, last_played_before_unit="days")
184 recovered = SmartPlaylistRules.from_dict(original.to_dict())
185 assert recovered.last_played_before_value == 30
186 assert recovered.last_played_before_unit == "days"
187
188 def test_from_dict_null_duration_fields_treated_as_none(self) -> None:
189 """from_dict treats null for duration and last_played fields as None."""
190 rules = SmartPlaylistRules.from_dict(
191 {
192 "min_duration": None,
193 "max_duration": None,
194 "last_played_before_value": None,
195 "last_played_before_unit": None,
196 }
197 )
198 assert rules.min_duration is None
199 assert rules.max_duration is None
200 assert rules.last_played_before_value is None
201 assert rules.last_played_before_unit is None
202
203
204# ---------------------------------------------------------------------------
205# Plugin validation tests
206# ---------------------------------------------------------------------------
207
208
209class TestRuleValidation:
210 """Tests for _validate_rules inside the plugin."""
211
212 def _make_plugin(self) -> SmartPlaylistProvider:
213 """Create a SmartPlaylistProvider with mocked mass."""
214 mass = MagicMock()
215 manifest = MagicMock()
216 manifest.domain = "smart_playlist"
217 config = MagicMock()
218 config.get_value.return_value = "GLOBAL"
219 return SmartPlaylistProvider(mass, manifest, config, set())
220
221 def test_valid_rules_pass(self) -> None:
222 """Valid rules do not raise."""
223 plugin = self._make_plugin()
224 rules = SmartPlaylistRules(logic=LOGIC_AND, limit=50)
225 plugin._validate_rules(rules) # should not raise
226
227 def test_invalid_logic_raises(self) -> None:
228 """Unknown logic operator raises InvalidDataError."""
229 plugin = self._make_plugin()
230 rules = SmartPlaylistRules(logic="XOR")
231 with pytest.raises(InvalidDataError, match="logic"):
232 plugin._validate_rules(rules)
233
234 def test_limit_out_of_range_raises(self) -> None:
235 """Limit outside 1-2000 raises InvalidDataError."""
236 plugin = self._make_plugin()
237 rules = SmartPlaylistRules(limit=0)
238 with pytest.raises(InvalidDataError, match="limit"):
239 plugin._validate_rules(rules)
240
241 rules_too_high = SmartPlaylistRules(limit=9999)
242 with pytest.raises(InvalidDataError, match="limit"):
243 plugin._validate_rules(rules_too_high)
244
245 def test_popularity_out_of_range_raises(self) -> None:
246 """min_popularity outside 0-100 raises InvalidDataError."""
247 plugin = self._make_plugin()
248 rules = SmartPlaylistRules(min_popularity=150)
249 with pytest.raises(InvalidDataError, match="popularity"):
250 plugin._validate_rules(rules)
251
252 def test_too_many_seeds_raises(self) -> None:
253 """More than MAX_SEEDS combined seeds raises InvalidDataError."""
254 plugin = self._make_plugin()
255 rules = SmartPlaylistRules(
256 seed_track_uris=[f"library://track/{i}" for i in range(6)],
257 seed_artist_uris=[f"library://artist/{i}" for i in range(6)],
258 )
259 with pytest.raises(InvalidDataError, match="Too many seeds"):
260 plugin._validate_rules(rules)
261
262 def test_negative_min_duration_raises(self) -> None:
263 """Negative min_duration raises InvalidDataError."""
264 plugin = self._make_plugin()
265 rules = SmartPlaylistRules(min_duration=-10)
266 with pytest.raises(InvalidDataError, match="min_duration"):
267 plugin._validate_rules(rules)
268
269 def test_negative_max_duration_raises(self) -> None:
270 """Negative max_duration raises InvalidDataError."""
271 plugin = self._make_plugin()
272 rules = SmartPlaylistRules(max_duration=-5)
273 with pytest.raises(InvalidDataError, match="max_duration"):
274 plugin._validate_rules(rules)
275
276 def test_min_duration_greater_than_max_raises(self) -> None:
277 """min_duration > max_duration raises InvalidDataError."""
278 plugin = self._make_plugin()
279 rules = SmartPlaylistRules(min_duration=600, max_duration=300)
280 with pytest.raises(InvalidDataError, match=r"min_duration.*max_duration"):
281 plugin._validate_rules(rules)
282
283 def test_last_played_before_value_zero_raises(self) -> None:
284 """last_played_before_value < 1 raises InvalidDataError."""
285 plugin = self._make_plugin()
286 rules = SmartPlaylistRules(last_played_before_value=0, last_played_before_unit="days")
287 with pytest.raises(InvalidDataError, match="last_played_before_value"):
288 plugin._validate_rules(rules)
289
290 def test_valid_duration_and_last_played_pass(self) -> None:
291 """Valid duration and last_played values do not raise."""
292 plugin = self._make_plugin()
293 rules = SmartPlaylistRules(
294 min_duration=180,
295 max_duration=600,
296 last_played_before_value=30,
297 last_played_before_unit="days",
298 )
299 plugin._validate_rules(rules) # should not raise
300
301 def test_last_played_invalid_unit_raises(self) -> None:
302 """Invalid last_played_before_unit raises InvalidDataError."""
303 plugin = self._make_plugin()
304 rules = SmartPlaylistRules(last_played_before_value=10, last_played_before_unit="years")
305 with pytest.raises(InvalidDataError, match="last_played_before_unit"):
306 plugin._validate_rules(rules)
307
308 def test_last_played_only_value_set_raises(self) -> None:
309 """Only last_played_before_value set (no unit) raises InvalidDataError."""
310 plugin = self._make_plugin()
311 rules = SmartPlaylistRules(last_played_before_value=10, last_played_before_unit=None)
312 with pytest.raises(InvalidDataError, match="last_played"):
313 plugin._validate_rules(rules)
314
315 def test_last_played_only_unit_set_raises(self) -> None:
316 """Only last_played_before_unit set (no value) raises InvalidDataError."""
317 plugin = self._make_plugin()
318 rules = SmartPlaylistRules(last_played_before_value=None, last_played_before_unit="days")
319 with pytest.raises(InvalidDataError, match="last_played"):
320 plugin._validate_rules(rules)
321
322
323# ---------------------------------------------------------------------------
324# Persistence tests (using tmp_path, no real MA instance needed)
325# ---------------------------------------------------------------------------
326
327
328@pytest.mark.asyncio
329async def test_rules_persist_to_disk(tmp_path: Any) -> None:
330 """Rules saved to disk survive plugin reload."""
331 rules_dir = tmp_path / "smart_playlists"
332 rules_dir.mkdir()
333
334 mass = MagicMock()
335 mass.storage_path = str(tmp_path)
336 mass.cache.clear = AsyncMock()
337 manifest = MagicMock()
338 manifest.domain = "smart_playlist"
339 config = MagicMock()
340 config.get_value.return_value = "GLOBAL"
341
342 plugin = SmartPlaylistProvider(mass, manifest, config, set())
343 await plugin.handle_async_init()
344 plugin._rules_dir = str(rules_dir)
345
346 rules = SmartPlaylistRules(genre_ids=[1, 2], favorites_only=True)
347 await plugin._save_rules("42", rules)
348
349 # Simulate reload
350 plugin2 = SmartPlaylistProvider(mass, manifest, config, set())
351 await plugin2.handle_async_init()
352 plugin2._rules_dir = str(rules_dir)
353 plugin2._rules_store = {}
354 plugin2._names_store = {}
355 await plugin2._load_rules_from_disk()
356
357 assert "42" in plugin2._rules_store
358 assert plugin2._rules_store["42"] == rules
359
360
361# ---------------------------------------------------------------------------
362# Evaluate-rules unit tests with mocked mass
363# ---------------------------------------------------------------------------
364
365
366def _make_mock_track(
367 item_id: str = "1",
368 uri: str = "library://track/1",
369 artist_ids: list[str] | None = None,
370 album_id: str | None = None,
371 favorite: bool = False,
372 popularity: int | None = None,
373 provider_instance: str = "library",
374 duration: int | None = None,
375 last_played: int = 0,
376 explicit: bool | None = None,
377) -> MagicMock:
378 """Build a minimal mock Track object."""
379 track = MagicMock()
380 track.item_id = item_id
381 track.uri = uri
382 track.name = f"Track {item_id}"
383 track.favorite = favorite
384 track.duration = duration
385 track.last_played = last_played
386
387 mapping = MagicMock()
388 mapping.provider_instance = provider_instance
389 mapping.item_id = item_id
390 track.provider_mappings = [mapping]
391
392 artist = MagicMock()
393 artist.item_id = (artist_ids or ["100"])[0]
394 artist.name = "Artist"
395 track.artists = [artist]
396
397 album = MagicMock()
398 album.item_id = album_id or "200"
399 track.album = album
400
401 track.metadata = MagicMock()
402 track.metadata.popularity = popularity
403 track.metadata.explicit = explicit
404
405 return track
406
407
408@pytest.mark.asyncio
409async def test_evaluate_and_no_filters_returns_library() -> None:
410 """With no filters, AND logic returns the entire library."""
411 mass = MagicMock()
412 manifest = MagicMock()
413 manifest.domain = "smart_playlist"
414 config = MagicMock()
415 config.get_value.return_value = "GLOBAL"
416 plugin = SmartPlaylistProvider(mass, manifest, config, set())
417
418 tracks = [_make_mock_track(str(i), f"library://track/{i}") for i in range(10)]
419 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=tracks)
420
421 rules = SmartPlaylistRules(logic=LOGIC_AND, limit=10)
422 result = await plugin._evaluate_and(rules)
423 assert len(result) == 10
424
425
426@pytest.mark.asyncio
427async def test_evaluate_and_artist_filter() -> None:
428 """AND logic filters tracks to only those from the specified artists."""
429 mass = MagicMock()
430 manifest = MagicMock()
431 manifest.domain = "smart_playlist"
432 config = MagicMock()
433 config.get_value.return_value = "GLOBAL"
434 plugin = SmartPlaylistProvider(mass, manifest, config, set())
435
436 artist_a = _make_mock_track("1", artist_ids=["10"])
437 artist_b = _make_mock_track("2", artist_ids=["20"])
438 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[artist_a, artist_b])
439
440 rules = SmartPlaylistRules(artist_ids=[10], logic=LOGIC_AND, limit=10)
441 result = await plugin._evaluate_and(rules)
442 assert len(result) == 1
443 assert result[0].item_id == "1"
444
445
446@pytest.mark.asyncio
447async def test_evaluate_or_genre_and_artist_union() -> None:
448 """OR logic returns union of genre tracks and artist tracks."""
449 mass = MagicMock()
450 manifest = MagicMock()
451 manifest.domain = "smart_playlist"
452 config = MagicMock()
453 config.get_value.return_value = "GLOBAL"
454 plugin = SmartPlaylistProvider(mass, manifest, config, set())
455
456 genre_track = _make_mock_track("1", uri="library://track/1", artist_ids=["99"])
457 artist_track = _make_mock_track("2", uri="library://track/2", artist_ids=["10"])
458
459 async def mock_get_library(**kwargs: Any) -> list[MagicMock]:
460 if kwargs.get("genre_ids"):
461 return [genre_track]
462 return [genre_track, artist_track]
463
464 cast("Any", plugin)._get_library_tracks = mock_get_library
465
466 rules = SmartPlaylistRules(
467 genre_ids=[5],
468 artist_ids=[10],
469 logic=LOGIC_OR,
470 limit=10,
471 )
472 result = await plugin._evaluate_or(rules)
473 uris = {t.uri for t in result}
474 assert "library://track/1" in uris
475 assert "library://track/2" in uris
476
477
478@pytest.mark.asyncio
479async def test_popularity_filter_applied() -> None:
480 """Tracks below min_popularity are filtered out."""
481 mass = MagicMock()
482 manifest = MagicMock()
483 manifest.domain = "smart_playlist"
484 config = MagicMock()
485 config.get_value.return_value = "GLOBAL"
486 plugin = SmartPlaylistProvider(mass, manifest, config, set())
487
488 low_pop = _make_mock_track("1", uri="library://track/1", popularity=30)
489 high_pop = _make_mock_track("2", uri="library://track/2", popularity=80)
490 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[low_pop, high_pop])
491
492 rules = SmartPlaylistRules(min_popularity=50, logic=LOGIC_AND, limit=10)
493 result = await plugin._evaluate_rules(rules)
494 assert all(t.metadata.popularity is None or t.metadata.popularity >= 50 for t in result)
495 uris = [t.uri for t in result]
496 assert "library://track/1" not in uris
497 assert "library://track/2" in uris
498
499
500@pytest.mark.asyncio
501async def test_favorites_only_filter() -> None:
502 """favorites_only=True passes favorite=True to the DB query layer."""
503 mass = MagicMock()
504 manifest = MagicMock()
505 manifest.domain = "smart_playlist"
506 config = MagicMock()
507 config.get_value.return_value = "GLOBAL"
508 plugin = SmartPlaylistProvider(mass, manifest, config, set())
509
510 fav = _make_mock_track("1", uri="library://track/1", favorite=True)
511 not_fav = _make_mock_track("2", uri="library://track/2", favorite=False)
512 all_tracks = [fav, not_fav]
513
514 async def mock_get_library(**kwargs: Any) -> list[MagicMock]:
515 if kwargs.get("favorite") is True:
516 return [t for t in all_tracks if t.favorite]
517 return all_tracks
518
519 cast("Any", plugin)._get_library_tracks = mock_get_library
520
521 rules = SmartPlaylistRules(favorites_only=True, logic=LOGIC_AND, limit=10)
522 result = await plugin._evaluate_rules(rules)
523 uris = [t.uri for t in result]
524 assert "library://track/1" in uris
525 assert "library://track/2" not in uris
526
527
528@pytest.mark.asyncio
529async def test_explicit_only_filter() -> None:
530 """explicit=True passes explicit=True to _get_library_tracks for SQL filtering."""
531 mass = MagicMock()
532 manifest = MagicMock()
533 manifest.domain = "smart_playlist"
534 config = MagicMock()
535 config.get_value.return_value = "GLOBAL"
536 plugin = SmartPlaylistProvider(mass, manifest, config, set())
537
538 explicit_track = _make_mock_track("1", uri="library://track/1", explicit=True)
539 clean_track = _make_mock_track("2", uri="library://track/2", explicit=False)
540 unknown_track = _make_mock_track("3", uri="library://track/3", explicit=None)
541
542 async def mock_get_library(**kwargs: Any) -> list[MagicMock]:
543 explicit_filter = kwargs.get("explicit")
544 all_tracks = [explicit_track, clean_track, unknown_track]
545 if explicit_filter is True:
546 # Simulate SQL filter: json_extract(...) = 1
547 return [t for t in all_tracks if t.metadata.explicit is True]
548 if explicit_filter is False:
549 # Simulate SQL filter: IS NULL OR = 0
550 return [t for t in all_tracks if t.metadata.explicit is not True]
551 return all_tracks
552
553 cast("Any", plugin)._get_library_tracks = mock_get_library
554
555 # Test explicit only
556 rules = SmartPlaylistRules(explicit=True, logic=LOGIC_AND, limit=10)
557 result = await plugin._evaluate_rules(rules)
558 uris = [t.uri for t in result]
559 assert "library://track/1" in uris
560 assert "library://track/2" not in uris
561 assert "library://track/3" not in uris
562
563
564@pytest.mark.asyncio
565async def test_no_explicit_filter() -> None:
566 """explicit=False excludes explicit tracks via SQL filtering."""
567 mass = MagicMock()
568 manifest = MagicMock()
569 manifest.domain = "smart_playlist"
570 config = MagicMock()
571 config.get_value.return_value = "GLOBAL"
572 plugin = SmartPlaylistProvider(mass, manifest, config, set())
573
574 explicit_track = _make_mock_track("1", uri="library://track/1", explicit=True)
575 clean_track = _make_mock_track("2", uri="library://track/2", explicit=False)
576 unknown_track = _make_mock_track("3", uri="library://track/3", explicit=None)
577
578 async def mock_get_library(**kwargs: Any) -> list[MagicMock]:
579 explicit_filter = kwargs.get("explicit")
580 all_tracks = [explicit_track, clean_track, unknown_track]
581 if explicit_filter is True:
582 return [t for t in all_tracks if t.metadata.explicit is True]
583 if explicit_filter is False:
584 # Simulate SQL filter: IS NULL OR = 0
585 return [t for t in all_tracks if t.metadata.explicit is not True]
586 return all_tracks
587
588 cast("Any", plugin)._get_library_tracks = mock_get_library
589
590 # Test no explicit
591 rules = SmartPlaylistRules(explicit=False, logic=LOGIC_AND, limit=10)
592 result = await plugin._evaluate_rules(rules)
593 uris = [t.uri for t in result]
594 assert "library://track/1" not in uris
595 assert "library://track/2" in uris
596 assert "library://track/3" in uris
597
598
599@pytest.mark.asyncio
600async def test_allow_explicit_filter() -> None:
601 """explicit=None returns all tracks regardless of explicit flag."""
602 mass = MagicMock()
603 manifest = MagicMock()
604 manifest.domain = "smart_playlist"
605 config = MagicMock()
606 config.get_value.return_value = "GLOBAL"
607 plugin = SmartPlaylistProvider(mass, manifest, config, set())
608
609 explicit_track = _make_mock_track("1", uri="library://track/1", explicit=True)
610 clean_track = _make_mock_track("2", uri="library://track/2", explicit=False)
611 unknown_track = _make_mock_track("3", uri="library://track/3", explicit=None)
612
613 all_tracks = [explicit_track, clean_track, unknown_track]
614 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=all_tracks)
615
616 # Test allow explicit (no filter)
617 rules = SmartPlaylistRules(explicit=None, logic=LOGIC_AND, limit=10)
618 result = await plugin._evaluate_rules(rules)
619 uris = [t.uri for t in result]
620 assert "library://track/1" in uris
621 assert "library://track/2" in uris
622 assert "library://track/3" in uris
623
624
625@pytest.mark.asyncio
626async def test_explicit_filter_generates_sql_query_parts() -> None:
627 """_get_library_tracks passes explicit parameter correctly to library_items."""
628 mass = MagicMock()
629 manifest = MagicMock()
630 manifest.domain = "smart_playlist"
631 config = MagicMock()
632 config.get_value.return_value = "GLOBAL"
633 plugin = SmartPlaylistProvider(mass, manifest, config, set())
634
635 # Mock tracks.library_items to capture kwargs
636 library_items_mock = AsyncMock(return_value=[])
637 mass.music.tracks.library_items = library_items_mock
638
639 # Test explicit=True passes correctly
640 await plugin._get_library_tracks(explicit=True, limit=10)
641 call_kwargs = library_items_mock.call_args.kwargs
642 assert call_kwargs["explicit"] is True
643
644 # Test explicit=False passes correctly
645 library_items_mock.reset_mock()
646 await plugin._get_library_tracks(explicit=False, limit=10)
647 call_kwargs = library_items_mock.call_args.kwargs
648 assert call_kwargs["explicit"] is False
649
650 # Test explicit=None passes correctly
651 library_items_mock.reset_mock()
652 await plugin._get_library_tracks(explicit=None, limit=10)
653 call_kwargs = library_items_mock.call_args.kwargs
654 assert call_kwargs["explicit"] is None
655
656
657@pytest.mark.asyncio
658async def test_limit_is_respected() -> None:
659 """Result is capped at rules.limit."""
660 mass = MagicMock()
661 manifest = MagicMock()
662 manifest.domain = "smart_playlist"
663 config = MagicMock()
664 config.get_value.return_value = "GLOBAL"
665 plugin = SmartPlaylistProvider(mass, manifest, config, set())
666
667 tracks = [_make_mock_track(str(i), f"library://track/{i}") for i in range(50)]
668 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=tracks)
669
670 rules = SmartPlaylistRules(limit=5)
671 result = await plugin._evaluate_rules(rules)
672 assert len(result) <= 5
673
674
675@pytest.mark.asyncio
676async def test_duration_filter_min_only() -> None:
677 """Tracks shorter than min_duration are filtered out."""
678 mass = MagicMock()
679 manifest = MagicMock()
680 manifest.domain = "smart_playlist"
681 config = MagicMock()
682 config.get_value.return_value = "GLOBAL"
683 plugin = SmartPlaylistProvider(mass, manifest, config, set())
684
685 short_track = _make_mock_track("1", uri="library://track/1", duration=120) # 2 minutes
686 long_track = _make_mock_track("2", uri="library://track/2", duration=300) # 5 minutes
687 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[short_track, long_track])
688
689 rules = SmartPlaylistRules(min_duration=180, logic=LOGIC_AND, limit=10) # 3 minutes min
690 result = await plugin._evaluate_rules(rules)
691 uris = [t.uri for t in result]
692 assert "library://track/1" not in uris # too short
693 assert "library://track/2" in uris
694
695
696@pytest.mark.asyncio
697async def test_duration_filter_max_only() -> None:
698 """Tracks longer than max_duration are filtered out."""
699 mass = MagicMock()
700 manifest = MagicMock()
701 manifest.domain = "smart_playlist"
702 config = MagicMock()
703 config.get_value.return_value = "GLOBAL"
704 plugin = SmartPlaylistProvider(mass, manifest, config, set())
705
706 short_track = _make_mock_track("1", uri="library://track/1", duration=120) # 2 minutes
707 long_track = _make_mock_track("2", uri="library://track/2", duration=600) # 10 minutes
708 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[short_track, long_track])
709
710 rules = SmartPlaylistRules(max_duration=300, logic=LOGIC_AND, limit=10) # 5 minutes max
711 result = await plugin._evaluate_rules(rules)
712 uris = [t.uri for t in result]
713 assert "library://track/1" in uris
714 assert "library://track/2" not in uris # too long
715
716
717@pytest.mark.asyncio
718async def test_duration_filter_between() -> None:
719 """Only tracks within min_duration and max_duration pass."""
720 mass = MagicMock()
721 manifest = MagicMock()
722 manifest.domain = "smart_playlist"
723 config = MagicMock()
724 config.get_value.return_value = "GLOBAL"
725 plugin = SmartPlaylistProvider(mass, manifest, config, set())
726
727 too_short = _make_mock_track("1", uri="library://track/1", duration=120) # 2 min
728 just_right = _make_mock_track("2", uri="library://track/2", duration=240) # 4 min
729 too_long = _make_mock_track("3", uri="library://track/3", duration=600) # 10 min
730 cast("Any", plugin)._get_library_tracks = AsyncMock(
731 return_value=[too_short, just_right, too_long]
732 )
733
734 rules = SmartPlaylistRules(
735 min_duration=180, max_duration=300, logic=LOGIC_AND, limit=10
736 ) # 3-5 minutes
737 result = await plugin._evaluate_rules(rules)
738 uris = [t.uri for t in result]
739 assert "library://track/1" not in uris # too short
740 assert "library://track/2" in uris # perfect
741 assert "library://track/3" not in uris # too long
742
743
744@pytest.mark.asyncio
745async def test_duration_filter_skips_tracks_without_duration() -> None:
746 """Tracks with duration=None are excluded when duration filter is active."""
747 mass = MagicMock()
748 manifest = MagicMock()
749 manifest.domain = "smart_playlist"
750 config = MagicMock()
751 config.get_value.return_value = "GLOBAL"
752 plugin = SmartPlaylistProvider(mass, manifest, config, set())
753
754 no_duration = _make_mock_track("1", uri="library://track/1", duration=None)
755 has_duration = _make_mock_track("2", uri="library://track/2", duration=240)
756 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[no_duration, has_duration])
757
758 rules = SmartPlaylistRules(min_duration=180, logic=LOGIC_AND, limit=10)
759 result = await plugin._evaluate_rules(rules)
760 uris = [t.uri for t in result]
761 assert "library://track/1" not in uris # no duration
762 assert "library://track/2" in uris
763
764
765@pytest.mark.asyncio
766async def test_last_played_filter() -> None:
767 """Tracks played recently are filtered out."""
768 mass = MagicMock()
769 manifest = MagicMock()
770 manifest.domain = "smart_playlist"
771 config = MagicMock()
772 config.get_value.return_value = "GLOBAL"
773 plugin = SmartPlaylistProvider(mass, manifest, config, set())
774
775 now = int(time.time())
776 never_played = _make_mock_track("1", uri="library://track/1", last_played=0)
777 played_recently = _make_mock_track(
778 "2", uri="library://track/2", last_played=now - 86400
779 ) # 1 day ago
780 played_long_ago = _make_mock_track(
781 "3", uri="library://track/3", last_played=now - (60 * 86400)
782 ) # 60 days ago
783 cast("Any", plugin)._get_library_tracks = AsyncMock(
784 return_value=[never_played, played_recently, played_long_ago]
785 )
786
787 # Test with days unit
788 rules = SmartPlaylistRules(
789 last_played_before_value=30, last_played_before_unit="days", logic=LOGIC_AND, limit=10
790 ) # Not played in last 30 days
791 result = await plugin._evaluate_rules(rules)
792 uris = [t.uri for t in result]
793 assert "library://track/1" in uris # never played = included
794 assert "library://track/2" not in uris # played 1 day ago = excluded
795 assert "library://track/3" in uris # played 60 days ago = included
796
797 # Test with hours unit
798 rules = SmartPlaylistRules(
799 last_played_before_value=12, last_played_before_unit="hours", logic=LOGIC_AND, limit=10
800 ) # Not played in last 12 hours
801 result = await plugin._evaluate_rules(rules)
802 uris = [t.uri for t in result]
803 assert "library://track/1" in uris # never played = included
804 assert "library://track/2" in uris # played 1 day ago = included
805 assert "library://track/3" in uris # played 60 days ago = included
806
807 # Test with weeks unit
808 rules = SmartPlaylistRules(
809 last_played_before_value=2, last_played_before_unit="weeks", logic=LOGIC_AND, limit=10
810 ) # Not played in last 2 weeks
811 result = await plugin._evaluate_rules(rules)
812 uris = [t.uri for t in result]
813 assert "library://track/1" in uris # never played = included
814 assert "library://track/2" not in uris # played 1 day ago = excluded
815 assert "library://track/3" in uris # played 60 days ago = included
816
817
818# ---------------------------------------------------------------------------
819# New feature tests: seed_artist, exclusions, dedup, validation, count_tracks
820# ---------------------------------------------------------------------------
821
822
823class TestNewValidation:
824 """Validate seed and range checks."""
825
826 def _make_plugin(self) -> SmartPlaylistProvider:
827 mass = MagicMock()
828 manifest = MagicMock()
829 manifest.domain = "smart_playlist"
830 config = MagicMock()
831 config.get_value.return_value = "GLOBAL"
832 return SmartPlaylistProvider(mass, manifest, config, set())
833
834 def test_mixed_seeds_within_cap_passes(self) -> None:
835 """A mix of seed types under the cap validates."""
836 plugin = self._make_plugin()
837 rules = SmartPlaylistRules(
838 seed_track_uris=["library://track/1", "library://track/2"],
839 seed_artist_uris=["library://artist/3"],
840 seed_album_uris=["library://album/4"],
841 seed_playlist_uris=["library://playlist/5"],
842 )
843 plugin._validate_rules(rules) # should not raise
844
845
846@pytest.mark.asyncio
847async def test_seed_mode_uses_tracks_from_seeds() -> None:
848 """When any seed URI is set, evaluator collects tracks via _tracks_from_seeds."""
849 mass = MagicMock()
850 manifest = MagicMock()
851 manifest.domain = "smart_playlist"
852 config = MagicMock()
853 config.get_value.return_value = "GLOBAL"
854 plugin = SmartPlaylistProvider(mass, manifest, config, set())
855
856 similar_tracks = [_make_mock_track("10", "library://track/10")]
857 cast("Any", plugin)._tracks_from_seeds = AsyncMock(return_value=similar_tracks)
858 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[])
859
860 rules = SmartPlaylistRules(
861 seed_artist_uris=["library://artist/5"],
862 seed_album_uris=["library://album/9"],
863 limit=10,
864 )
865 result = await plugin._evaluate_rules(rules)
866
867 cast("Any", plugin)._tracks_from_seeds.assert_awaited_once()
868 awaited_args = cast("Any", plugin)._tracks_from_seeds.await_args
869 assert awaited_args.args[0] == ["library://artist/5", "library://album/9"]
870 cast("Any", plugin)._get_library_tracks.assert_not_awaited()
871 assert len(result) == 1
872
873
874@pytest.mark.asyncio
875async def test_tracks_from_seeds_pools_base_and_similar() -> None:
876 """_tracks_from_seeds gathers each seed's base tracks plus similar tracks, deduped."""
877 mass = MagicMock()
878 manifest = MagicMock()
879 manifest.domain = "smart_playlist"
880 config = MagicMock()
881 config.get_value.return_value = "GLOBAL"
882 plugin = SmartPlaylistProvider(mass, manifest, config, set())
883
884 seed = _make_mock_track("seed", "library://track/seed")
885 base = _make_mock_track("base", "library://track/base")
886 sim1 = _make_mock_track("sim1", "library://track/sim1")
887 sim2 = _make_mock_track("sim2", "library://track/sim2")
888
889 ctrl = MagicMock()
890 ctrl.get = AsyncMock(return_value=seed)
891 mass.music.get_controller = MagicMock(return_value=ctrl)
892 mass.player_queues.get_tracks_for_playback = AsyncMock(return_value=[base])
893 # similar repeats the base track, which must be deduped out of the pool
894 mass.music.tracks.similar_tracks = AsyncMock(return_value=[sim1, sim2, base])
895
896 result = await plugin._tracks_from_seeds(["library://track/10"], target_size=10)
897
898 ids = [track.item_id for track in result]
899 assert "base" in ids
900 assert {"sim1", "sim2"} <= set(ids)
901 assert ids.count("base") == 1
902
903
904@pytest.mark.asyncio
905async def test_tracks_from_seeds_samples_evenly_across_seeds() -> None:
906 """A large seed must not crowd the pool: each seed contributes evenly (round-robin)."""
907 mass = MagicMock()
908 manifest = MagicMock()
909 manifest.domain = "smart_playlist"
910 config = MagicMock()
911 config.get_value.return_value = "GLOBAL"
912 plugin = SmartPlaylistProvider(mass, manifest, config, set())
913
914 seed_a = _make_mock_track("seed_a", "library://track/seed_a")
915 seed_b = _make_mock_track("seed_b", "library://track/seed_b")
916 base_a = _make_mock_track("base_a", "library://track/base_a")
917 base_b = _make_mock_track("base_b", "library://track/base_b")
918 # Seed A yields far more similar tracks than seed B; the old sequential fill let A alone
919 # reach the pool cap so B never contributed a single track.
920 a_similar = [_make_mock_track(f"a_sim_{i}", f"library://track/a_sim_{i}") for i in range(30)]
921 b_similar = [_make_mock_track("b_sim_0", "library://track/b_sim_0")]
922
923 ctrl = MagicMock()
924 ctrl.get = AsyncMock(side_effect=[seed_a, seed_b])
925 mass.music.get_controller = MagicMock(return_value=ctrl)
926 mass.player_queues.get_tracks_for_playback = AsyncMock(
927 side_effect=lambda seed: {seed_a: [base_a], seed_b: [base_b]}[seed]
928 )
929 mass.music.tracks.similar_tracks = AsyncMock(
930 side_effect=lambda item_id, _provider: {"base_a": a_similar, "base_b": b_similar}[item_id]
931 )
932
933 result = await plugin._tracks_from_seeds(
934 ["library://track/seed_a", "library://track/seed_b"], target_size=4
935 )
936
937 ids = {track.item_id for track in result}
938 # seed B's tracks must survive even though seed A dwarfs it
939 assert "base_b" in ids
940 assert "b_sim_0" in ids
941 assert "base_a" in ids
942 # round-robin, not concatenation: A and B alternate at the head of the pool
943 head = [track.item_id for track in result[:2]]
944 assert head == ["base_a", "base_b"]
945
946
947@pytest.mark.asyncio
948async def test_tracks_from_seeds_shuffles_seed_tracks(monkeypatch: pytest.MonkeyPatch) -> None:
949 """Base tracks are drawn from across the seed, not just its first few (stored-order) items."""
950 mass = MagicMock()
951 manifest = MagicMock()
952 manifest.domain = "smart_playlist"
953 config = MagicMock()
954 config.get_value.return_value = "GLOBAL"
955 plugin = SmartPlaylistProvider(mass, manifest, config, set())
956
957 # a large seed whose tracks resolve in stored order; without shuffling only t0.. would be used
958 seed_tracks = [_make_mock_track(f"t{i}", f"library://track/t{i}") for i in range(20)]
959
960 ctrl = MagicMock()
961 ctrl.get = AsyncMock(return_value=_make_mock_track("seed", "library://track/seed"))
962 mass.music.get_controller = MagicMock(return_value=ctrl)
963 mass.player_queues.get_tracks_for_playback = AsyncMock(return_value=seed_tracks)
964 mass.music.tracks.similar_tracks = AsyncMock(return_value=[])
965 # deterministic "shuffle": reverse in place, so tail tracks land at the head
966 monkeypatch.setattr(
967 "music_assistant.providers.smart_playlist.random.shuffle", lambda seq: seq.reverse()
968 )
969
970 result = await plugin._tracks_from_seeds(["library://track/seed"], target_size=2)
971
972 ids = {track.item_id for track in result}
973 assert "t19" in ids
974 assert "t0" not in ids
975
976
977@pytest.mark.asyncio
978async def test_exclusion_filters_out_excluded_artist() -> None:
979 """Tracks from excluded artists are removed from the result."""
980 mass = MagicMock()
981 manifest = MagicMock()
982 manifest.domain = "smart_playlist"
983 config = MagicMock()
984 config.get_value.return_value = "GLOBAL"
985 plugin = SmartPlaylistProvider(mass, manifest, config, set())
986
987 included = _make_mock_track("1", "library://track/1", artist_ids=["10"])
988 excluded = _make_mock_track("2", "library://track/2", artist_ids=["99"])
989 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[included, excluded])
990
991 rules = SmartPlaylistRules(excluded_artist_ids=[99], limit=10)
992 result = await plugin._evaluate_rules(rules)
993 uris = [t.uri for t in result]
994 assert "library://track/1" in uris
995 assert "library://track/2" not in uris
996
997
998@pytest.mark.asyncio
999async def test_exclusion_filters_out_excluded_uri() -> None:
1000 """Tracks whose URI is in excluded_track_uris are removed."""
1001 mass = MagicMock()
1002 manifest = MagicMock()
1003 manifest.domain = "smart_playlist"
1004 config = MagicMock()
1005 config.get_value.return_value = "GLOBAL"
1006 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1007
1008 t1 = _make_mock_track("1", "library://track/1")
1009 t2 = _make_mock_track("2", "library://track/2")
1010 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[t1, t2])
1011
1012 rules = SmartPlaylistRules(excluded_track_uris=["library://track/2"], limit=10)
1013 result = await plugin._evaluate_rules(rules)
1014 assert all(t.uri != "library://track/2" for t in result)
1015
1016
1017@pytest.mark.asyncio
1018async def test_evaluate_rules_removes_duplicate_track_uris() -> None:
1019 """Smart playlist evaluation should not return the same track URI multiple times."""
1020 mass = MagicMock()
1021 manifest = MagicMock()
1022 manifest.domain = "smart_playlist"
1023 config = MagicMock()
1024 config.get_value.return_value = "GLOBAL"
1025 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1026
1027 provider_mapping = ProviderMapping(
1028 item_id="1",
1029 provider_domain="library",
1030 provider_instance="library",
1031 available=True,
1032 )
1033 dup_a_1 = Track(
1034 item_id="1",
1035 provider="library",
1036 name="Track 1",
1037 uri="library://track/dup",
1038 provider_mappings={provider_mapping},
1039 )
1040 dup_a_2 = Track(
1041 item_id="2",
1042 provider="library",
1043 name="Track 2",
1044 uri="library://track/dup",
1045 provider_mappings={provider_mapping},
1046 )
1047 dup_a_3 = Track(
1048 item_id="3",
1049 provider="library",
1050 name="Track 3",
1051 uri="library://track/dup",
1052 provider_mappings={provider_mapping},
1053 )
1054 uniq_b = Track(
1055 item_id="4",
1056 provider="library",
1057 name="Track 4",
1058 uri="library://track/unique",
1059 provider_mappings={provider_mapping},
1060 )
1061 cast("Any", plugin)._get_library_tracks = AsyncMock(
1062 return_value=[dup_a_1, dup_a_2, dup_a_3, uniq_b]
1063 )
1064 cast("Any", plugin)._enrich_tracks_with_db_genres = AsyncMock(return_value=None)
1065
1066 rules = SmartPlaylistRules(limit=10, logic=LOGIC_AND)
1067 result = await plugin._evaluate_rules(rules)
1068
1069 uris = [track.uri for track in result]
1070 assert uris.count("library://track/dup") == 1
1071 assert "library://track/unique" in uris
1072
1073
1074@pytest.mark.asyncio
1075async def test_evaluate_rules_dedup_skips_unavailable_tracks() -> None:
1076 """Dedup should skip unavailable tracks before adding to the result set."""
1077 mass = MagicMock()
1078 manifest = MagicMock()
1079 manifest.domain = "smart_playlist"
1080 config = MagicMock()
1081 config.get_value.return_value = "GLOBAL"
1082 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1083
1084 available_track = _make_mock_track("1", "library://track/available")
1085 available_track.available = True
1086 unavailable_track = _make_mock_track("2", "library://track/unavailable")
1087 unavailable_track.available = False
1088 cast("Any", plugin)._get_library_tracks = AsyncMock(
1089 return_value=[available_track, unavailable_track]
1090 )
1091
1092 rules = SmartPlaylistRules(limit=10, logic=LOGIC_AND)
1093 result = await plugin._evaluate_rules(rules)
1094
1095 assert len(result) == 1
1096 assert result[0].uri == "library://track/available"
1097
1098
1099def _swallow_task(coro: Any, **_: Any) -> None:
1100 """Close coroutines passed to a mocked mass.create_task so pytest stays quiet."""
1101 coro.close()
1102
1103
1104@pytest.mark.asyncio
1105async def test_get_playlist_tracks_dynamic_cold_evaluates_and_caches(tmp_path: Any) -> None:
1106 """On a fully-cold cache the sample is evaluated and a store task is scheduled."""
1107 mass = MagicMock()
1108 mass.storage_path = str(tmp_path)
1109 mass.cache.get_with_freshness = AsyncMock(return_value=(None, False, False))
1110 mass.cache.set = AsyncMock()
1111 use_real_create_task(mass)
1112 manifest = MagicMock()
1113 manifest.domain = "smart_playlist"
1114 config = MagicMock()
1115 config.get_value.return_value = "GLOBAL"
1116 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1117 await plugin.handle_async_init()
1118
1119 tracks = [_make_mock_track(str(i), f"library://track/{i}") for i in range(50)]
1120 library_mock = AsyncMock(return_value=tracks)
1121 cast("Any", plugin)._get_library_tracks = library_mock
1122
1123 rules = SmartPlaylistRules(limit=100, is_dynamic=True)
1124 plugin._rules_store["abc"] = rules
1125
1126 result = await plugin.get_playlist_tracks("abc")
1127 assert len(result) <= DYNAMIC_PLAYLIST_SAMPLE_SIZE
1128 assert len(result) > 5
1129 # Observable behaviour: the wrapped evaluator ran and its result was stored.
1130 library_mock.assert_awaited()
1131 mass.cache.set.assert_awaited_once()
1132
1133
1134@pytest.mark.asyncio
1135async def test_get_playlist_tracks_dynamic_returns_fresh_cache(tmp_path: Any) -> None:
1136 """A fresh cache hit short-circuits evaluation and does not touch the stale path."""
1137 mass = MagicMock()
1138 mass.storage_path = str(tmp_path)
1139 cached = [_make_mock_track(str(i), f"library://track/cached-{i}") for i in range(3)]
1140 mass.cache.get_with_freshness = AsyncMock(return_value=(cached, True, True))
1141 mass.cache.set = AsyncMock()
1142 mass.create_task = MagicMock()
1143 manifest = MagicMock()
1144 manifest.domain = "smart_playlist"
1145 config = MagicMock()
1146 config.get_value.return_value = "GLOBAL"
1147 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1148 await plugin.handle_async_init()
1149
1150 evaluate_mock = AsyncMock(return_value=[])
1151 cast("Any", plugin)._evaluate_rules = evaluate_mock
1152
1153 plugin._rules_store["abc"] = SmartPlaylistRules(limit=100, is_dynamic=True)
1154 result = await plugin.get_playlist_tracks("abc")
1155 assert result == cached
1156 evaluate_mock.assert_not_awaited()
1157 # A single freshness lookup runs; no scheduled refresh.
1158 mass.cache.get_with_freshness.assert_awaited_once()
1159 mass.create_task.assert_not_called()
1160
1161
1162@pytest.mark.asyncio
1163async def test_get_playlist_tracks_dynamic_serves_stale_and_refreshes(tmp_path: Any) -> None:
1164 """A stale-only cache hit is returned immediately and refresh is scheduled."""
1165 mass = MagicMock()
1166 mass.storage_path = str(tmp_path)
1167 stale = [_make_mock_track(str(i), f"library://track/stale-{i}") for i in range(3)]
1168 # The single freshness lookup returns the expired entry (a stale hit).
1169 mass.cache.get_with_freshness = AsyncMock(return_value=(stale, False, True))
1170 mass.cache.set = AsyncMock()
1171 mass.create_task = MagicMock(side_effect=_swallow_task)
1172 manifest = MagicMock()
1173 manifest.domain = "smart_playlist"
1174 config = MagicMock()
1175 config.get_value.return_value = "GLOBAL"
1176 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1177 await plugin.handle_async_init()
1178
1179 evaluate_mock = AsyncMock(return_value=[])
1180 cast("Any", plugin)._evaluate_rules = evaluate_mock
1181
1182 plugin._rules_store["abc"] = SmartPlaylistRules(limit=100, is_dynamic=True)
1183 result = await plugin.get_playlist_tracks("abc")
1184 assert result == stale
1185 # Synchronous evaluation is skipped â the caller gets the stale sample immediately.
1186 evaluate_mock.assert_not_awaited()
1187 # A background refresh is scheduled (task_id keeps it deduped across concurrent calls).
1188 mass.create_task.assert_called_once()
1189 task_id = mass.create_task.call_args.kwargs.get("task_id")
1190 assert task_id
1191 assert "abc" in task_id
1192
1193
1194@pytest.mark.asyncio
1195async def test_get_playlist_tracks_static_uses_full_limit(tmp_path: Any) -> None:
1196 """get_playlist_tracks uses full rules.limit for static (non-dynamic) playlists."""
1197 mass = MagicMock()
1198 mass.storage_path = str(tmp_path)
1199 manifest = MagicMock()
1200 manifest.domain = "smart_playlist"
1201 config = MagicMock()
1202 config.get_value.return_value = "GLOBAL"
1203 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1204 await plugin.handle_async_init()
1205
1206 tracks = [_make_mock_track(str(i), f"library://track/{i}") for i in range(50)]
1207 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=tracks)
1208
1209 rules = SmartPlaylistRules(limit=20, is_dynamic=False)
1210 plugin._rules_store["xyz"] = rules
1211
1212 result = await plugin.get_playlist_tracks("xyz")
1213 assert len(result) <= 20
1214 assert len(result) > 5 # proves limit was not capped at 5
1215
1216
1217@pytest.mark.asyncio
1218async def test_get_playlist_resolves_library_id_to_provider_uuid(tmp_path: Any) -> None:
1219 """get_playlist resolves a library id input to the stored provider UUID."""
1220 mass = MagicMock()
1221 mass.storage_path = str(tmp_path)
1222 manifest = MagicMock()
1223 manifest.domain = "smart_playlist"
1224 config = MagicMock()
1225 config.get_value.return_value = "GLOBAL"
1226 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1227 await plugin.handle_async_init()
1228
1229 plugin._rules_store["abc"] = SmartPlaylistRules(limit=10, is_dynamic=True)
1230
1231 mapping = MagicMock()
1232 mapping.provider_instance = plugin.instance_id
1233 mapping.item_id = "abc"
1234 library_item = MagicMock()
1235 library_item.provider_mappings = [mapping]
1236 # Mock get_library_item for resolving "123" -> "abc"
1237 mass.music.playlists.get_library_item = AsyncMock(return_value=library_item)
1238 # Mock get_library_item_by_prov_id to return None (no artwork)
1239 mass.music.playlists.get_library_item_by_prov_id = AsyncMock(return_value=None)
1240
1241 playlist = await plugin.get_playlist("123")
1242 assert playlist.item_id == "abc"
1243
1244
1245@pytest.mark.asyncio
1246async def test_get_playlist_loads_library_artwork(
1247 tmp_path: Any,
1248) -> None:
1249 """get_playlist loads artwork from the library when available."""
1250 mass = MagicMock()
1251 mass.storage_path = str(tmp_path)
1252 manifest = MagicMock()
1253 manifest.domain = "smart_playlist"
1254 config = MagicMock()
1255 config.get_value.return_value = "GLOBAL"
1256 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1257 await plugin.handle_async_init()
1258
1259 plugin._rules_store["abc"] = SmartPlaylistRules(limit=10, is_dynamic=False)
1260
1261 # Mock library item with artwork
1262 library_artwork = MediaItemImage(
1263 type=ImageType.THUMB,
1264 path="generated_artwork.jpg",
1265 provider="playlist_art",
1266 remotely_accessible=False,
1267 )
1268 library_playlist = MagicMock()
1269 library_playlist.metadata.images = UniqueList([library_artwork])
1270
1271 mass.music.playlists.get_library_item_by_prov_id = AsyncMock(return_value=library_playlist)
1272
1273 playlist = await plugin.get_playlist("abc")
1274 assert playlist.metadata.images is not None
1275 assert len(playlist.metadata.images) == 1
1276 assert playlist.metadata.images[0].path == "generated_artwork.jpg"
1277 assert playlist.metadata.images[0].provider == "playlist_art"
1278
1279 # Verify library lookup was called
1280 mass.music.playlists.get_library_item_by_prov_id.assert_awaited_once_with(
1281 "abc", plugin.instance_id
1282 )
1283
1284
1285@pytest.mark.asyncio
1286async def test_get_playlist_tracks_dynamic_uses_resolved_provider_id(
1287 tmp_path: Any,
1288 monkeypatch: pytest.MonkeyPatch,
1289) -> None:
1290 """Dynamic track fetch uses resolved provider UUID for the cached sample lookup."""
1291 mass = MagicMock()
1292 mass.storage_path = str(tmp_path)
1293 manifest = MagicMock()
1294 manifest.domain = "smart_playlist"
1295 config = MagicMock()
1296 config.get_value.return_value = "GLOBAL"
1297 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1298 await plugin.handle_async_init()
1299
1300 plugin._rules_store["abc"] = SmartPlaylistRules(limit=100, is_dynamic=True)
1301
1302 mapping = MagicMock()
1303 mapping.provider_instance = plugin.instance_id
1304 mapping.item_id = "abc"
1305 library_item = MagicMock()
1306 library_item.provider_mappings = [mapping]
1307 # Mock get_library_item for resolving "123" -> "abc"
1308 mass.music.playlists.get_library_item = AsyncMock(return_value=library_item)
1309 # Mock get_library_item_by_prov_id to return None (no artwork)
1310 mass.music.playlists.get_library_item_by_prov_id = AsyncMock(return_value=None)
1311
1312 expected = [_make_mock_track("1", "library://track/1")]
1313 cached_dynamic_sample_mock = AsyncMock(return_value=expected)
1314 monkeypatch.setattr(plugin, "_cached_dynamic_sample", cached_dynamic_sample_mock)
1315
1316 result = await plugin.get_playlist_tracks("123")
1317
1318 assert result == expected
1319 cached_dynamic_sample_mock.assert_awaited_once_with("abc", ())
1320
1321
1322@pytest.mark.asyncio
1323async def test_get_playlist_tracks_dynamic_cache_key_differs_by_provider_filter(
1324 tmp_path: Any,
1325 monkeypatch: pytest.MonkeyPatch,
1326) -> None:
1327 """Different provider filters produce different cache keys for dynamic playlists."""
1328 mass = MagicMock()
1329 mass.storage_path = str(tmp_path)
1330 manifest = MagicMock()
1331 manifest.domain = "smart_playlist"
1332 config = MagicMock()
1333 config.get_value.return_value = "GLOBAL"
1334 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1335 await plugin.handle_async_init()
1336
1337 plugin._rules_store["abc"] = SmartPlaylistRules(limit=100, is_dynamic=True)
1338
1339 cached_dynamic_sample_mock = AsyncMock(return_value=[])
1340 monkeypatch.setattr(plugin, "_cached_dynamic_sample", cached_dynamic_sample_mock)
1341
1342 # Call once with no user (no provider filter)
1343 monkeypatch.setattr("music_assistant.providers.smart_playlist.get_current_user", lambda: None)
1344 await plugin.get_playlist_tracks("abc")
1345
1346 # Call again with a user that has a provider filter
1347 user_with_filter = MagicMock()
1348 user_with_filter.provider_filter = ["spotify_instance_id", "tidal_instance_id"]
1349 monkeypatch.setattr(
1350 "music_assistant.providers.smart_playlist.get_current_user",
1351 lambda: user_with_filter,
1352 )
1353 await plugin.get_playlist_tracks("abc")
1354
1355 calls = cached_dynamic_sample_mock.await_args_list
1356 assert len(calls) == 2
1357 # The second argument (user_provider_filter) must differ between the two calls.
1358 assert calls[0].args[1] != calls[1].args[1]
1359 assert calls[0].args[1] == ()
1360 assert calls[1].args[1] == ("spotify_instance_id", "tidal_instance_id")
1361
1362
1363@pytest.mark.asyncio
1364async def test_count_tracks_returns_count_and_duration(tmp_path: Any) -> None:
1365 """count_tracks returns a dict with count and duration_seconds."""
1366 mass = MagicMock()
1367 mass.storage_path = str(tmp_path)
1368 manifest = MagicMock()
1369 manifest.domain = "smart_playlist"
1370 config = MagicMock()
1371 config.get_value.return_value = "GLOBAL"
1372 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1373 await plugin.handle_async_init()
1374
1375 tracks = []
1376 for i in range(3):
1377 t = _make_mock_track(str(i), f"library://track/{i}")
1378 t.duration = 200
1379 t.last_played = 0 # never played
1380 tracks.append(t)
1381
1382 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=tracks)
1383
1384 result = await plugin.count_tracks(SmartPlaylistRules(limit=10).to_dict())
1385 assert result["count"] == 3
1386 assert result["duration_seconds"] == 600
1387
1388
1389# ---------------------------------------------------------------------------
1390# album_type filter tests
1391# ---------------------------------------------------------------------------
1392
1393
1394def _make_mock_track_with_album_type(
1395 item_id: str,
1396 uri: str,
1397 album_type: str = "unknown",
1398) -> MagicMock:
1399 """Build a minimal mock Track with a unique album.item_id per item_id."""
1400 track = _make_mock_track(item_id, uri)
1401 track.album = MagicMock()
1402 # Unique album ID per track (item_id "1" â album "1000") so library_items mocks are precise.
1403 track.album.item_id = str(int(item_id) * 1000)
1404 track.album.year = None
1405 track.album.album_type = AlbumType(album_type)
1406 return track
1407
1408
1409class TestSmartPlaylistRulesAlbumType:
1410 """Tests for album_types / excluded_album_types fields on SmartPlaylistRules."""
1411
1412 def test_album_types_defaults_to_empty(self) -> None:
1413 """album_types and excluded_album_types default to empty lists."""
1414 rules = SmartPlaylistRules()
1415 assert rules.album_types == []
1416 assert rules.excluded_album_types == []
1417
1418 def test_album_types_round_trip(self) -> None:
1419 """album_types / excluded_album_types survive a to_dict / from_dict round-trip."""
1420 rules = SmartPlaylistRules(
1421 album_types=["album", "ep"],
1422 excluded_album_types=["single", "compilation"],
1423 )
1424 recovered = SmartPlaylistRules.from_dict(rules.to_dict())
1425 assert recovered.album_types == ["album", "ep"]
1426 assert recovered.excluded_album_types == ["single", "compilation"]
1427
1428 def test_old_json_without_album_types_loads_cleanly(self) -> None:
1429 """Rules JSON without album_types fields deserializes with empty defaults."""
1430 rules = SmartPlaylistRules.from_dict({"limit": 50, "favorites_only": True})
1431 assert rules.album_types == []
1432 assert rules.excluded_album_types == []
1433
1434 def test_human_readable_includes_album_types(self) -> None:
1435 """human_readable mentions album_types when set."""
1436 rules = SmartPlaylistRules(album_types=["album", "ep"])
1437 summary = rules.human_readable()
1438 assert "album" in summary
1439 assert "ep" in summary
1440
1441 def test_human_readable_includes_excluded_album_types(self) -> None:
1442 """human_readable mentions excluded_album_types when set."""
1443 rules = SmartPlaylistRules(excluded_album_types=["single"])
1444 assert "single" in rules.human_readable()
1445
1446
1447class TestAlbumTypeValidation:
1448 """Tests for album_type validation in validate_rules."""
1449
1450 def _make_plugin(self) -> SmartPlaylistProvider:
1451 mass = MagicMock()
1452 manifest = MagicMock()
1453 manifest.domain = "smart_playlist"
1454 config = MagicMock()
1455 config.get_value.return_value = "GLOBAL"
1456 return SmartPlaylistProvider(mass, manifest, config, set())
1457
1458 def test_valid_album_types_pass(self) -> None:
1459 """All AlbumType values are valid."""
1460 plugin = self._make_plugin()
1461 rules = SmartPlaylistRules(
1462 album_types=["album", "single", "ep", "live", "soundtrack", "compilation"]
1463 )
1464 plugin._validate_rules(rules) # must not raise
1465
1466 def test_invalid_album_type_raises(self) -> None:
1467 """Unknown album_type value raises InvalidDataError."""
1468 plugin = self._make_plugin()
1469 rules = SmartPlaylistRules(album_types=["not_a_real_type"])
1470 with pytest.raises(InvalidDataError, match="album_types"):
1471 plugin._validate_rules(rules)
1472
1473 def test_invalid_excluded_album_type_raises(self) -> None:
1474 """Unknown excluded_album_type value raises InvalidDataError."""
1475 plugin = self._make_plugin()
1476 rules = SmartPlaylistRules(excluded_album_types=["bogus"])
1477 with pytest.raises(InvalidDataError, match="excluded_album_types"):
1478 plugin._validate_rules(rules)
1479
1480
1481@pytest.mark.asyncio
1482async def test_evaluate_rules_album_types_filter() -> None:
1483 """album_types filter keeps only tracks whose album ID is in the allowed set."""
1484 mass = MagicMock()
1485 manifest = MagicMock()
1486 manifest.domain = "smart_playlist"
1487 config = MagicMock()
1488 config.get_value.return_value = "GLOBAL"
1489 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1490
1491 album_track = _make_mock_track_with_album_type("1", "library://track/1", "album")
1492 single_track = _make_mock_track_with_album_type("2", "library://track/2", "single")
1493 unknown_track = _make_mock_track_with_album_type("3", "library://track/3", "unknown")
1494
1495 for t in (album_track, single_track, unknown_track):
1496 t.available = True
1497 t.last_played = 0
1498 t.metadata = MagicMock()
1499 t.metadata.genres = None
1500
1501 cast("Any", plugin)._get_library_tracks = AsyncMock(
1502 return_value=[album_track, single_track, unknown_track]
1503 )
1504 cast("Any", plugin)._enrich_tracks_with_db_genres = AsyncMock(return_value=None)
1505 # albums.library_items returns only the "album" type album (album_track.album.item_id = "1000")
1506 mock_album = MagicMock()
1507 mock_album.item_id = "1000"
1508 mass.music.albums.library_items = AsyncMock(return_value=[mock_album])
1509
1510 rules = SmartPlaylistRules(album_types=["album"], limit=10)
1511 result = await plugin._evaluate_rules(rules)
1512 uris = [t.uri for t in result]
1513 assert "library://track/1" in uris # album â included
1514 assert "library://track/2" not in uris # single â excluded
1515 assert "library://track/3" not in uris # unknown album type â excluded
1516
1517
1518@pytest.mark.asyncio
1519async def test_evaluate_rules_excluded_album_types_filter() -> None:
1520 """excluded_album_types removes tracks whose album.album_type is in the exclusion list."""
1521 mass = MagicMock()
1522 manifest = MagicMock()
1523 manifest.domain = "smart_playlist"
1524 config = MagicMock()
1525 config.get_value.return_value = "GLOBAL"
1526 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1527
1528 album_track = _make_mock_track_with_album_type("1", "library://track/1", "album")
1529 single_track = _make_mock_track_with_album_type("2", "library://track/2", "single")
1530
1531 for t in (album_track, single_track):
1532 t.available = True
1533 t.last_played = 0
1534 t.metadata = MagicMock()
1535 t.metadata.genres = None
1536
1537 cast("Any", plugin)._get_library_tracks = AsyncMock(return_value=[album_track, single_track])
1538 cast("Any", plugin)._enrich_tracks_with_db_genres = AsyncMock(return_value=None)
1539 # albums.library_items returns only the "single" type album (single_track.album.item_id = "2000")
1540 mock_album = MagicMock()
1541 mock_album.item_id = "2000"
1542 mass.music.albums.library_items = AsyncMock(return_value=[mock_album])
1543
1544 rules = SmartPlaylistRules(excluded_album_types=["single"], limit=10)
1545 result = await plugin._evaluate_rules(rules)
1546 uris = [t.uri for t in result]
1547 assert "library://track/1" in uris # album â kept
1548 assert "library://track/2" not in uris # single â excluded
1549
1550
1551@pytest.mark.asyncio
1552async def test_seed_mode_album_types_filter_is_applied() -> None:
1553 """album_types filter is enforced in seed mode via _apply_seed_post_filters."""
1554 mass = MagicMock()
1555 mass.music.genres.get_library_item = AsyncMock(side_effect=Exception("not called"))
1556 manifest = MagicMock()
1557 manifest.domain = "smart_playlist"
1558 config = MagicMock()
1559 config.get_value.return_value = "GLOBAL"
1560 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1561
1562 album_track = _make_mock_track_with_album_type("1", "library://track/1", "album")
1563 single_track = _make_mock_track_with_album_type("2", "library://track/2", "single")
1564
1565 for t in (album_track, single_track):
1566 t.available = True
1567 t.last_played = 0
1568 t.metadata = MagicMock()
1569 t.metadata.popularity = None
1570 t.metadata.genres = None
1571 t.favorite = False
1572
1573 # Seed mode is triggered when seed_track_uris is non-empty.
1574 # Mock _tracks_from_seeds to return mixed album types.
1575 cast("Any", plugin)._tracks_from_seeds = AsyncMock(return_value=[album_track, single_track])
1576 cast("Any", plugin)._enrich_tracks_with_db_genres = AsyncMock(return_value=None)
1577 # albums.library_items returns only the "album" type album (album_track.album.item_id = "1000")
1578 mock_album = MagicMock()
1579 mock_album.item_id = "1000"
1580 mass.music.albums.library_items = AsyncMock(return_value=[mock_album])
1581
1582 rules = SmartPlaylistRules(
1583 seed_track_uris=["library://track/99"],
1584 album_types=["album"],
1585 limit=10,
1586 )
1587 result = await plugin._evaluate_rules(rules)
1588 uris = [t.uri for t in result]
1589 assert "library://track/1" in uris # album â kept
1590 assert "library://track/2" not in uris # single â filtered out by _apply_seed_post_filters
1591
1592
1593# ---------------------------------------------------------------------------
1594# Database genre enrichment tests
1595# ---------------------------------------------------------------------------
1596
1597
1598@pytest.mark.asyncio
1599async def test_enrich_tracks_with_db_genres_adds_missing_genres() -> None:
1600 """_enrich_tracks_with_db_genres should query DB and add genres to tracks without them."""
1601 mass = MagicMock()
1602 manifest = MagicMock()
1603 manifest.domain = "smart_playlist"
1604 config = MagicMock()
1605 config.get_value.return_value = "GLOBAL"
1606 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1607
1608 # Track with no metadata.genres
1609 track_no_genres = Track(
1610 item_id="123",
1611 provider="library",
1612 name="Track Without Genres",
1613 uri="library://track/123",
1614 provider_mappings={
1615 ProviderMapping(
1616 item_id="123",
1617 provider_domain="library",
1618 provider_instance="library",
1619 available=True,
1620 )
1621 },
1622 )
1623 track_no_genres.metadata = MediaItemMetadata()
1624 track_no_genres.metadata.genres = None
1625
1626 # Mock genre controller response: track 123 has genres "Rock" and "Alternative"
1627 rock_genre = Genre(item_id="1", provider="library", name="Rock", provider_mappings=set())
1628 alternative_genre = Genre(
1629 item_id="2", provider="library", name="Alternative", provider_mappings=set()
1630 )
1631 mass.music.genres.get_genres_for_media_item = AsyncMock(
1632 return_value=[rock_genre, alternative_genre]
1633 )
1634
1635 await plugin._enrich_tracks_with_db_genres([track_no_genres])
1636
1637 genres = track_no_genres.metadata.genres
1638 assert genres == {"Rock", "Alternative"}
1639 mass.music.genres.get_genres_for_media_item.assert_called_once() # type: ignore[unreachable]
1640
1641
1642@pytest.mark.asyncio
1643async def test_enrich_tracks_with_db_genres_skips_tracks_with_existing_genres() -> None:
1644 """Tracks that already have genres should not be queried."""
1645 mass = MagicMock()
1646 manifest = MagicMock()
1647 manifest.domain = "smart_playlist"
1648 config = MagicMock()
1649 config.get_value.return_value = "GLOBAL"
1650 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1651
1652 # Track with existing genres
1653 track_with_genres = Track(
1654 item_id="456",
1655 provider="library",
1656 name="Track With Genres",
1657 uri="library://track/456",
1658 provider_mappings={
1659 ProviderMapping(
1660 item_id="456",
1661 provider_domain="library",
1662 provider_instance="library",
1663 available=True,
1664 )
1665 },
1666 )
1667 track_with_genres.metadata = MediaItemMetadata()
1668 track_with_genres.metadata.genres = {"Pop", "Dance"}
1669
1670 mass.music.genres.get_genres_for_media_item = AsyncMock()
1671
1672 await plugin._enrich_tracks_with_db_genres([track_with_genres])
1673
1674 # Should not query genres controller since track already has genres
1675 mass.music.genres.get_genres_for_media_item.assert_not_called()
1676 assert track_with_genres.metadata.genres == {"Pop", "Dance"}
1677
1678
1679@pytest.mark.asyncio
1680async def test_enrich_tracks_with_db_genres_only_queries_library_tracks() -> None:
1681 """Non-library tracks (streaming) should not be queried."""
1682 mass = MagicMock()
1683 manifest = MagicMock()
1684 manifest.domain = "smart_playlist"
1685 config = MagicMock()
1686 config.get_value.return_value = "GLOBAL"
1687 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1688
1689 # Streaming track (item_id is not a digit string)
1690 streaming_track = Track(
1691 item_id="spotify:track:abc123",
1692 provider="spotify",
1693 name="Streaming Track",
1694 uri="spotify://track/abc123",
1695 provider_mappings={
1696 ProviderMapping(
1697 item_id="spotify:track:abc123",
1698 provider_domain="spotify",
1699 provider_instance="spotify_instance",
1700 available=True,
1701 )
1702 },
1703 )
1704
1705 mass.music.genres.get_genres_for_media_item = AsyncMock()
1706 mass.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=None)
1707
1708 await plugin._enrich_tracks_with_db_genres([streaming_track])
1709
1710 # Should not query genres controller for non-library tracks
1711 mass.music.genres.get_genres_for_media_item.assert_not_called()
1712
1713
1714@pytest.mark.asyncio
1715async def test_enrich_tracks_with_db_genres_handles_empty_list() -> None:
1716 """Empty track list should return immediately without querying."""
1717 mass = MagicMock()
1718 manifest = MagicMock()
1719 manifest.domain = "smart_playlist"
1720 config = MagicMock()
1721 config.get_value.return_value = "GLOBAL"
1722 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1723
1724 mass.music.genres.get_genres_for_media_item = AsyncMock()
1725
1726 await plugin._enrich_tracks_with_db_genres([])
1727
1728 mass.music.genres.get_genres_for_media_item.assert_not_called()
1729
1730
1731@pytest.mark.asyncio
1732async def test_seed_mode_enriches_genres_when_excluded_genres_present() -> None:
1733 """Seed mode should enrich genres when excluded_genre_ids or excluded_genre_names are set."""
1734 mass = MagicMock()
1735 manifest = MagicMock()
1736 manifest.domain = "smart_playlist"
1737 config = MagicMock()
1738 config.get_value.return_value = "GLOBAL"
1739 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1740
1741 # Create a library track without genres
1742 track = MagicMock()
1743 track.item_id = "123"
1744 track.uri = "library://track/123"
1745 track.provider = "library"
1746 track.available = True
1747 track.metadata = MagicMock()
1748 track.metadata.genres = None
1749 track.metadata.popularity = 50
1750
1751 # Mock _tracks_from_seeds to return our test track
1752 cast("Any", plugin)._tracks_from_seeds = AsyncMock(return_value=[track])
1753
1754 # Mock _enrich_tracks_with_db_genres to verify it gets called
1755 enrich_mock = AsyncMock()
1756 cast("Any", plugin)._enrich_tracks_with_db_genres = enrich_mock
1757
1758 # Mock required methods
1759 cast("Any", plugin)._resolve_excluded_genre_names = AsyncMock(return_value={"rock"})
1760 cast("Any", plugin)._get_album_ids_for_types = AsyncMock(return_value=[])
1761 cast("Any", plugin)._apply_exclusions = MagicMock(return_value=[track])
1762 cast("Any", plugin)._deduplicate_tracks = MagicMock(return_value=[track])
1763
1764 # Test with excluded_genre_ids only (no included genres)
1765 rules = SmartPlaylistRules(
1766 seed_track_uris=["library://track/99"],
1767 excluded_genre_ids=[1],
1768 limit=10,
1769 )
1770 await plugin._evaluate_rules(rules)
1771
1772 # Verify enrichment was called in seed mode
1773 enrich_mock.assert_called_once()
1774
1775
1776@pytest.mark.asyncio
1777async def test_enrich_tracks_with_db_genres_handles_duplicate_item_ids() -> None:
1778 """Multiple Track objects with the same item_id should all be enriched."""
1779 mass = MagicMock()
1780 manifest = MagicMock()
1781 manifest.domain = "smart_playlist"
1782 config = MagicMock()
1783 config.get_value.return_value = "GLOBAL"
1784 plugin = SmartPlaylistProvider(mass, manifest, config, set())
1785
1786 # Create two different Track objects with the same library item_id
1787 mapping1 = ProviderMapping(
1788 item_id="123",
1789 provider_domain="library",
1790 provider_instance="library",
1791 available=True,
1792 )
1793 track1 = Track(
1794 item_id="spotify:track:abc",
1795 provider="spotify",
1796 name="Track 1",
1797 uri="spotify://track/abc",
1798 provider_mappings={mapping1},
1799 )
1800 track1.metadata = MediaItemMetadata()
1801 track1.metadata.genres = None
1802
1803 mapping2 = ProviderMapping(
1804 item_id="123",
1805 provider_domain="library",
1806 provider_instance="library",
1807 available=True,
1808 )
1809 track2 = Track(
1810 item_id="spotify:track:def",
1811 provider="spotify",
1812 name="Track 2",
1813 uri="spotify://track/def",
1814 provider_mappings={mapping2},
1815 )
1816 track2.metadata = MediaItemMetadata()
1817 track2.metadata.genres = None
1818
1819 # Mock genre controller response
1820 rock_genre = Genre(item_id="1", provider="library", name="Rock", provider_mappings=set())
1821 alternative_genre = Genre(
1822 item_id="2", provider="library", name="Alternative", provider_mappings=set()
1823 )
1824 mass.music.genres.get_genres_for_media_item = AsyncMock(
1825 return_value=[rock_genre, alternative_genre]
1826 )
1827 library_track = MagicMock()
1828 library_track.item_id = "123"
1829 mass.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=library_track)
1830
1831 await plugin._enrich_tracks_with_db_genres([track1, track2])
1832
1833 # Both tracks should have been enriched
1834 assert track1.metadata.genres == {"Rock", "Alternative"}
1835 genres2 = track2.metadata.genres # type: ignore[unreachable]
1836 assert genres2 == {"Rock", "Alternative"}
1837
1838
1839# ---------------------------------------------------------------------------
1840# AI-generated description tests
1841# ---------------------------------------------------------------------------
1842
1843
1844def _make_ai_provider(
1845 response: str = "A mellow mix for the evening.", instance_id: str = "ai--1"
1846) -> MagicMock:
1847 """Build a mock plugin provider exposing one AI engine returning the given response."""
1848 provider = MagicMock(spec=PluginProvider)
1849 provider.instance_id = instance_id
1850 provider.ai_query = AsyncMock(return_value=response)
1851 provider.get_ai_engines = AsyncMock(
1852 return_value=[AIEngine(id="engine", name=instance_id, provider=provider)]
1853 )
1854 return provider
1855
1856
1857def _make_ai_plugin(
1858 tmp_path: Any,
1859 *,
1860 ai_enabled: bool = True,
1861 ai_provider: Any = None,
1862 ai_engine: str | None = None,
1863) -> SmartPlaylistProvider:
1864 """
1865 Build a SmartPlaylistProvider wired for AI-description tests.
1866
1867 :param ai_engine: The stored engine selection; defaults to the given provider's engine,
1868 as the load-time seeding would have stored it. Pass "" to start out unseeded.
1869 """
1870 mass = MagicMock()
1871 mass.storage_path = str(tmp_path)
1872 mass.cache.clear = AsyncMock()
1873 mass.metadata.locale = "en_US"
1874 mass.music.playlists.get_library_item_by_prov_id = AsyncMock(return_value=None)
1875 providers = [ai_provider] if ai_provider is not None else []
1876 mass.get_providers_supporting_feature = MagicMock(return_value=providers)
1877 manifest = MagicMock()
1878 manifest.domain = "smart_playlist"
1879 config = MagicMock()
1880 if ai_engine is None and ai_provider is not None:
1881 ai_engine = f"{ai_provider.instance_id}/engine"
1882 config_values: dict[str, Any] = {
1883 CONF_AI_DESCRIPTIONS: ai_enabled,
1884 CONF_AI_ENGINE: ai_engine or None,
1885 }
1886 config.get_value.side_effect = lambda key, *_args: config_values.get(key, "GLOBAL")
1887 mass.config.get_raw_provider_config_value.side_effect = lambda _instance_id, key, default=None: (
1888 config_values.get(key, default)
1889 )
1890 mass.config.set_raw_provider_config_value.side_effect = (
1891 lambda _instance_id, key, value, **_kwargs: config_values.__setitem__(key, value)
1892 )
1893 return SmartPlaylistProvider(mass, manifest, config, set())
1894
1895
1896def _make_library_item(plugin: SmartPlaylistProvider, prov_id: str, db_id: int = 7) -> MagicMock:
1897 """Build a mock library playlist item mapped back to the given provider id."""
1898 mapping = MagicMock()
1899 mapping.provider_instance = plugin.instance_id
1900 mapping.item_id = prov_id
1901 library_item = MagicMock()
1902 library_item.item_id = db_id
1903 library_item.provider_mappings = [mapping]
1904 return library_item
1905
1906
1907def _capture_scheduled(names: list[str]) -> Any:
1908 """Return a create_task side-effect that records scheduled coroutine names and closes them."""
1909
1910 def _side_effect(coro: Any, **_: Any) -> None:
1911 names.append(coro.cr_code.co_name)
1912 coro.close()
1913
1914 return _side_effect
1915
1916
1917@pytest.mark.asyncio
1918async def test_generate_ai_description_uses_provider(tmp_path: Any) -> None:
1919 """When enabled and a provider is available, the AI response is returned."""
1920 ai_provider = _make_ai_provider("Chill evening vibes.")
1921 plugin = _make_ai_plugin(tmp_path, ai_enabled=True, ai_provider=ai_provider)
1922
1923 rules = SmartPlaylistRules(favorites_only=True)
1924 result = await plugin._generate_ai_description("Evening Chill", rules)
1925
1926 assert result == "Chill evening vibes."
1927 ai_provider.ai_query.assert_awaited_once()
1928 prompt = ai_provider.ai_query.await_args.args[0]
1929 assert "Evening Chill" in prompt
1930 assert "Favorites only" in prompt
1931
1932
1933@pytest.mark.asyncio
1934async def test_generate_ai_description_includes_locale(tmp_path: Any) -> None:
1935 """The configured locale is passed to the provider so it answers in that language."""
1936 ai_provider = _make_ai_provider("Een rustige mix voor de avond.")
1937 plugin = _make_ai_plugin(tmp_path, ai_enabled=True, ai_provider=ai_provider)
1938 cast("Any", plugin.mass).metadata.locale = "nl_NL"
1939
1940 await plugin._generate_ai_description("Avond Chill", SmartPlaylistRules(favorites_only=True))
1941
1942 prompt = ai_provider.ai_query.await_args.args[0]
1943 assert "nl_NL" in prompt
1944
1945
1946@pytest.mark.asyncio
1947async def test_generate_ai_description_disabled_returns_none(tmp_path: Any) -> None:
1948 """With the toggle off, the AI provider is never called."""
1949 ai_provider = _make_ai_provider()
1950 plugin = _make_ai_plugin(tmp_path, ai_enabled=False, ai_provider=ai_provider)
1951
1952 result = await plugin._generate_ai_description("X", SmartPlaylistRules())
1953
1954 assert result is None
1955 ai_provider.ai_query.assert_not_awaited()
1956
1957
1958@pytest.mark.asyncio
1959async def test_generate_ai_description_no_provider_returns_none(tmp_path: Any) -> None:
1960 """With no AI_QUERY provider available, None is returned."""
1961 plugin = _make_ai_plugin(tmp_path, ai_enabled=True, ai_provider=None)
1962
1963 result = await plugin._generate_ai_description("X", SmartPlaylistRules())
1964
1965 assert result is None
1966 cast("Any", plugin.mass).get_providers_supporting_feature.assert_called_once_with(
1967 ProviderFeature.AI_QUERY, priority=(ProviderType.PLUGIN,)
1968 )
1969
1970
1971@pytest.mark.asyncio
1972async def test_generate_ai_description_provider_error_returns_none(tmp_path: Any) -> None:
1973 """A failing AI provider falls back to None instead of raising."""
1974 ai_provider = _make_ai_provider()
1975 ai_provider.ai_query = AsyncMock(side_effect=Exception("boom"))
1976 plugin = _make_ai_plugin(tmp_path, ai_enabled=True, ai_provider=ai_provider)
1977
1978 result = await plugin._generate_ai_description("X", SmartPlaylistRules(favorites_only=True))
1979
1980 assert result is None
1981
1982
1983@pytest.mark.asyncio
1984async def test_generate_ai_description_stalled_provider_returns_none(
1985 tmp_path: Any, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
1986) -> None:
1987 """A stalled AI provider gives up instead of leaving the task pending forever."""
1988 monkeypatch.setattr("music_assistant.providers.smart_playlist.AI_QUERY_TIMEOUT_SECONDS", 0.01)
1989
1990 async def _answers_too_late(*_args: Any, **_kwargs: Any) -> str:
1991 await asyncio.sleep(5)
1992 return "A mellow mix for the evening."
1993
1994 ai_provider = _make_ai_provider()
1995 ai_provider.ai_query = AsyncMock(side_effect=_answers_too_late)
1996 plugin = _make_ai_plugin(tmp_path, ai_enabled=True, ai_provider=ai_provider)
1997 caplog.set_level(logging.DEBUG, logger=plugin.logger.name)
1998
1999 result = await plugin._generate_ai_description("X", SmartPlaylistRules(favorites_only=True))
2000
2001 assert result is None
2002 assert "no response within" in caplog.text
2003
2004
2005@pytest.mark.asyncio
2006async def test_generate_ai_description_reports_a_provider_side_timeout_as_a_failure(
2007 tmp_path: Any, caplog: pytest.LogCaptureFixture
2008) -> None:
2009 """A timeout raised by the provider itself is not reported as our own cap."""
2010 ai_provider = _make_ai_provider()
2011 ai_provider.ai_query = AsyncMock(side_effect=TimeoutError)
2012 plugin = _make_ai_plugin(tmp_path, ai_enabled=True, ai_provider=ai_provider)
2013 caplog.set_level(logging.DEBUG, logger=plugin.logger.name)
2014
2015 result = await plugin._generate_ai_description("X", SmartPlaylistRules(favorites_only=True))
2016
2017 assert result is None
2018 assert "no response within" not in caplog.text
2019
2020
2021@pytest.mark.asyncio
2022async def test_generate_ai_description_stays_on_the_configured_engine(tmp_path: Any) -> None:
2023 """A failing selection yields no description instead of asking another engine."""
2024 failing = _make_ai_provider(instance_id="ai--bad")
2025 failing.ai_query = AsyncMock(side_effect=Exception("boom"))
2026 other = _make_ai_provider("Second provider result.", instance_id="ai--good")
2027 plugin = _make_ai_plugin(tmp_path, ai_enabled=True, ai_engine="ai--bad/engine")
2028 cast("Any", plugin.mass).get_providers_supporting_feature = MagicMock(
2029 return_value=[failing, other]
2030 )
2031
2032 result = await plugin._generate_ai_description("X", SmartPlaylistRules(favorites_only=True))
2033
2034 assert result is None
2035 failing.ai_query.assert_awaited_once()
2036 other.ai_query.assert_not_awaited()
2037
2038
2039@pytest.mark.asyncio
2040async def test_first_use_adopts_a_concrete_engine_selection(tmp_path: Any) -> None:
2041 """An instance without a stored selection adopts one on first use, not at load."""
2042 ai_provider = _make_ai_provider("Chill evening vibes.")
2043 plugin = _make_ai_plugin(tmp_path, ai_provider=ai_provider, ai_engine="")
2044 await plugin.handle_async_init()
2045 mass = cast("Any", plugin.mass)
2046 mass.config.set_raw_provider_config_value.assert_not_called()
2047
2048 assert (
2049 await plugin._generate_ai_description("Evening Chill", SmartPlaylistRules())
2050 == "Chill evening vibes."
2051 )
2052
2053 assert mass.config.set_raw_provider_config_value.call_args.args == (
2054 plugin.instance_id,
2055 CONF_AI_ENGINE,
2056 "ai--1/engine",
2057 )
2058
2059
2060@pytest.mark.asyncio
2061async def test_disabled_toggle_does_not_schedule_refresh(tmp_path: Any) -> None:
2062 """With the toggle off, creating a playlist schedules no background AI refresh."""
2063 plugin = _make_ai_plugin(tmp_path, ai_enabled=False)
2064 await plugin.handle_async_init()
2065 mass = cast("Any", plugin.mass)
2066 mass.music.playlists.add_item_to_library = AsyncMock(return_value=MagicMock())
2067 scheduled: list[str] = []
2068 mass.create_task = MagicMock(side_effect=_capture_scheduled(scheduled))
2069
2070 await plugin.create_smart_playlist("Evening Chill", {"favorites_only": True})
2071
2072 assert scheduled == []
2073
2074
2075@pytest.mark.asyncio
2076async def test_generate_ai_description_blank_response_returns_none(tmp_path: Any) -> None:
2077 """A blank/whitespace AI response is treated as no description."""
2078 plugin = _make_ai_plugin(tmp_path, ai_enabled=True, ai_provider=_make_ai_provider(" "))
2079
2080 result = await plugin._generate_ai_description("X", SmartPlaylistRules(favorites_only=True))
2081
2082 assert result is None
2083
2084
2085@pytest.mark.asyncio
2086async def test_build_playlist_uses_stored_ai_description(tmp_path: Any) -> None:
2087 """_build_playlist uses the stored AI description verbatim (no prefix)."""
2088 plugin = _make_ai_plugin(tmp_path)
2089 await plugin.handle_async_init()
2090 plugin._names_store["abc"] = "My List"
2091 plugin._descriptions_store["abc"] = "Hand-crafted AI summary."
2092
2093 playlist = await plugin._build_playlist("abc", SmartPlaylistRules(favorites_only=True))
2094
2095 assert playlist.metadata.description == "Hand-crafted AI summary."
2096
2097
2098@pytest.mark.asyncio
2099async def test_build_playlist_ignores_stored_description_when_disabled(tmp_path: Any) -> None:
2100 """With the toggle off, a stored AI description is ignored in favour of the summary."""
2101 plugin = _make_ai_plugin(tmp_path, ai_enabled=False)
2102 await plugin.handle_async_init()
2103 plugin._names_store["abc"] = "My List"
2104 plugin._descriptions_store["abc"] = "Old AI text."
2105 rules = SmartPlaylistRules(favorites_only=True)
2106
2107 playlist = await plugin._build_playlist("abc", rules)
2108
2109 assert playlist.metadata.description == f"[Smart Playlist] {rules.human_readable()}"
2110
2111
2112@pytest.mark.asyncio
2113async def test_build_playlist_falls_back_to_human_readable(tmp_path: Any) -> None:
2114 """Without a stored AI description, _build_playlist uses the mechanical summary."""
2115 plugin = _make_ai_plugin(tmp_path)
2116 await plugin.handle_async_init()
2117 plugin._names_store["abc"] = "My List"
2118 rules = SmartPlaylistRules(favorites_only=True)
2119
2120 playlist = await plugin._build_playlist("abc", rules)
2121
2122 assert playlist.metadata.description == f"[Smart Playlist] {rules.human_readable()}"
2123
2124
2125@pytest.mark.asyncio
2126async def test_ai_description_persists_to_disk(tmp_path: Any) -> None:
2127 """A stored AI description survives a plugin reload."""
2128 rules_dir = tmp_path / "smart_playlists"
2129 rules_dir.mkdir()
2130
2131 plugin = _make_ai_plugin(tmp_path)
2132 await plugin.handle_async_init()
2133 plugin._rules_dir = str(rules_dir)
2134 plugin._names_store["42"] = "Name"
2135 plugin._descriptions_store["42"] = "Persisted AI text."
2136 await plugin._save_rules("42", SmartPlaylistRules(genre_ids=[1]))
2137
2138 plugin2 = _make_ai_plugin(tmp_path)
2139 await plugin2.handle_async_init()
2140 plugin2._rules_dir = str(rules_dir)
2141 plugin2._rules_store = {}
2142 plugin2._names_store = {}
2143 plugin2._descriptions_store = {}
2144 await plugin2._load_rules_from_disk()
2145
2146 assert plugin2._descriptions_store.get("42") == "Persisted AI text."
2147
2148
2149@pytest.mark.asyncio
2150async def test_write_json_preserves_original_on_failure(
2151 tmp_path: Any, monkeypatch: pytest.MonkeyPatch
2152) -> None:
2153 """A failed atomic replace must leave the existing file intact, never truncated."""
2154 target = tmp_path / "rules.json"
2155 await write_json(str(target), {"value": "original"})
2156
2157 def _boom(*_: Any, **__: Any) -> None:
2158 raise OSError("replace failed")
2159
2160 monkeypatch.setattr("music_assistant.providers.smart_playlist.helpers.Path.replace", _boom)
2161 with pytest.raises(OSError, match="replace failed"):
2162 await write_json(str(target), {"value": "new"})
2163
2164 assert json.loads(target.read_text()) == {"value": "original"}
2165 # The temp file must be cleaned up so it can't accumulate on repeated failures.
2166 assert not (tmp_path / "rules.json.tmp").exists()
2167
2168
2169@pytest.mark.asyncio
2170async def test_write_json_cleans_temp_on_cancellation(
2171 tmp_path: Any, monkeypatch: pytest.MonkeyPatch
2172) -> None:
2173 """Cancellation during the write must not leave a temp file behind or corrupt the original."""
2174 target = tmp_path / "rules.json"
2175 await write_json(str(target), {"value": "original"})
2176
2177 def _cancel(*_: Any, **__: Any) -> None:
2178 raise asyncio.CancelledError
2179
2180 monkeypatch.setattr("music_assistant.providers.smart_playlist.helpers.Path.replace", _cancel)
2181 with pytest.raises(asyncio.CancelledError):
2182 await write_json(str(target), {"value": "new"})
2183
2184 assert json.loads(target.read_text()) == {"value": "original"}
2185 assert not (tmp_path / "rules.json.tmp").exists()
2186
2187
2188@pytest.mark.asyncio
2189async def test_update_playlist_description_skips_when_unchanged(tmp_path: Any) -> None:
2190 """No library write/event when the description already matches."""
2191 plugin = _make_ai_plugin(tmp_path)
2192 await plugin.handle_async_init()
2193 existing = MagicMock()
2194 existing.metadata.description = "Same text."
2195 mass = cast("Any", plugin.mass)
2196 mass.music.playlists.get_library_item = AsyncMock(return_value=existing)
2197 mass.music.playlists.update_item_in_library = AsyncMock()
2198
2199 await plugin._update_playlist_description(7, "Same text.")
2200
2201 mass.music.playlists.update_item_in_library.assert_not_awaited()
2202
2203
2204@pytest.mark.asyncio
2205async def test_update_playlist_description_writes_when_changed(tmp_path: Any) -> None:
2206 """The library item is rewritten only when the description actually differs."""
2207 plugin = _make_ai_plugin(tmp_path)
2208 await plugin.handle_async_init()
2209 existing = Playlist(
2210 item_id="1",
2211 provider="library",
2212 name="P",
2213 provider_mappings={
2214 ProviderMapping(item_id="1", provider_domain="library", provider_instance="library")
2215 },
2216 )
2217 existing.metadata = MediaItemMetadata(description="Old text.")
2218 mass = cast("Any", plugin.mass)
2219 mass.music.playlists.get_library_item = AsyncMock(return_value=existing)
2220 mass.music.playlists.update_item_in_library = AsyncMock()
2221
2222 await plugin._update_playlist_description(7, "New text.")
2223
2224 mass.music.playlists.update_item_in_library.assert_awaited_once()
2225 written = mass.music.playlists.update_item_in_library.await_args.args[1]
2226 assert written.metadata.description == "New text."
2227
2228
2229@pytest.mark.asyncio
2230async def test_create_smart_playlist_schedules_ai_generation(tmp_path: Any) -> None:
2231 """Creating a smart playlist schedules background AI description generation."""
2232 plugin = _make_ai_plugin(tmp_path)
2233 await plugin.handle_async_init()
2234 mass = cast("Any", plugin.mass)
2235 mass.music.playlists.add_item_to_library = AsyncMock(return_value=MagicMock())
2236 scheduled: list[str] = []
2237 mass.create_task = MagicMock(side_effect=_capture_scheduled(scheduled))
2238
2239 await plugin.create_smart_playlist("Evening Chill", {"favorites_only": True})
2240
2241 assert scheduled == ["_refresh_ai_description"]
2242 # Deduped per playlist so rapid calls don't run concurrent refreshes.
2243 kwargs = mass.create_task.call_args.kwargs
2244 assert kwargs["task_id"].startswith("smart_playlist_ai_desc_")
2245 assert kwargs["abort_existing"] is True
2246
2247
2248@pytest.mark.asyncio
2249async def test_update_rules_drops_stale_and_schedules_regeneration(tmp_path: Any) -> None:
2250 """Updating rules clears the stale AI description, sets the fallback, and regenerates."""
2251 plugin = _make_ai_plugin(tmp_path)
2252 await plugin.handle_async_init()
2253 plugin._rules_store["abc"] = SmartPlaylistRules(favorites_only=True)
2254 plugin._names_store["abc"] = "Name"
2255 plugin._descriptions_store["abc"] = "Stale AI text."
2256 mass = cast("Any", plugin.mass)
2257 scheduled: list[str] = []
2258 mass.create_task = MagicMock(side_effect=_capture_scheduled(scheduled))
2259 mass.music.playlists.get_library_item_by_prov_id = AsyncMock(
2260 return_value=_make_library_item(plugin, "abc")
2261 )
2262 cast("Any", plugin)._update_playlist_description = AsyncMock()
2263
2264 await plugin.update_smart_playlist_rules("abc", {"genre_ids": [1]})
2265
2266 assert "abc" not in plugin._descriptions_store
2267 # The stale description must also be invalidated on disk, not just in memory, so it
2268 # cannot be reloaded after a restart before the background refresh runs.
2269 persisted = json.loads((tmp_path / "smart_playlists" / RULES_FILENAME).read_text())
2270 assert persisted["abc"]["ai_description"] is None
2271 scheduled_desc = cast("Any", plugin)._update_playlist_description.await_args.args[1]
2272 assert scheduled_desc.startswith("[Smart Playlist]")
2273 assert scheduled == ["_refresh_ai_description"]
2274 assert mass.create_task.call_args.kwargs == {
2275 "task_id": "smart_playlist_ai_desc_abc",
2276 "abort_existing": True,
2277 }
2278
2279
2280@pytest.mark.asyncio
2281async def test_update_rules_skips_metadata_refresh_if_unchanged(tmp_path: Any) -> None:
2282 """Updating rules with identical values does not trigger metadata refresh."""
2283 plugin = _make_ai_plugin(tmp_path)
2284 await plugin.handle_async_init()
2285 initial_rules = SmartPlaylistRules(favorites_only=True, genre_ids=[1])
2286 plugin._rules_store["abc"] = initial_rules
2287 plugin._names_store["abc"] = "Name"
2288 mass = cast("Any", plugin.mass)
2289 mass.music.playlists.get_library_item_by_prov_id = AsyncMock(
2290 return_value=_make_library_item(plugin, "abc")
2291 )
2292 cast("Any", plugin)._update_playlist_description = AsyncMock()
2293 mass.create_task = MagicMock()
2294 mass.call_later = MagicMock()
2295
2296 # Update with identical rules
2297 await plugin.update_smart_playlist_rules("abc", {"favorites_only": True, "genre_ids": [1]})
2298
2299 # Should not trigger metadata refresh since rules didn't change
2300 mass.call_later.assert_not_called()
2301
2302
2303@pytest.mark.asyncio
2304async def test_update_rules_triggers_metadata_refresh_if_changed(tmp_path: Any) -> None:
2305 """Updating rules with different values triggers metadata refresh."""
2306 plugin = _make_ai_plugin(tmp_path)
2307 await plugin.handle_async_init()
2308 initial_rules = SmartPlaylistRules(favorites_only=True)
2309 plugin._rules_store["abc"] = initial_rules
2310 plugin._names_store["abc"] = "Name"
2311 mass = cast("Any", plugin.mass)
2312 library_item = _make_library_item(plugin, "abc")
2313 mass.music.playlists.get_library_item_by_prov_id = AsyncMock(return_value=library_item)
2314 cast("Any", plugin)._update_playlist_description = AsyncMock()
2315 mass.create_task = MagicMock()
2316 mass.call_later = MagicMock()
2317
2318 # Update with different rules
2319 await plugin.update_smart_playlist_rules("abc", {"genre_ids": [1]})
2320
2321 # Should trigger metadata refresh since rules changed
2322 mass.call_later.assert_called_once()
2323 args = mass.call_later.call_args
2324 assert args[0][0] == 5 # delay
2325 assert args[0][1] == mass.metadata.update_metadata # function
2326 assert args[0][2] == library_item # library_item
2327 assert args[1]["task_id"] == "smart_playlist_metadata_refresh_abc"
2328 assert args[1]["force_refresh"] is True
2329
2330
2331@pytest.mark.asyncio
2332async def test_refresh_ai_description_stores_and_updates(tmp_path: Any) -> None:
2333 """The background refresh stores the AI text and pushes it to the library item."""
2334 plugin = _make_ai_plugin(tmp_path, ai_provider=_make_ai_provider("Fresh AI summary."))
2335 await plugin.handle_async_init()
2336 plugin._rules_store["abc"] = SmartPlaylistRules(favorites_only=True)
2337 plugin._names_store["abc"] = "Name"
2338 cast("Any", plugin.mass).music.playlists.get_library_item_by_prov_id = AsyncMock(
2339 return_value=_make_library_item(plugin, "abc")
2340 )
2341 cast("Any", plugin)._update_playlist_description = AsyncMock()
2342
2343 await plugin._refresh_ai_description("abc")
2344
2345 assert plugin._descriptions_store["abc"] == "Fresh AI summary."
2346 cast("Any", plugin)._update_playlist_description.assert_awaited_once()
2347 assert (
2348 cast("Any", plugin)._update_playlist_description.await_args.args[1] == "Fresh AI summary."
2349 )
2350
2351
2352@pytest.mark.asyncio
2353async def test_refresh_ai_description_no_provider_uses_fallback(tmp_path: Any) -> None:
2354 """With no AI available, the refresh drops any stale text and writes the fallback."""
2355 plugin = _make_ai_plugin(tmp_path, ai_provider=None)
2356 await plugin.handle_async_init()
2357 rules = SmartPlaylistRules(favorites_only=True)
2358 plugin._rules_store["abc"] = rules
2359 plugin._names_store["abc"] = "Name"
2360 plugin._descriptions_store["abc"] = "Stale."
2361 cast("Any", plugin.mass).music.playlists.get_library_item_by_prov_id = AsyncMock(
2362 return_value=_make_library_item(plugin, "abc")
2363 )
2364 cast("Any", plugin)._update_playlist_description = AsyncMock()
2365
2366 await plugin._refresh_ai_description("abc")
2367
2368 assert "abc" not in plugin._descriptions_store
2369 written = cast("Any", plugin)._update_playlist_description.await_args.args[1]
2370 assert written == f"[Smart Playlist] {rules.human_readable()}"
2371
2372
2373@pytest.mark.asyncio
2374async def test_refresh_ai_description_skips_flush_when_unchanged(tmp_path: Any) -> None:
2375 """No rules-file flush when the stored description doesn't change (e.g. no AI provider)."""
2376 plugin = _make_ai_plugin(tmp_path, ai_provider=None)
2377 await plugin.handle_async_init()
2378 plugin._rules_store["abc"] = SmartPlaylistRules(favorites_only=True)
2379 plugin._names_store["abc"] = "Name" # no stored description to begin with
2380 cast("Any", plugin)._flush_rules_to_disk = AsyncMock()
2381 cast("Any", plugin)._update_playlist_description = AsyncMock()
2382 cast("Any", plugin.mass).music.playlists.get_library_item_by_prov_id = AsyncMock(
2383 return_value=_make_library_item(plugin, "abc")
2384 )
2385
2386 await plugin._refresh_ai_description("abc")
2387
2388 cast("Any", plugin)._flush_rules_to_disk.assert_not_awaited()
2389
2390
2391# ---------------------------------------------------------------------------
2392# Genre AND logic filtering tests
2393# ---------------------------------------------------------------------------
2394
2395
2396@pytest.mark.asyncio
2397async def test_filter_tracks_with_all_genres_filters_correctly() -> None: # noqa: PLR0915
2398 """_filter_tracks_with_all_genres returns only tracks that have ALL required genres."""
2399 mass = MagicMock()
2400 manifest = MagicMock()
2401 manifest.domain = "smart_playlist"
2402 config = MagicMock()
2403 config.get_value.return_value = "GLOBAL"
2404 plugin = SmartPlaylistProvider(mass, manifest, config, set())
2405
2406 # Create mock tracks:
2407 # Track 1: Library track (direct)
2408 track_1 = _make_mock_track("1", uri="library://track/101")
2409 track_1.provider = "library"
2410 track_1.item_id = "101"
2411
2412 # Track 2: Spotify track with library mapping
2413 track_2 = _make_mock_track("2", uri="spotify://track/abc")
2414 track_2.provider = "spotify"
2415 track_2.item_id = "abc"
2416 track_2_mapping = MagicMock()
2417 track_2_mapping.provider_domain = "spotify"
2418 track_2_mapping.provider_instance = None
2419 track_2_mapping.item_id = "abc" # Provider's item ID
2420 track_2.provider_mappings = [track_2_mapping]
2421
2422 # Track 3: Apple Music track with library mapping
2423 track_3 = _make_mock_track("3", uri="apple_music://track/xyz")
2424 track_3.provider = "apple_music"
2425 track_3.item_id = "xyz"
2426 track_3_mapping = MagicMock()
2427 track_3_mapping.provider_domain = "apple_music"
2428 track_3_mapping.provider_instance = None
2429 track_3_mapping.item_id = "xyz" # Provider's item ID
2430 track_3.provider_mappings = [track_3_mapping]
2431
2432 tracks = [track_1, track_2, track_3]
2433
2434 # Mock get_library_item_by_prov_id to resolve provider items to library items
2435 async def mock_get_library_item(
2436 item_id: str, provider_instance_id_or_domain: str
2437 ) -> MagicMock | None:
2438 if provider_instance_id_or_domain == "spotify" and item_id == "abc":
2439 lib_track = MagicMock()
2440 lib_track.item_id = "102" # Library DB ID
2441 return lib_track
2442 if provider_instance_id_or_domain == "apple_music" and item_id == "xyz":
2443 lib_track = MagicMock()
2444 lib_track.item_id = "103" # Library DB ID
2445 return lib_track
2446 return None
2447
2448 cast("Any", plugin.mass.music.tracks).get_library_item_by_prov_id = mock_get_library_item
2449
2450 # Mock genres for each track:
2451 # Track 101: has genres 10 and 20 (matches requirement)
2452 # Track 102: has only genre 10 (missing 20, should be filtered out)
2453 # Track 103: has genres 10, 20, and 30 (matches requirement with extra genre)
2454 async def mock_get_genres(_media_type: Any, media_id: int) -> list[MagicMock]:
2455 genre_10 = MagicMock()
2456 genre_10.item_id = "10"
2457 genre_20 = MagicMock()
2458 genre_20.item_id = "20"
2459 genre_30 = MagicMock()
2460 genre_30.item_id = "30"
2461
2462 if media_id == 101:
2463 return [genre_10, genre_20]
2464 if media_id == 102:
2465 return [genre_10]
2466 if media_id == 103:
2467 return [genre_10, genre_20, genre_30]
2468 return []
2469
2470 cast("Any", plugin.mass.music.genres).get_genres_for_media_item = mock_get_genres
2471
2472 # Require genres 10 AND 20
2473 result = await plugin._filter_tracks_with_all_genres(cast("Any", tracks), [10, 20])
2474
2475 # Only tracks 1 and 3 should be returned (have both genres 10 and 20)
2476 assert len(result) == 2
2477 result_uris = {t.uri for t in result}
2478 assert "library://track/101" in result_uris
2479 assert "apple_music://track/xyz" in result_uris
2480 assert "spotify://track/abc" not in result_uris
2481
2482
2483@pytest.mark.asyncio
2484async def test_filter_tracks_with_all_genres_preserves_order() -> None:
2485 """_filter_tracks_with_all_genres preserves the original track order."""
2486 mass = MagicMock()
2487 manifest = MagicMock()
2488 manifest.domain = "smart_playlist"
2489 config = MagicMock()
2490 config.get_value.return_value = "GLOBAL"
2491 plugin = SmartPlaylistProvider(mass, manifest, config, set())
2492
2493 # Create tracks in specific order: 103, 101, 102
2494 track_3 = _make_mock_track("3", uri="library://track/103")
2495 track_3.provider = "library"
2496 track_3.item_id = "103"
2497
2498 track_1 = _make_mock_track("1", uri="spotify://track/abc")
2499 track_1.provider = "spotify"
2500 track_1.item_id = "abc"
2501 track_1_mapping = MagicMock()
2502 track_1_mapping.provider_domain = "spotify"
2503 track_1_mapping.provider_instance = None
2504 track_1_mapping.item_id = "abc" # Provider's item ID
2505 track_1.provider_mappings = [track_1_mapping]
2506
2507 track_2 = _make_mock_track("2", uri="apple_music://track/xyz")
2508 track_2.provider = "apple_music"
2509 track_2.item_id = "xyz"
2510 track_2_mapping = MagicMock()
2511 track_2_mapping.provider_domain = "apple_music"
2512 track_2_mapping.provider_instance = None
2513 track_2_mapping.item_id = "xyz" # Provider's item ID
2514 track_2.provider_mappings = [track_2_mapping]
2515
2516 tracks = [track_3, track_1, track_2]
2517
2518 # Mock get_library_item_by_prov_id to resolve provider items to library items
2519 async def mock_get_library_item(
2520 item_id: str, provider_instance_id_or_domain: str
2521 ) -> MagicMock | None:
2522 if provider_instance_id_or_domain == "spotify" and item_id == "abc":
2523 lib_track = MagicMock()
2524 lib_track.item_id = "101" # Library DB ID
2525 return lib_track
2526 if provider_instance_id_or_domain == "apple_music" and item_id == "xyz":
2527 lib_track = MagicMock()
2528 lib_track.item_id = "102" # Library DB ID
2529 return lib_track
2530 return None
2531
2532 cast("Any", plugin.mass.music.tracks).get_library_item_by_prov_id = mock_get_library_item
2533
2534 # All three tracks have both required genres
2535 async def mock_get_genres(_media_type: Any, _media_id: int) -> list[MagicMock]:
2536 genre_10 = MagicMock()
2537 genre_10.item_id = "10"
2538 genre_20 = MagicMock()
2539 genre_20.item_id = "20"
2540 return [genre_10, genre_20]
2541
2542 cast("Any", plugin.mass.music.genres).get_genres_for_media_item = mock_get_genres
2543
2544 result = await plugin._filter_tracks_with_all_genres(cast("Any", tracks), [10, 20])
2545
2546 # Order should be preserved: track 3, track 1, track 2
2547 assert len(result) == 3
2548 assert result[0].uri == "library://track/103"
2549 assert result[1].uri == "spotify://track/abc"
2550 assert result[2].uri == "apple_music://track/xyz"
2551
2552
2553@pytest.mark.asyncio
2554async def test_filter_tracks_with_all_genres_handles_non_numeric_genre_ids() -> None:
2555 """_filter_tracks_with_all_genres ignores genres with non-numeric IDs."""
2556 mass = MagicMock()
2557 manifest = MagicMock()
2558 manifest.domain = "smart_playlist"
2559 config = MagicMock()
2560 config.get_value.return_value = "GLOBAL"
2561 plugin = SmartPlaylistProvider(mass, manifest, config, set())
2562
2563 track_1 = _make_mock_track("1", uri="library://track/101")
2564 track_1.provider = "library"
2565 track_1.item_id = "101"
2566
2567 tracks = [track_1]
2568
2569 # Return a mix of numeric and non-numeric genre IDs
2570 async def mock_get_genres(_media_type: Any, _media_id: int) -> list[MagicMock]:
2571 genre_10 = MagicMock()
2572 genre_10.item_id = "10"
2573 genre_20 = MagicMock()
2574 genre_20.item_id = "20"
2575 genre_invalid = MagicMock()
2576 genre_invalid.item_id = "bandcamp:123-456" # non-numeric
2577 return [genre_10, genre_20, genre_invalid]
2578
2579 cast("Any", plugin.mass.music.genres).get_genres_for_media_item = mock_get_genres
2580
2581 # Should still match because track has genres 10 and 20 (ignoring the invalid one)
2582 result = await plugin._filter_tracks_with_all_genres(cast("Any", tracks), [10, 20])
2583
2584 assert len(result) == 1
2585 assert result[0].uri == "library://track/101"
2586
2587
2588# ---------------------------------------------------------------------------
2589# get_playlist_tracks â recency track filter (dynamic playlists only)
2590# ---------------------------------------------------------------------------
2591
2592
2593@pytest.mark.asyncio
2594async def test_dynamic_playlist_applies_recency_filter() -> None:
2595 """A dynamic smart playlist drops filter-rejected tracks at the boundary."""
2596 mass = MagicMock()
2597 manifest = MagicMock()
2598 manifest.domain = "smart_playlist"
2599 config = MagicMock()
2600 config.get_value.return_value = "GLOBAL"
2601 plugin = SmartPlaylistProvider(mass, manifest, config, set())
2602
2603 rules = SmartPlaylistRules(is_dynamic=True)
2604 cast("Any", plugin)._resolve_rules_for_playlist_id = AsyncMock(return_value=("42", rules))
2605 sample = [_make_mock_track("keep"), _make_mock_track("drop")]
2606 cast("Any", plugin)._cached_dynamic_sample = AsyncMock(return_value=sample)
2607
2608 with track_filter(lambda track: track.item_id != "drop"):
2609 result = await plugin.get_playlist_tracks("42")
2610
2611 assert [track.item_id for track in result] == ["keep"]
2612
2613
2614@pytest.mark.asyncio
2615async def test_static_playlist_ignores_recency_filter() -> None:
2616 """A non-dynamic smart playlist is evaluated as-is and never recency-filtered."""
2617 mass = MagicMock()
2618 manifest = MagicMock()
2619 manifest.domain = "smart_playlist"
2620 config = MagicMock()
2621 config.get_value.return_value = "GLOBAL"
2622 plugin = SmartPlaylistProvider(mass, manifest, config, set())
2623
2624 rules = SmartPlaylistRules(is_dynamic=False)
2625 cast("Any", plugin)._resolve_rules_for_playlist_id = AsyncMock(return_value=("42", rules))
2626 evaluated = [_make_mock_track("keep"), _make_mock_track("drop")]
2627 cast("Any", plugin)._evaluate_rules = AsyncMock(return_value=evaluated)
2628
2629 with track_filter(lambda track: track.item_id != "drop"):
2630 result = await plugin.get_playlist_tracks("42")
2631
2632 assert [track.item_id for track in result] == ["keep", "drop"]
2633