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