/
/
/
1"""Integration tests for the GenreController (V3 schema).
2
3Uses the ``mass`` fixture from ``tests/conftest.py`` which creates a full
4MusicAssistant instance with a real SQLite database in a temporary directory.
5"""
6
7from __future__ import annotations
8
9import asyncio
10import json
11import logging
12from collections.abc import AsyncGenerator
13
14import pytest
15from music_assistant_models.enums import MediaType
16from music_assistant_models.errors import MediaNotFoundError
17from music_assistant_models.media_items import (
18 Artist,
19 Genre,
20 Track,
21)
22from music_assistant_models.unique_list import UniqueList
23
24from music_assistant.constants import (
25 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
26 DB_TABLE_GENRES,
27 DEFAULT_GENRE_MAPPING,
28)
29from music_assistant.controllers.media.genres import GenreController
30from music_assistant.mass import MusicAssistant
31
32# ---------------------------------------------------------------------------
33# Fixtures & helpers
34# ---------------------------------------------------------------------------
35
36
37@pytest.fixture(scope="class")
38async def mass(tmp_path_factory: pytest.TempPathFactory) -> AsyncGenerator[MusicAssistant, None]:
39 """Class-scoped MusicAssistant instance (one per test class)."""
40 tmp_path = tmp_path_factory.mktemp("genre_tests")
41 storage_path = tmp_path / "data"
42 cache_path = tmp_path / "cache"
43 storage_path.mkdir(parents=True)
44 cache_path.mkdir(parents=True)
45 logging.getLogger("aiosqlite").level = logging.INFO
46 mass_instance = MusicAssistant(str(storage_path), str(cache_path))
47 await mass_instance.start()
48 try:
49 yield mass_instance
50 finally:
51 await mass_instance.stop()
52
53
54@pytest.fixture(scope="class")
55async def genre_ctrl(mass: MusicAssistant) -> GenreController:
56 """Get the genre controller from a running MusicAssistant instance."""
57 return mass.music.genres
58
59
60def _make_genre(name: str, favorite: bool = False) -> Genre:
61 """Create a Genre object for adding to the library."""
62 return Genre(
63 item_id="0",
64 provider="library",
65 name=name,
66 provider_mappings=set(),
67 favorite=favorite,
68 )
69
70
71async def _add_test_artist(mass: MusicAssistant, name: str) -> Artist:
72 """Add a minimal artist to the library."""
73 artist = Artist(
74 item_id="0",
75 provider="library",
76 name=name,
77 provider_mappings=set(),
78 )
79 return await mass.music.artists.add_item_to_library(artist)
80
81
82async def _add_test_track(mass: MusicAssistant, name: str) -> Track:
83 """Add a minimal track to the library (creates an artist first)."""
84 artist = await _add_test_artist(mass, f"Artist for {name}")
85 track = Track(
86 item_id="0",
87 provider="library",
88 name=name,
89 provider_mappings=set(),
90 artists=UniqueList([artist]),
91 )
92 return await mass.music.tracks.add_item_to_library(track)
93
94
95# ===================================================================
96# Group B: Genre CRUD (14 tests)
97# ===================================================================
98
99
100class TestGenreCRUD:
101 """Tests for adding, reading, updating, and removing genres."""
102
103 async def test_add_genre(self, genre_ctrl: GenreController) -> None:
104 """add_item_to_library returns Genre with numeric id and correct name."""
105 genre = await genre_ctrl.add_item_to_library(_make_genre("Rock"))
106 assert int(genre.item_id) > 0
107 assert genre.name == "Rock"
108
109 async def test_add_genre_creates_self_alias(
110 self, mass: MusicAssistant, genre_ctrl: GenreController
111 ) -> None:
112 """Genre has its own name in genre_aliases JSON column."""
113 genre = await genre_ctrl.add_item_to_library(_make_genre("Blues"))
114 # Check genre_aliases JSON column directly
115 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": int(genre.item_id)})
116 assert row is not None
117 aliases = json.loads(row["genre_aliases"])
118 assert "Blues" in aliases
119
120 async def test_add_genre_duplicate_updates(self, genre_ctrl: GenreController) -> None:
121 """Adding the same genre with library id returns the same item_id (update, no duplicate)."""
122 genre1 = await genre_ctrl.add_item_to_library(_make_genre("Jazz"))
123 # Second add using the real library id (simulates re-adding same item)
124 dup = Genre(
125 item_id=genre1.item_id,
126 provider="library",
127 name="Jazz",
128 provider_mappings=set(),
129 )
130 genre2 = await genre_ctrl.add_item_to_library(dup)
131 assert genre1.item_id == genre2.item_id
132
133 async def test_get_library_item(self, genre_ctrl: GenreController) -> None:
134 """get_library_item returns Genre with genre_aliases populated."""
135 created = await genre_ctrl.add_item_to_library(_make_genre("Funk"))
136 fetched = await genre_ctrl.get_library_item(int(created.item_id))
137 assert fetched.name == "Funk"
138 assert fetched.genre_aliases is not None
139 assert "Funk" in fetched.genre_aliases
140
141 async def test_get_library_item_not_found(self, genre_ctrl: GenreController) -> None:
142 """Raises MediaNotFoundError for nonexistent id."""
143 with pytest.raises(MediaNotFoundError):
144 await genre_ctrl.get_library_item(999999)
145
146 async def test_update_smart_merge(self, genre_ctrl: GenreController) -> None:
147 """Update with metadata merges without overwrite flag."""
148 genre = await genre_ctrl.add_item_to_library(_make_genre("Reggae"))
149 update = _make_genre("Reggae")
150 update.favorite = True
151 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=False)
152 assert updated.favorite is True
153 assert updated.name == "Reggae"
154
155 async def test_update_overwrite(self, genre_ctrl: GenreController) -> None:
156 """Update with overwrite=True replaces name."""
157 genre = await genre_ctrl.add_item_to_library(_make_genre("OldName"))
158 update = _make_genre("NewName")
159 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=True)
160 assert updated.name == "NewName"
161
162 async def test_update_ensures_self_alias(self, genre_ctrl: GenreController) -> None:
163 """After name update, self-alias exists for new name."""
164 genre = await genre_ctrl.add_item_to_library(_make_genre("OldGenre"))
165 update = _make_genre("RenamedGenre")
166 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=True)
167 assert updated.genre_aliases is not None
168 assert "RenamedGenre" in updated.genre_aliases
169
170 async def test_remove_genre(self, genre_ctrl: GenreController) -> None:
171 """After remove, get_library_item raises MediaNotFoundError."""
172 genre = await genre_ctrl.add_item_to_library(_make_genre("Ska"))
173 await genre_ctrl.remove_item_from_library(genre.item_id)
174 with pytest.raises(MediaNotFoundError):
175 await genre_ctrl.get_library_item(int(genre.item_id))
176
177 async def test_remove_cleans_mappings(
178 self, mass: MusicAssistant, genre_ctrl: GenreController
179 ) -> None:
180 """After remove, genre_media_item_mapping entries for that genre are gone."""
181 genre = await genre_ctrl.add_item_to_library(_make_genre("Dubstep"))
182 genre_id = int(genre.item_id)
183 # Add a media mapping first
184 track = await _add_test_track(mass, "Dubstep Track")
185 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track.item_id, "Dubstep")
186 # Now remove the genre
187 await genre_ctrl.remove_item_from_library(genre.item_id)
188 rows = await mass.music.database.get_rows_from_query(
189 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} WHERE genre_id = :genre_id",
190 {"genre_id": genre_id},
191 limit=0,
192 )
193 assert len(rows) == 0
194
195 async def test_library_items(self, genre_ctrl: GenreController) -> None:
196 """Add 3 genres, returns all 3."""
197 for name in ("Alpha", "Beta", "Gamma"):
198 await genre_ctrl.add_item_to_library(_make_genre(name))
199 items = await genre_ctrl.library_items()
200 names = {g.name for g in items}
201 assert {"Alpha", "Beta", "Gamma"}.issubset(names)
202
203 async def test_library_items_search(self, genre_ctrl: GenreController) -> None:
204 """Search 'country' returns only matching genres."""
205 await genre_ctrl.add_item_to_library(_make_genre("Country"))
206 await genre_ctrl.add_item_to_library(_make_genre("Metal"))
207 items = await genre_ctrl.library_items(search="country")
208 assert all("country" in g.name.lower() for g in items)
209
210 async def test_library_items_rejects_genre_param(self, genre_ctrl: GenreController) -> None:
211 """library_items(genre=1) raises ValueError."""
212 with pytest.raises(ValueError, match="genre parameter is not supported"):
213 await genre_ctrl.library_items(genre=1)
214
215 async def test_library_count(self, genre_ctrl: GenreController) -> None:
216 """Returns correct count; favorite_only=True filters."""
217 await genre_ctrl.add_item_to_library(_make_genre("CountA"))
218 await genre_ctrl.add_item_to_library(_make_genre("CountB", favorite=True))
219 total = await genre_ctrl.library_count()
220 assert total >= 2
221 fav = await genre_ctrl.library_count(favorite_only=True)
222 assert fav >= 1
223 assert fav <= total
224
225
226# ===================================================================
227# Group C: Alias Operations (8 tests)
228# ===================================================================
229
230
231class TestAliasOperations:
232 """Tests for add_alias, remove_alias string operations on genres."""
233
234 async def test_add_alias(self, genre_ctrl: GenreController) -> None:
235 """add_alias adds a string to genre_aliases."""
236 genre = await genre_ctrl.add_item_to_library(_make_genre("Electronic"))
237 updated = await genre_ctrl.add_alias(genre.item_id, "EDM")
238 assert updated.genre_aliases is not None
239 assert "EDM" in updated.genre_aliases
240 assert "Electronic" in updated.genre_aliases
241
242 async def test_add_alias_idempotent(self, genre_ctrl: GenreController) -> None:
243 """Adding the same alias twice doesn't duplicate."""
244 genre = await genre_ctrl.add_item_to_library(_make_genre("House"))
245 await genre_ctrl.add_alias(genre.item_id, "Deep House")
246 updated = await genre_ctrl.add_alias(genre.item_id, "Deep House")
247 assert updated.genre_aliases is not None
248 assert list(updated.genre_aliases).count("Deep House") == 1
249
250 async def test_add_alias_multiple(self, genre_ctrl: GenreController) -> None:
251 """Multiple aliases can be added to a single genre."""
252 genre = await genre_ctrl.add_item_to_library(_make_genre("Ambient"))
253 await genre_ctrl.add_alias(genre.item_id, "Ambient Music")
254 updated = await genre_ctrl.add_alias(genre.item_id, "Chill Ambient")
255 assert updated.genre_aliases is not None
256 assert "Ambient" in updated.genre_aliases
257 assert "Ambient Music" in updated.genre_aliases
258 assert "Chill Ambient" in updated.genre_aliases
259
260 async def test_remove_alias(self, genre_ctrl: GenreController) -> None:
261 """remove_alias removes a string from genre_aliases."""
262 genre = await genre_ctrl.add_item_to_library(_make_genre("Techno"))
263 await genre_ctrl.add_alias(genre.item_id, "Detroit Techno")
264 updated = await genre_ctrl.remove_alias(genre.item_id, "Detroit Techno")
265 assert updated.genre_aliases is not None
266 assert "Detroit Techno" not in updated.genre_aliases
267 assert "Techno" in updated.genre_aliases
268
269 async def test_remove_self_alias_raises(self, genre_ctrl: GenreController) -> None:
270 """Removing the genre's own name raises ValueError."""
271 genre = await genre_ctrl.add_item_to_library(_make_genre("Soul"))
272 with pytest.raises(ValueError, match="Cannot remove self-alias"):
273 await genre_ctrl.remove_alias(genre.item_id, "Soul")
274
275 async def test_remove_alias_cleans_media_mappings(
276 self, mass: MusicAssistant, genre_ctrl: GenreController
277 ) -> None:
278 """Removing an alias also removes media mappings that used that alias."""
279 genre = await genre_ctrl.add_item_to_library(_make_genre("Latin"))
280 await genre_ctrl.add_alias(genre.item_id, "Latin Pop")
281 track = await _add_test_track(mass, "Latin Track")
282 await genre_ctrl.add_media_mapping(
283 genre.item_id, MediaType.TRACK, track.item_id, "Latin Pop"
284 )
285 # Remove the alias
286 await genre_ctrl.remove_alias(genre.item_id, "Latin Pop")
287 # Check mapping is gone
288 rows = await mass.music.database.get_rows_from_query(
289 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
290 "WHERE genre_id = :gid AND alias = :alias",
291 {"gid": int(genre.item_id), "alias": "Latin Pop"},
292 limit=0,
293 )
294 assert len(rows) == 0
295
296 async def test_add_alias_not_found(self, genre_ctrl: GenreController) -> None:
297 """add_alias for nonexistent genre raises MediaNotFoundError."""
298 with pytest.raises(MediaNotFoundError):
299 await genre_ctrl.add_alias(999999, "NoGenre")
300
301 async def test_remove_alias_not_found(self, genre_ctrl: GenreController) -> None:
302 """remove_alias for nonexistent genre raises MediaNotFoundError."""
303 with pytest.raises(MediaNotFoundError):
304 await genre_ctrl.remove_alias(999999, "NoGenre")
305
306
307# ===================================================================
308# Group D: Media Mapping Operations (8 tests)
309# ===================================================================
310
311
312class TestMediaMappingOperations:
313 """Tests for add_media_mapping and remove_media_mapping."""
314
315 async def test_add_media_mapping_track(
316 self, mass: MusicAssistant, genre_ctrl: GenreController
317 ) -> None:
318 """Mapping exists in genre_media_item_mapping table."""
319 genre = await genre_ctrl.add_item_to_library(_make_genre("Pop"))
320 track = await _add_test_track(mass, "Pop Track")
321 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Pop")
322 rows = await mass.music.database.get_rows_from_query(
323 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
324 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
325 {
326 "gid": int(genre.item_id),
327 "mt": MediaType.TRACK.value,
328 "mid": int(track.item_id),
329 },
330 limit=1,
331 )
332 assert len(rows) == 1
333 assert rows[0]["alias"] == "Pop"
334
335 async def test_add_media_mapping_idempotent(
336 self, mass: MusicAssistant, genre_ctrl: GenreController
337 ) -> None:
338 """Calling add_media_mapping twice doesn't raise (uses allow_replace)."""
339 genre = await genre_ctrl.add_item_to_library(_make_genre("Grunge"))
340 track = await _add_test_track(mass, "Grunge Song")
341 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Grunge")
342 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Grunge")
343
344 async def test_remove_media_mapping_track(
345 self, mass: MusicAssistant, genre_ctrl: GenreController
346 ) -> None:
347 """Mapping removed from DB."""
348 genre = await genre_ctrl.add_item_to_library(_make_genre("Disco"))
349 track = await _add_test_track(mass, "Disco Track")
350 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Disco")
351 await genre_ctrl.remove_media_mapping(genre.item_id, MediaType.TRACK, track.item_id)
352 rows = await mass.music.database.get_rows_from_query(
353 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
354 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
355 {
356 "gid": int(genre.item_id),
357 "mt": MediaType.TRACK.value,
358 "mid": int(track.item_id),
359 },
360 limit=1,
361 )
362 assert len(rows) == 0
363
364 async def test_add_media_mapping_artist(
365 self, mass: MusicAssistant, genre_ctrl: GenreController
366 ) -> None:
367 """Artist mapping works correctly."""
368 genre = await genre_ctrl.add_item_to_library(_make_genre("Funk2"))
369 artist = await _add_test_artist(mass, "Funk Artist")
370 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.ARTIST, artist.item_id, "Funk2")
371 rows = await mass.music.database.get_rows_from_query(
372 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
373 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
374 {
375 "gid": int(genre.item_id),
376 "mt": MediaType.ARTIST.value,
377 "mid": int(artist.item_id),
378 },
379 limit=1,
380 )
381 assert len(rows) == 1
382
383 async def test_mapping_preserves_alias_string(
384 self, mass: MusicAssistant, genre_ctrl: GenreController
385 ) -> None:
386 """The alias column records which alias caused the mapping."""
387 genre = await genre_ctrl.add_item_to_library(_make_genre("Afrobeat"))
388 await genre_ctrl.add_alias(genre.item_id, "Highlife")
389 track = await _add_test_track(mass, "Afrobeat Track")
390 await genre_ctrl.add_media_mapping(
391 genre.item_id, MediaType.TRACK, track.item_id, "Highlife"
392 )
393 rows = await mass.music.database.get_rows_from_query(
394 f"SELECT alias FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
395 "WHERE genre_id = :gid AND media_id = :mid",
396 {"gid": int(genre.item_id), "mid": int(track.item_id)},
397 limit=1,
398 )
399 assert len(rows) == 1
400 assert rows[0]["alias"] == "Highlife"
401
402 async def test_multiple_genres_same_track(
403 self, mass: MusicAssistant, genre_ctrl: GenreController
404 ) -> None:
405 """A track can be mapped to multiple genres."""
406 genre1 = await genre_ctrl.add_item_to_library(_make_genre("Genre1"))
407 genre2 = await genre_ctrl.add_item_to_library(_make_genre("Genre2"))
408 track = await _add_test_track(mass, "Multi Genre Track")
409 await genre_ctrl.add_media_mapping(genre1.item_id, MediaType.TRACK, track.item_id, "Genre1")
410 await genre_ctrl.add_media_mapping(genre2.item_id, MediaType.TRACK, track.item_id, "Genre2")
411 rows = await mass.music.database.get_rows_from_query(
412 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
413 "WHERE media_id = :mid AND media_type = 'track'",
414 {"mid": int(track.item_id)},
415 limit=0,
416 )
417 assert len(rows) == 2
418
419 async def test_multiple_tracks_same_genre(
420 self, mass: MusicAssistant, genre_ctrl: GenreController
421 ) -> None:
422 """Multiple tracks can be mapped to the same genre."""
423 genre = await genre_ctrl.add_item_to_library(_make_genre("SharedGenre"))
424 track1 = await _add_test_track(mass, "Shared Track 1")
425 track2 = await _add_test_track(mass, "Shared Track 2")
426 await genre_ctrl.add_media_mapping(
427 genre.item_id, MediaType.TRACK, track1.item_id, "SharedGenre"
428 )
429 await genre_ctrl.add_media_mapping(
430 genre.item_id, MediaType.TRACK, track2.item_id, "SharedGenre"
431 )
432 rows = await mass.music.database.get_rows_from_query(
433 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
434 "WHERE genre_id = :gid AND media_type = 'track'",
435 {"gid": int(genre.item_id)},
436 limit=0,
437 )
438 assert len(rows) == 2
439
440 async def test_remove_nonexistent_mapping(self, genre_ctrl: GenreController) -> None:
441 """Removing a mapping that doesn't exist doesn't raise."""
442 genre = await genre_ctrl.add_item_to_library(_make_genre("NoMapping"))
443 await genre_ctrl.remove_media_mapping(genre.item_id, MediaType.TRACK, 999999)
444
445
446# ===================================================================
447# Group E: sync_media_item_genres (8 tests)
448# ===================================================================
449
450
451class TestSyncMediaItemGenres:
452 """Tests for sync_media_item_genres."""
453
454 async def test_sync_creates_genre(
455 self, mass: MusicAssistant, genre_ctrl: GenreController
456 ) -> None:
457 """New genre created, mapping exists."""
458 track = await _add_test_track(mass, "Sync Track 1")
459 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"Psytrance"})
460 rows = await mass.music.database.get_rows_from_query(
461 f"SELECT * FROM {DB_TABLE_GENRES} WHERE name = :name",
462 {"name": "Psytrance"},
463 limit=1,
464 )
465 assert len(rows) == 1
466
467 async def test_sync_uses_existing_genre(
468 self, mass: MusicAssistant, genre_ctrl: GenreController
469 ) -> None:
470 """No duplicate genre created."""
471 await genre_ctrl.add_item_to_library(_make_genre("Punk"))
472 track = await _add_test_track(mass, "Sync Track 2")
473 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"Punk"})
474 rows = await mass.music.database.get_rows_from_query(
475 f"SELECT * FROM {DB_TABLE_GENRES} WHERE name = :name",
476 {"name": "Punk"},
477 limit=0,
478 )
479 assert len(rows) == 1
480
481 async def test_sync_adds_new_mappings(
482 self, mass: MusicAssistant, genre_ctrl: GenreController
483 ) -> None:
484 """Multiple genres creates both mappings."""
485 track = await _add_test_track(mass, "Sync Track 3")
486 await genre_ctrl.sync_media_item_genres(
487 MediaType.TRACK, track.item_id, {"SyncRock", "SyncJazz"}
488 )
489 rows = await mass.music.database.get_rows_from_query(
490 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
491 "WHERE media_id = :mid AND media_type = 'track'",
492 {"mid": int(track.item_id)},
493 limit=0,
494 )
495 assert len(rows) == 2
496
497 async def test_sync_removes_stale_mappings(
498 self, mass: MusicAssistant, genre_ctrl: GenreController
499 ) -> None:
500 """Re-sync with subset removes stale mapping."""
501 track = await _add_test_track(mass, "Sync Track 4")
502 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncA", "SyncB"})
503 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncA"})
504 rows = await mass.music.database.get_rows_from_query(
505 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
506 "WHERE media_id = :mid AND media_type = 'track'",
507 {"mid": int(track.item_id)},
508 limit=0,
509 )
510 assert len(rows) == 1
511
512 async def test_sync_empty_set_removes_all(
513 self, mass: MusicAssistant, genre_ctrl: GenreController
514 ) -> None:
515 """Empty set removes all mappings."""
516 track = await _add_test_track(mass, "Sync Track 5")
517 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncX"})
518 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, set())
519 rows = await mass.music.database.get_rows_from_query(
520 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
521 "WHERE media_id = :mid AND media_type = 'track'",
522 {"mid": int(track.item_id)},
523 limit=0,
524 )
525 assert len(rows) == 0
526
527 async def test_sync_idempotent(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
528 """Second call with same set is a no-op."""
529 track = await _add_test_track(mass, "Sync Track 6")
530 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncIdem"})
531 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncIdem"})
532 rows = await mass.music.database.get_rows_from_query(
533 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
534 "WHERE media_id = :mid AND media_type = 'track'",
535 {"mid": int(track.item_id)},
536 limit=0,
537 )
538 assert len(rows) == 1
539
540 async def test_sync_skips_empty_names(
541 self, mass: MusicAssistant, genre_ctrl: GenreController
542 ) -> None:
543 """Empty and whitespace-only names are skipped."""
544 track = await _add_test_track(mass, "Sync Track 7")
545 await genre_ctrl.sync_media_item_genres(
546 MediaType.TRACK, track.item_id, {"SyncValid", "", " "}
547 )
548 rows = await mass.music.database.get_rows_from_query(
549 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
550 "WHERE media_id = :mid AND media_type = 'track'",
551 {"mid": int(track.item_id)},
552 limit=0,
553 )
554 assert len(rows) == 1
555
556 async def test_sync_concurrent(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
557 """asyncio.gather with different sets doesn't crash."""
558 track1 = await _add_test_track(mass, "Conc Track 1")
559 track2 = await _add_test_track(mass, "Conc Track 2")
560 await asyncio.gather(
561 genre_ctrl.sync_media_item_genres(MediaType.TRACK, track1.item_id, {"ConcA"}),
562 genre_ctrl.sync_media_item_genres(MediaType.TRACK, track2.item_id, {"ConcB"}),
563 )
564
565 async def test_sync_one_alias_maps_to_multiple_genres(
566 self, mass: MusicAssistant, genre_ctrl: GenreController
567 ) -> None:
568 """One alias shared by two genres creates mappings to both (n:n)."""
569 genre_a = await genre_ctrl.add_item_to_library(_make_genre("GenreA"))
570 genre_b = await genre_ctrl.add_item_to_library(_make_genre("GenreB"))
571 # Both genres claim "shared-alias"
572 await genre_ctrl.add_alias(genre_a.item_id, "shared-alias")
573 await genre_ctrl.add_alias(genre_b.item_id, "shared-alias")
574 track = await _add_test_track(mass, "SharedAlias Track")
575 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"shared-alias"})
576 rows = await mass.music.database.get_rows_from_query(
577 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
578 "WHERE media_id = :mid AND media_type = 'track'",
579 {"mid": int(track.item_id)},
580 limit=0,
581 )
582 mapped_genre_ids = {int(r["genre_id"]) for r in rows}
583 assert int(genre_a.item_id) in mapped_genre_ids
584 assert int(genre_b.item_id) in mapped_genre_ids
585
586
587# ===================================================================
588# Group F: promote_alias_to_genre (4 tests)
589# ===================================================================
590
591
592class TestPromoteAlias:
593 """Tests for promote_alias_to_genre."""
594
595 async def test_promote_alias(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
596 """New genre created, media mappings moved to new genre."""
597 parent = await genre_ctrl.add_item_to_library(_make_genre("ParentGenre"))
598 await genre_ctrl.add_alias(parent.item_id, "SubGenre")
599 # Add a media mapping via the alias
600 track = await _add_test_track(mass, "Promote Track")
601 await genre_ctrl.add_media_mapping(
602 parent.item_id, MediaType.TRACK, track.item_id, "SubGenre"
603 )
604
605 new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "SubGenre")
606 assert new_genre.name == "SubGenre"
607 assert int(new_genre.item_id) != int(parent.item_id)
608
609 # Media mapping should have moved to new genre
610 rows = await mass.music.database.get_rows_from_query(
611 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
612 "WHERE media_id = :mid AND media_type = 'track' AND alias = 'SubGenre'",
613 {"mid": int(track.item_id)},
614 limit=1,
615 )
616 assert len(rows) == 1
617 assert int(rows[0]["genre_id"]) == int(new_genre.item_id)
618
619 async def test_promote_creates_self_alias(self, genre_ctrl: GenreController) -> None:
620 """New genre has its own name as alias."""
621 parent = await genre_ctrl.add_item_to_library(_make_genre("PromParent"))
622 await genre_ctrl.add_alias(parent.item_id, "PromChild")
623
624 new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromChild")
625 assert new_genre.genre_aliases is not None
626 assert "PromChild" in new_genre.genre_aliases
627
628 async def test_promote_self_alias_raises(self, genre_ctrl: GenreController) -> None:
629 """Raises ValueError for self-alias."""
630 genre = await genre_ctrl.add_item_to_library(_make_genre("PromSelf"))
631 with pytest.raises(ValueError, match="Cannot promote self-alias"):
632 await genre_ctrl.promote_alias_to_genre(genre.item_id, "PromSelf")
633
634 async def test_promote_removes_alias_from_source(self, genre_ctrl: GenreController) -> None:
635 """Alias is removed from source genre after promotion."""
636 parent = await genre_ctrl.add_item_to_library(_make_genre("PromComplete"))
637 await genre_ctrl.add_alias(parent.item_id, "PromAlias")
638
639 await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromAlias")
640 updated_parent = await genre_ctrl.get_library_item(int(parent.item_id))
641 assert updated_parent.genre_aliases is not None
642 assert "PromAlias" not in updated_parent.genre_aliases
643 assert "PromComplete" in updated_parent.genre_aliases
644
645
646# ===================================================================
647# Group G: restore_default_genres (5 tests)
648# ===================================================================
649
650
651class TestRestoreDefaultGenres:
652 """Tests for restore_default_genres."""
653
654 async def test_restore_partial_on_empty(self, genre_ctrl: GenreController) -> None:
655 """Creates genres from DEFAULT_GENRE_MAPPING with self-aliases."""
656 created = await genre_ctrl.restore_default_genres(full_restore=False)
657 assert len(created) > 0
658 for genre in created[:3]:
659 assert genre.genre_aliases is not None
660 assert genre.name in genre.genre_aliases
661
662 async def test_restore_partial_idempotent(self, genre_ctrl: GenreController) -> None:
663 """Second call returns empty list (no duplicates)."""
664 await genre_ctrl.restore_default_genres(full_restore=False)
665 second = await genre_ctrl.restore_default_genres(full_restore=False)
666 assert len(second) == 0
667
668 async def test_restore_partial_adds_missing(
669 self, mass: MusicAssistant, genre_ctrl: GenreController
670 ) -> None:
671 """Pre-existing genres not duplicated, missing ones added."""
672 first_default = DEFAULT_GENRE_MAPPING[0]["genre"]
673 await genre_ctrl.add_item_to_library(_make_genre(first_default))
674 before = await genre_ctrl.library_count()
675 created = await genre_ctrl.restore_default_genres(full_restore=False)
676 after = await genre_ctrl.library_count()
677 assert len(created) == after - before
678
679 async def test_restore_full_clears_all(self, genre_ctrl: GenreController) -> None:
680 """Full restore: custom genres gone, only defaults remain."""
681 await genre_ctrl.add_item_to_library(_make_genre("MyCustomGenre"))
682 await genre_ctrl.restore_default_genres(full_restore=True)
683 items = await genre_ctrl.library_items(limit=0)
684 names = {g.name for g in items}
685 assert "MyCustomGenre" not in names
686 assert len(items) == len(DEFAULT_GENRE_MAPPING)
687
688 async def test_restore_creates_configured_aliases(self, genre_ctrl: GenreController) -> None:
689 """Genres have aliases from genre_mapping.json."""
690 await genre_ctrl.restore_default_genres(full_restore=True)
691 entries_with_aliases = [e for e in DEFAULT_GENRE_MAPPING if e.get("aliases")]
692 if not entries_with_aliases:
693 pytest.skip("No default genres with aliases configured")
694 entry = entries_with_aliases[0]
695 items = await genre_ctrl.library_items(search=entry["genre"])
696 assert len(items) > 0
697 genre = items[0]
698 assert genre.genre_aliases is not None
699 # Self-alias should be present
700 assert entry["genre"] in genre.genre_aliases
701 # Configured aliases should be present
702 for alias in entry["aliases"]:
703 assert alias in genre.genre_aliases
704
705
706# ===================================================================
707# Group H: Query Methods (7 tests)
708# ===================================================================
709
710
711class TestQueryMethods:
712 """Tests for radio_mode, mapped_media, and overview endpoints."""
713
714 async def test_radio_mode_empty(self, genre_ctrl: GenreController) -> None:
715 """No mapped tracks returns empty list."""
716 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyRadio"))
717 tracks = await genre_ctrl.radio_mode_base_tracks(genre)
718 assert tracks == []
719
720 async def test_radio_mode_returns_tracks(
721 self, mass: MusicAssistant, genre_ctrl: GenreController
722 ) -> None:
723 """Mapped tracks are returned."""
724 genre = await genre_ctrl.add_item_to_library(_make_genre("RadioGenre"))
725 track = await _add_test_track(mass, "Radio Track")
726 await genre_ctrl.add_media_mapping(
727 genre.item_id, MediaType.TRACK, track.item_id, "RadioGenre"
728 )
729 tracks = await genre_ctrl.radio_mode_base_tracks(genre)
730 assert len(tracks) >= 1
731 assert any(t.name == "Radio Track" for t in tracks)
732
733 async def test_radio_mode_limit_50(self, genre_ctrl: GenreController) -> None:
734 """At most 50 tracks returned (hardcoded limit in radio_mode_base_tracks)."""
735 genre = await genre_ctrl.add_item_to_library(_make_genre("RadioLimit"))
736 tracks = await genre_ctrl.radio_mode_base_tracks(genre)
737 assert len(tracks) <= 50
738
739 async def test_mapped_media_returns_all_types(
740 self, mass: MusicAssistant, genre_ctrl: GenreController
741 ) -> None:
742 """Returns (tracks, albums, artists) tuple."""
743 genre = await genre_ctrl.add_item_to_library(_make_genre("MappedMedia"))
744 result = await genre_ctrl.mapped_media(genre)
745 assert isinstance(result, tuple)
746 assert len(result) == 3
747 tracks, albums, artists = result
748 assert isinstance(tracks, list)
749 assert isinstance(albums, list)
750 assert isinstance(artists, list)
751
752 async def test_mapped_media_empty(self, genre_ctrl: GenreController) -> None:
753 """No mappings returns ([], [], [])."""
754 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyMapped"))
755 tracks, albums, artists = await genre_ctrl.mapped_media(genre)
756 assert tracks == []
757 assert albums == []
758 assert artists == []
759
760 async def test_overview_returns_folders(
761 self, mass: MusicAssistant, genre_ctrl: GenreController
762 ) -> None:
763 """Returns RecommendationFolder items when mappings exist."""
764 genre = await genre_ctrl.add_item_to_library(_make_genre("OverviewGenre"))
765 track = await _add_test_track(mass, "Overview Track")
766 await genre_ctrl.add_media_mapping(
767 genre.item_id, MediaType.TRACK, track.item_id, "OverviewGenre"
768 )
769 folders = await genre_ctrl.get_overview(genre.item_id)
770 assert len(folders) >= 1
771 assert folders[0].name == "Tracks"
772
773 async def test_overview_empty(self, genre_ctrl: GenreController) -> None:
774 """No mappings returns empty list."""
775 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyOverview"))
776 folders = await genre_ctrl.get_overview(genre.item_id)
777 assert folders == []
778
779 async def test_get_genres_for_media_item(
780 self, mass: MusicAssistant, genre_ctrl: GenreController
781 ) -> None:
782 """Returns genres mapped to a specific media item."""
783 genre1 = await genre_ctrl.add_item_to_library(_make_genre("GenreForItem1"))
784 genre2 = await genre_ctrl.add_item_to_library(_make_genre("GenreForItem2"))
785 track = await _add_test_track(mass, "Track With Genres")
786 await genre_ctrl.add_media_mapping(
787 genre1.item_id, MediaType.TRACK, track.item_id, "GenreForItem1"
788 )
789 await genre_ctrl.add_media_mapping(
790 genre2.item_id, MediaType.TRACK, track.item_id, "GenreForItem2"
791 )
792 genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id)
793 genre_names = {g.name for g in genres}
794 assert "GenreForItem1" in genre_names
795 assert "GenreForItem2" in genre_names
796
797 async def test_get_genres_for_media_item_empty(
798 self, mass: MusicAssistant, genre_ctrl: GenreController
799 ) -> None:
800 """Returns empty list for unmapped media item."""
801 track = await _add_test_track(mass, "Track Without Genres")
802 genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id)
803 assert genres == []
804
805
806# ===================================================================
807# Group I: Genre Lookup & Scanner (5 tests)
808# ===================================================================
809
810
811class TestGenreLookupAndScanner:
812 """Tests for genre/alias lookup and scanner status."""
813
814 async def test_find_genres_for_alias_existing(self, genre_ctrl: GenreController) -> None:
815 """Finds existing genre by name."""
816 genre = await genre_ctrl.add_item_to_library(_make_genre("Garage"))
817 found = await genre_ctrl._find_genres_for_alias("Garage")
818 assert isinstance(found, list)
819 assert int(genre.item_id) in found
820
821 async def test_find_genres_for_alias_by_alias(self, genre_ctrl: GenreController) -> None:
822 """Finds existing genre by alias string in genre_aliases JSON."""
823 genre = await genre_ctrl.add_item_to_library(_make_genre("Breakbeat"))
824 await genre_ctrl.add_alias(genre.item_id, "Big Beat")
825 found = await genre_ctrl._find_genres_for_alias("Big Beat")
826 assert isinstance(found, list)
827 assert int(genre.item_id) in found
828
829 async def test_find_genres_for_alias_creates_new(self, genre_ctrl: GenreController) -> None:
830 """Creates new genre when no match found."""
831 found = await genre_ctrl._find_genres_for_alias("BrandNewGenre12345")
832 assert isinstance(found, list)
833 assert len(found) == 1
834 genre = await genre_ctrl.get_library_item(found[0])
835 assert genre.name == "BrandNewGenre12345"
836
837 async def test_scanner_status(self, genre_ctrl: GenreController) -> None:
838 """Returns dict with expected keys."""
839 status = await genre_ctrl.get_scanner_status()
840 assert "running" in status
841 assert "last_scan_time" in status
842
843 async def test_scan_mappings_trigger(self, genre_ctrl: GenreController) -> None:
844 """Returns 'triggered' status."""
845 result = await genre_ctrl.scan_mappings()
846 assert result["status"] == "triggered"
847
848
849# ===================================================================
850# Group J: Base Class Integration (3 tests)
851# ===================================================================
852
853
854class TestBaseClassIntegration:
855 """Tests for base class query patterns (genre_aliases column, pagination, favorites)."""
856
857 async def test_genre_aliases_inline(self, genre_ctrl: GenreController) -> None:
858 """genre_aliases column populates genre_aliases on fetched Genre."""
859 genre = await genre_ctrl.add_item_to_library(_make_genre("InlineTest"))
860 await genre_ctrl.add_alias(genre.item_id, "Inline Alias")
861 # Fetch via library_items (uses base_query)
862 items = await genre_ctrl.library_items(search="InlineTest")
863 assert len(items) >= 1
864 fetched = items[0]
865 assert fetched.genre_aliases is not None
866 assert "InlineTest" in fetched.genre_aliases
867 assert "Inline Alias" in fetched.genre_aliases
868
869 async def test_pagination(self, genre_ctrl: GenreController) -> None:
870 """limit/offset work correctly."""
871 for i in range(5):
872 await genre_ctrl.add_item_to_library(_make_genre(f"Page{i}"))
873 page1 = await genre_ctrl.library_items(limit=2, offset=0, order_by="name")
874 page2 = await genre_ctrl.library_items(limit=2, offset=2, order_by="name")
875 assert len(page1) == 2
876 assert len(page2) == 2
877 ids1 = {g.item_id for g in page1}
878 ids2 = {g.item_id for g in page2}
879 assert ids1.isdisjoint(ids2)
880
881 async def test_favorite_filter(self, genre_ctrl: GenreController) -> None:
882 """favorite=True filters correctly."""
883 await genre_ctrl.add_item_to_library(_make_genre("FavYes", favorite=True))
884 await genre_ctrl.add_item_to_library(_make_genre("FavNo", favorite=False))
885 favs = await genre_ctrl.library_items(favorite=True)
886 assert all(g.favorite for g in favs)
887 assert any(g.name == "FavYes" for g in favs)
888