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