/
/
/
1"""
2Integration tests for the GenreController (V3 schema).
3
4Uses a database-only MusicAssistant instance with a real SQLite database in a
5temporary directory.
6"""
7
8from __future__ import annotations
9
10import asyncio
11import json
12from pathlib import Path
13from typing import Any
14from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
15from uuid import uuid4
16
17import pytest
18from music_assistant_models.enums import AlbumType, MediaType
19from music_assistant_models.errors import MediaNotFoundError
20from music_assistant_models.helpers import create_safe_string
21from music_assistant_models.media_items import (
22 Album,
23 Artist,
24 Genre,
25 GenreSummary,
26 Podcast,
27 ProviderMapping,
28 Track,
29)
30from music_assistant_models.unique_list import UniqueList
31
32from music_assistant.constants import (
33 DB_TABLE_ALBUM_TRACKS,
34 DB_TABLE_ALBUMS,
35 DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION,
36 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
37 DB_TABLE_GENRES,
38 DB_TABLE_PLAYLOG,
39 DB_TABLE_PODCASTS,
40 DB_TABLE_PROVIDER_MAPPINGS,
41 DB_TABLE_TRACKS,
42 DEFAULT_AUDIOBOOK_GENRE_MAPPING,
43 DEFAULT_GENRE_MAPPING,
44 DEFAULT_PODCAST_GENRE_MAPPING,
45)
46from music_assistant.controllers.music.media.genres import GenreController
47from music_assistant.mass import MusicAssistant
48
49# ---------------------------------------------------------------------------
50# Fixtures & helpers
51# ---------------------------------------------------------------------------
52
53
54@pytest.fixture(scope="class", name="mass")
55def mass_fixture(music_mass_class: MusicAssistant) -> MusicAssistant:
56 """Return the class-scoped database-only Music Assistant fixture."""
57 return music_mass_class
58
59
60@pytest.fixture(scope="class")
61async def genre_ctrl(mass: MusicAssistant) -> GenreController:
62 """Get the genre controller from a running MusicAssistant instance."""
63 return mass.music.genres
64
65
66def _make_genre(name: str, favorite: bool = False) -> Genre:
67 """Create a Genre object for adding to the library."""
68 return Genre(
69 item_id="0",
70 provider="library",
71 name=name,
72 provider_mappings=set(),
73 favorite=favorite,
74 )
75
76
77def _library_provider_mapping() -> set[ProviderMapping]:
78 """Create a provider mapping set with in_library=True and a unique provider_item_id."""
79 return {
80 ProviderMapping(
81 item_id=uuid4().hex,
82 provider_domain="library",
83 provider_instance="library",
84 in_library=True,
85 )
86 }
87
88
89async def _add_test_artist(mass: MusicAssistant, name: str) -> Artist:
90 """Add a minimal artist to the library."""
91 artist = Artist(
92 item_id="0",
93 provider="library",
94 name=name,
95 provider_mappings=_library_provider_mapping(),
96 )
97 return await mass.music.artists.add_item_to_library(artist)
98
99
100async def _add_test_track(mass: MusicAssistant, name: str) -> Track:
101 """Add a minimal track to the library (creates an artist first)."""
102 artist = await _add_test_artist(mass, f"Artist for {name}")
103 track = Track(
104 item_id="0",
105 provider="library",
106 name=name,
107 provider_mappings=_library_provider_mapping(),
108 artists=UniqueList([artist]),
109 )
110 return await mass.music.tracks.add_item_to_library(track)
111
112
113async def _add_test_podcast(mass: MusicAssistant, name: str) -> Podcast:
114 """Add a minimal podcast to the library."""
115 podcast = Podcast(
116 item_id="0",
117 provider="library",
118 name=name,
119 provider_mappings=_library_provider_mapping(),
120 )
121 return await mass.music.podcasts.add_item_to_library(podcast)
122
123
124async def _set_podcast_genres(mass: MusicAssistant, podcast_id: int, genres: list[str]) -> None:
125 """Set metadata.genres on a podcast row directly in the DB."""
126 await mass.music.database.execute(
127 f"UPDATE {DB_TABLE_PODCASTS} "
128 "SET metadata = json_set(metadata, '$.genres', json(:genres)) "
129 "WHERE item_id = :id",
130 {"genres": json.dumps(genres), "id": podcast_id},
131 )
132 await mass.music.database.commit()
133
134
135async def _add_test_album(mass: MusicAssistant, name: str) -> Album:
136 """Add a minimal album to the library."""
137 album = Album(
138 item_id="0",
139 provider="library",
140 name=name,
141 provider_mappings=_library_provider_mapping(),
142 album_type=AlbumType.ALBUM,
143 )
144 return await mass.music.albums.add_item_to_library(album)
145
146
147# ===================================================================
148# Group B: Genre CRUD (14 tests)
149# ===================================================================
150
151
152class TestGenreCRUD:
153 """Tests for adding, reading, updating, and removing genres."""
154
155 async def test_add_genre(self, genre_ctrl: GenreController) -> None:
156 """add_item_to_library returns Genre with numeric id and correct name."""
157 genre = await genre_ctrl.add_item_to_library(_make_genre("Rock"))
158 assert int(genre.item_id) > 0
159 assert genre.name == "Rock"
160
161 async def test_add_genre_creates_self_alias(
162 self, mass: MusicAssistant, genre_ctrl: GenreController
163 ) -> None:
164 """Genre has its own name in genre_aliases JSON column."""
165 genre = await genre_ctrl.add_item_to_library(_make_genre("Blues"))
166 # Check genre_aliases JSON column directly
167 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": int(genre.item_id)})
168 assert row is not None
169 aliases = json.loads(row["genre_aliases"])
170 assert "Blues" in aliases
171
172 async def test_add_genre_duplicate_updates(self, genre_ctrl: GenreController) -> None:
173 """Adding the same genre with library id returns the same item_id (update, no duplicate)."""
174 genre1 = await genre_ctrl.add_item_to_library(_make_genre("Jazz"))
175 # Second add using the real library id (simulates re-adding same item)
176 dup = Genre(
177 item_id=genre1.item_id,
178 provider="library",
179 name="Jazz",
180 provider_mappings=set(),
181 )
182 genre2 = await genre_ctrl.add_item_to_library(dup)
183 assert genre1.item_id == genre2.item_id
184
185 async def test_get_library_item(self, genre_ctrl: GenreController) -> None:
186 """get_library_item returns Genre with genre_aliases populated."""
187 created = await genre_ctrl.add_item_to_library(_make_genre("Funk"))
188 fetched = await genre_ctrl.get_library_item(int(created.item_id))
189 assert fetched.name == "Funk"
190 assert fetched.genre_aliases is not None
191 assert "Funk" in fetched.genre_aliases
192
193 async def test_content_type_defaults_to_none(self, genre_ctrl: GenreController) -> None:
194 """A genre added without a content_type round-trips as None (music/general)."""
195 created = await genre_ctrl.add_item_to_library(_make_genre("Soul"))
196 fetched = await genre_ctrl.get_library_item(int(created.item_id))
197 assert fetched.content_type is None
198
199 async def test_content_type_persists_and_round_trips(self, genre_ctrl: GenreController) -> None:
200 """A genre's content_type is persisted to the DB column and read back as the enum."""
201 genre = Genre(
202 item_id="0",
203 provider="library",
204 name="True Crime",
205 provider_mappings=set(),
206 content_type=MediaType.PODCAST,
207 )
208 created = await genre_ctrl.add_item_to_library(genre)
209 fetched = await genre_ctrl.get_library_item(int(created.item_id))
210 assert fetched.content_type is MediaType.PODCAST
211
212 async def test_content_type_immutable_on_overwrite_update(
213 self, genre_ctrl: GenreController
214 ) -> None:
215 """The taxonomy is set at creation and is not changed by an update, even with overwrite."""
216 genre = Genre(
217 item_id="0",
218 provider="library",
219 name="Documentary",
220 provider_mappings=set(),
221 content_type=MediaType.PODCAST,
222 )
223 created = await genre_ctrl.add_item_to_library(genre)
224 update = Genre(
225 item_id="0",
226 provider="library",
227 name="Documentary",
228 provider_mappings=set(),
229 content_type=MediaType.AUDIOBOOK,
230 )
231 updated = await genre_ctrl.update_item_in_library(created.item_id, update, overwrite=True)
232 assert updated.content_type is MediaType.PODCAST
233 fetched = await genre_ctrl.get_library_item(int(created.item_id))
234 assert fetched.content_type is MediaType.PODCAST
235
236 async def test_get_library_item_not_found(self, genre_ctrl: GenreController) -> None:
237 """Raises MediaNotFoundError for nonexistent id."""
238 with pytest.raises(MediaNotFoundError):
239 await genre_ctrl.get_library_item(999999)
240
241 async def test_update_smart_merge(self, genre_ctrl: GenreController) -> None:
242 """Update with metadata merges without overwrite flag."""
243 genre = await genre_ctrl.add_item_to_library(_make_genre("Reggae"))
244 update = _make_genre("Reggae")
245 update.favorite = True
246 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=False)
247 assert updated.favorite is True
248 assert updated.name == "Reggae"
249
250 async def test_update_overwrite(self, genre_ctrl: GenreController) -> None:
251 """Update with overwrite=True replaces name."""
252 genre = await genre_ctrl.add_item_to_library(_make_genre("OldName"))
253 update = _make_genre("NewName")
254 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=True)
255 assert updated.name == "NewName"
256
257 async def test_update_ensures_self_alias(self, genre_ctrl: GenreController) -> None:
258 """After name update, self-alias exists for new name."""
259 genre = await genre_ctrl.add_item_to_library(_make_genre("OldGenre"))
260 update = _make_genre("RenamedGenre")
261 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=True)
262 assert updated.genre_aliases is not None
263 assert "RenamedGenre" in updated.genre_aliases
264
265 async def test_remove_genre(self, genre_ctrl: GenreController) -> None:
266 """After remove, get_library_item raises MediaNotFoundError."""
267 genre = await genre_ctrl.add_item_to_library(_make_genre("Ska"))
268 await genre_ctrl.remove_item_from_library(genre.item_id)
269 with pytest.raises(MediaNotFoundError):
270 await genre_ctrl.get_library_item(int(genre.item_id))
271
272 async def test_remove_cleans_mappings(
273 self, mass: MusicAssistant, genre_ctrl: GenreController
274 ) -> None:
275 """After remove, genre_media_item_mapping entries for that genre are gone."""
276 genre = await genre_ctrl.add_item_to_library(_make_genre("Dubstep"))
277 genre_id = int(genre.item_id)
278 # Add a media mapping first
279 track = await _add_test_track(mass, "Dubstep Track")
280 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track.item_id, "Dubstep")
281 # Now remove the genre
282 await genre_ctrl.remove_item_from_library(genre.item_id)
283 rows = await mass.music.database.get_rows_from_query(
284 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} WHERE genre_id = :genre_id",
285 {"genre_id": genre_id},
286 limit=0,
287 )
288 assert len(rows) == 0
289
290 async def test_library_items(self, genre_ctrl: GenreController) -> None:
291 """Add 3 genres, returns all 3."""
292 for name in ("Alpha", "Beta", "Gamma"):
293 await genre_ctrl.add_item_to_library(_make_genre(name))
294 items = await genre_ctrl.library_items(hide_empty=False)
295 names = {g.name for g in items}
296 assert {"Alpha", "Beta", "Gamma"}.issubset(names)
297
298 async def test_library_items_summary_includes_alias_count(
299 self, genre_ctrl: GenreController
300 ) -> None:
301 """Summary items carry the mapped alias count, not the aliases themselves."""
302 genre = await genre_ctrl.add_item_to_library(_make_genre("AliasSummaryGenre"))
303 await genre_ctrl.add_alias(genre.item_id, "Alias Summary Rock")
304 items = await genre_ctrl.library_items(hide_empty=False)
305 item = next(g for g in items if g.name == "AliasSummaryGenre")
306 assert isinstance(item, GenreSummary)
307 # the genre's own name is stored as an alias but must not be counted
308 assert item.genre_alias_count == 1
309 assert item.genre_aliases is None
310
311 async def test_library_items_search(self, genre_ctrl: GenreController) -> None:
312 """Search 'country' returns Country genre but not unrelated ones like Metal."""
313 await genre_ctrl.add_item_to_library(_make_genre("Metal"))
314 items = await genre_ctrl.library_items(search="country", hide_empty=False)
315 names = {g.name for g in items}
316 assert "country" in names
317 assert "Metal" not in names
318
319 async def test_library_items_hide_empty_true(
320 self, mass: MusicAssistant, genre_ctrl: GenreController
321 ) -> None:
322 """hide_empty=True returns only genres with media mappings."""
323 mapped = await genre_ctrl.add_item_to_library(_make_genre("HideEmptyMapped"))
324 await genre_ctrl.add_item_to_library(_make_genre("HideEmptyUnmapped"))
325 track = await _add_test_track(mass, "HideEmpty Track")
326 await genre_ctrl.add_media_mapping(
327 int(mapped.item_id), MediaType.TRACK, track.item_id, "HideEmptyMapped"
328 )
329 items = await genre_ctrl.library_items(hide_empty=True)
330 names = {g.name for g in items}
331 assert "HideEmptyMapped" in names
332 assert "HideEmptyUnmapped" not in names
333
334 async def test_library_items_hide_empty_false(self, genre_ctrl: GenreController) -> None:
335 """hide_empty=False returns all genres regardless of mappings."""
336 await genre_ctrl.add_item_to_library(_make_genre("HideEmptyFalseGenre"))
337 items = await genre_ctrl.library_items(hide_empty=False)
338 names = {g.name for g in items}
339 assert "HideEmptyFalseGenre" in names
340
341 async def test_library_items_hide_empty_none_returns_default_genres(
342 self, genre_ctrl: GenreController
343 ) -> None:
344 """
345 hide_empty=None (default) returns only default genres (translation_key IS NOT NULL).
346
347 Default genres are seeded via restore_default_genres (translation_key IS NOT NULL).
348 Non-default genres created via _find_genres_for_alias mirror the library scan path
349 and store translation_key=NULL in the DB.
350 """
351 await genre_ctrl.restore_default_genres()
352 scanned_name = "ScannedNonDefaultGenreXyz"
353 await genre_ctrl._find_genres_for_alias(scanned_name, None)
354
355 default_genre_name = DEFAULT_GENRE_MAPPING[0]["genre"]
356 items = await genre_ctrl.library_items(hide_empty=None)
357 names = {g.name for g in items}
358 assert default_genre_name in names
359 assert scanned_name not in names
360
361 async def test_library_items_default_is_hide_empty_none(
362 self, genre_ctrl: GenreController
363 ) -> None:
364 """Calling library_items() with no hide_empty arg behaves like hide_empty=None."""
365 await genre_ctrl.restore_default_genres()
366 await genre_ctrl._find_genres_for_alias("DefaultArgScannedGenreXyz", None)
367 default_genre_name = DEFAULT_GENRE_MAPPING[0]["genre"]
368 items_default = await genre_ctrl.library_items()
369 items_none = await genre_ctrl.library_items(hide_empty=None)
370 assert {g.item_id for g in items_default} == {g.item_id for g in items_none}
371 names = {g.name for g in items_default}
372 assert default_genre_name in names
373 assert "DefaultArgScannedGenreXyz" not in names
374
375 async def test_library_items_rejects_genre_param(self, genre_ctrl: GenreController) -> None:
376 """library_items(genre=1) raises ValueError."""
377 with pytest.raises(ValueError, match="genre parameter is not supported"):
378 await genre_ctrl.library_items(genre=1)
379
380 async def test_library_items_media_type_filter(
381 self, mass: MusicAssistant, genre_ctrl: GenreController
382 ) -> None:
383 """
384 media_type filter returns all non-empty genres for that type, including non-defaults.
385
386 Verifies:
387 - Non-default genres (no translation_key) with mappings ARE returned — the default
388 translation_key IS NOT NULL filter is bypassed when media_type is set.
389 - Genres mapped only to another type are excluded.
390 - Default genres (with translation_key) that have no mapping for the type are excluded.
391 - A genre mapped to multiple types appears in results for each of those types.
392 - search composing with media_type works correctly.
393 - No mappings for the requested type returns an empty list.
394 """
395 track_genre = await genre_ctrl.add_item_to_library(_make_genre("MT_FilterTrackOnlyGenre"))
396 album_genre = await genre_ctrl.add_item_to_library(_make_genre("MT_FilterAlbumOnlyGenre"))
397 shared_genre = await genre_ctrl.add_item_to_library(_make_genre("MT_FilterSharedGenre"))
398
399 track = await _add_test_track(mass, "MT Filter Track")
400 album = await _add_test_album(mass, "MT Filter Album")
401 await genre_ctrl.add_media_mapping(
402 int(track_genre.item_id), MediaType.TRACK, track.item_id, "MT_FilterTrackOnlyGenre"
403 )
404 await genre_ctrl.add_media_mapping(
405 int(album_genre.item_id), MediaType.ALBUM, album.item_id, "MT_FilterAlbumOnlyGenre"
406 )
407 # shared_genre is mapped to both tracks and albums
408 await genre_ctrl.add_media_mapping(
409 int(shared_genre.item_id), MediaType.TRACK, track.item_id, "MT_FilterSharedGenre"
410 )
411 await genre_ctrl.add_media_mapping(
412 int(shared_genre.item_id), MediaType.ALBUM, album.item_id, "MT_FilterSharedGenre"
413 )
414
415 # Add a default genre (has translation_key) that has NO mappings for any type —
416 # it must not appear in media_type results even though hide_empty=None would normally
417 # include all defaults.
418 await genre_ctrl.restore_default_genres()
419
420 track_results = await genre_ctrl.library_items(media_type=MediaType.TRACK)
421 track_names = {g.name for g in track_results}
422 assert "MT_FilterTrackOnlyGenre" in track_names, (
423 "track-mapped genre missing from TRACK results"
424 )
425 assert "MT_FilterSharedGenre" in track_names, "shared genre missing from TRACK results"
426 assert "MT_FilterAlbumOnlyGenre" not in track_names, (
427 "album-only genre appeared in TRACK results"
428 )
429 # Unmapped default genres must not bleed through —
430 # media_type overrides the translation_key filter
431 default_genre_name = DEFAULT_GENRE_MAPPING[0]["genre"]
432 assert default_genre_name not in track_names, (
433 "unmapped default genre appeared in TRACK results"
434 )
435
436 album_results = await genre_ctrl.library_items(media_type=MediaType.ALBUM)
437 album_names = {g.name for g in album_results}
438 assert "MT_FilterAlbumOnlyGenre" in album_names, (
439 "album-mapped genre missing from ALBUM results"
440 )
441 assert "MT_FilterSharedGenre" in album_names, "shared genre missing from ALBUM results"
442 assert "MT_FilterTrackOnlyGenre" not in album_names, (
443 "track-only genre appeared in ALBUM results"
444 )
445
446 # search composes correctly with media_type
447 search_results = await genre_ctrl.library_items(
448 media_type=MediaType.TRACK, search="MT_FilterShared"
449 )
450 search_names = {g.name for g in search_results}
451 assert "MT_FilterSharedGenre" in search_names
452 assert "MT_FilterTrackOnlyGenre" not in search_names
453
454 # No mappings for the requested type returns an empty list
455 playlist_results = await genre_ctrl.library_items(media_type=MediaType.PLAYLIST)
456 playlist_names = {g.name for g in playlist_results}
457 assert "MT_FilterTrackOnlyGenre" not in playlist_names
458 assert "MT_FilterAlbumOnlyGenre" not in playlist_names
459 assert "MT_FilterSharedGenre" not in playlist_names
460
461 async def test_library_count(self, genre_ctrl: GenreController) -> None:
462 """Returns correct count; favorite_only=True filters."""
463 await genre_ctrl.add_item_to_library(_make_genre("CountA"))
464 await genre_ctrl.add_item_to_library(_make_genre("CountB", favorite=True))
465 total = await genre_ctrl.library_count()
466 assert total >= 2
467 fav = await genre_ctrl.library_count(favorite_only=True)
468 assert fav >= 1
469 assert fav <= total
470
471
472# ===================================================================
473# Group C: Alias Operations (8 tests)
474# ===================================================================
475
476
477class TestAliasOperations:
478 """Tests for add_alias, remove_alias string operations on genres."""
479
480 async def test_add_alias(self, genre_ctrl: GenreController) -> None:
481 """add_alias adds a string to genre_aliases."""
482 genre = await genre_ctrl.add_item_to_library(_make_genre("Electronic"))
483 updated = await genre_ctrl.add_alias(genre.item_id, "EDM")
484 assert updated.genre_aliases is not None
485 assert "EDM" in updated.genre_aliases
486 assert "Electronic" in updated.genre_aliases
487
488 async def test_add_alias_idempotent(self, genre_ctrl: GenreController) -> None:
489 """Adding the same alias twice doesn't duplicate."""
490 genre = await genre_ctrl.add_item_to_library(_make_genre("House"))
491 await genre_ctrl.add_alias(genre.item_id, "Deep House")
492 updated = await genre_ctrl.add_alias(genre.item_id, "Deep House")
493 assert updated.genre_aliases is not None
494 assert list(updated.genre_aliases).count("Deep House") == 1
495
496 async def test_add_alias_multiple(self, genre_ctrl: GenreController) -> None:
497 """Multiple aliases can be added to a single genre."""
498 genre = await genre_ctrl.add_item_to_library(_make_genre("Ambient"))
499 await genre_ctrl.add_alias(genre.item_id, "Ambient Music")
500 updated = await genre_ctrl.add_alias(genre.item_id, "Chill Ambient")
501 assert updated.genre_aliases is not None
502 assert "Ambient" in updated.genre_aliases
503 assert "Ambient Music" in updated.genre_aliases
504 assert "Chill Ambient" in updated.genre_aliases
505
506 async def test_remove_alias(self, genre_ctrl: GenreController) -> None:
507 """remove_alias removes a string from genre_aliases."""
508 genre = await genre_ctrl.add_item_to_library(_make_genre("Techno"))
509 await genre_ctrl.add_alias(genre.item_id, "Detroit Techno")
510 updated = await genre_ctrl.remove_alias(genre.item_id, "Detroit Techno")
511 assert updated.genre_aliases is not None
512 assert "Detroit Techno" not in updated.genre_aliases
513 assert "Techno" in updated.genre_aliases
514
515 async def test_remove_self_alias_raises(self, genre_ctrl: GenreController) -> None:
516 """Removing the genre's own name raises ValueError."""
517 genre = await genre_ctrl.add_item_to_library(_make_genre("Soul"))
518 with pytest.raises(ValueError, match="Cannot remove self-alias"):
519 await genre_ctrl.remove_alias(genre.item_id, "Soul")
520
521 async def test_remove_alias_cleans_media_mappings(
522 self, mass: MusicAssistant, genre_ctrl: GenreController
523 ) -> None:
524 """Removing an alias also removes media mappings that used that alias."""
525 genre = await genre_ctrl.add_item_to_library(_make_genre("Latin"))
526 await genre_ctrl.add_alias(genre.item_id, "Latin Pop")
527 track = await _add_test_track(mass, "Latin Track")
528 await genre_ctrl.add_media_mapping(
529 genre.item_id, MediaType.TRACK, track.item_id, "Latin Pop"
530 )
531 # Remove the alias
532 await genre_ctrl.remove_alias(genre.item_id, "Latin Pop")
533 # Check mapping is gone
534 rows = await mass.music.database.get_rows_from_query(
535 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
536 "WHERE genre_id = :gid AND alias = :alias",
537 {"gid": int(genre.item_id), "alias": "Latin Pop"},
538 limit=0,
539 )
540 assert len(rows) == 0
541
542 async def test_add_alias_not_found(self, genre_ctrl: GenreController) -> None:
543 """add_alias for nonexistent genre raises MediaNotFoundError."""
544 with pytest.raises(MediaNotFoundError):
545 await genre_ctrl.add_alias(999999, "NoGenre")
546
547 async def test_remove_alias_not_found(self, genre_ctrl: GenreController) -> None:
548 """remove_alias for nonexistent genre raises MediaNotFoundError."""
549 with pytest.raises(MediaNotFoundError):
550 await genre_ctrl.remove_alias(999999, "NoGenre")
551
552
553# ===================================================================
554# Group D: Media Mapping Operations (8 tests)
555# ===================================================================
556
557
558class TestMediaMappingOperations:
559 """Tests for add_media_mapping and remove_media_mapping."""
560
561 async def test_add_media_mapping_track(
562 self, mass: MusicAssistant, genre_ctrl: GenreController
563 ) -> None:
564 """Mapping exists in genre_media_item_mapping table."""
565 genre = await genre_ctrl.add_item_to_library(_make_genre("Pop"))
566 track = await _add_test_track(mass, "Pop Track")
567 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Pop")
568 rows = await mass.music.database.get_rows_from_query(
569 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
570 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
571 {
572 "gid": int(genre.item_id),
573 "mt": MediaType.TRACK.value,
574 "mid": int(track.item_id),
575 },
576 limit=1,
577 )
578 assert len(rows) == 1
579 assert rows[0]["alias"] == "Pop"
580
581 async def test_add_media_mapping_sets_is_manual(
582 self, mass: MusicAssistant, genre_ctrl: GenreController
583 ) -> None:
584 """Mappings created via add_media_mapping have is_manual = 1."""
585 genre = await genre_ctrl.add_item_to_library(_make_genre("IsManualPop"))
586 track = await _add_test_track(mass, "IsManualPop Track")
587 await genre_ctrl.add_media_mapping(
588 genre.item_id, MediaType.TRACK, track.item_id, "IsManualPop"
589 )
590 row = await mass.music.database.get_row(
591 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
592 {
593 "genre_id": int(genre.item_id),
594 "media_id": int(track.item_id),
595 "media_type": MediaType.TRACK.value,
596 },
597 )
598 assert row is not None
599 assert row["is_manual"] == 1
600
601 async def test_add_media_mapping_upgrades_existing_auto_row(
602 self, mass: MusicAssistant, genre_ctrl: GenreController
603 ) -> None:
604 """Calling add_media_mapping on a pre-existing non-manual row upgrades is_manual to 1."""
605 genre = await genre_ctrl.add_item_to_library(_make_genre("UpgradeGenre"))
606 track = await _add_test_track(mass, "Upgrade Track")
607 # insert row directly without is_manual (simulates scanner-created row)
608 await mass.music.database.insert(
609 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
610 {
611 "genre_id": int(genre.item_id),
612 "media_id": int(track.item_id),
613 "media_type": MediaType.TRACK.value,
614 "alias": "UpgradeGenre",
615 },
616 )
617 row_before = await mass.music.database.get_row(
618 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
619 {
620 "genre_id": int(genre.item_id),
621 "media_id": int(track.item_id),
622 "media_type": MediaType.TRACK.value,
623 },
624 )
625 assert row_before is not None
626 assert row_before["is_manual"] == 0
627
628 await genre_ctrl.add_media_mapping(
629 genre.item_id, MediaType.TRACK, track.item_id, "UpgradeGenre"
630 )
631
632 row_after = await mass.music.database.get_row(
633 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
634 {
635 "genre_id": int(genre.item_id),
636 "media_id": int(track.item_id),
637 "media_type": MediaType.TRACK.value,
638 },
639 )
640 assert row_after is not None
641 assert row_after["is_manual"] == 1
642
643 async def test_add_media_mapping_idempotent(
644 self, mass: MusicAssistant, genre_ctrl: GenreController
645 ) -> None:
646 """Calling add_media_mapping twice doesn't raise (uses allow_replace)."""
647 genre = await genre_ctrl.add_item_to_library(_make_genre("Grunge"))
648 track = await _add_test_track(mass, "Grunge Song")
649 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Grunge")
650 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Grunge")
651
652 async def test_remove_media_mapping_track(
653 self, mass: MusicAssistant, genre_ctrl: GenreController
654 ) -> None:
655 """Mapping removed from DB."""
656 genre = await genre_ctrl.add_item_to_library(_make_genre("Disco"))
657 track = await _add_test_track(mass, "Disco Track")
658 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Disco")
659 await genre_ctrl.remove_media_mapping(genre.item_id, MediaType.TRACK, track.item_id)
660 rows = await mass.music.database.get_rows_from_query(
661 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
662 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
663 {
664 "gid": int(genre.item_id),
665 "mt": MediaType.TRACK.value,
666 "mid": int(track.item_id),
667 },
668 limit=1,
669 )
670 assert len(rows) == 0
671
672 async def test_add_media_mapping_artist(
673 self, mass: MusicAssistant, genre_ctrl: GenreController
674 ) -> None:
675 """Artist mapping works correctly."""
676 genre = await genre_ctrl.add_item_to_library(_make_genre("Funk2"))
677 artist = await _add_test_artist(mass, "Funk Artist")
678 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.ARTIST, artist.item_id, "Funk2")
679 rows = await mass.music.database.get_rows_from_query(
680 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
681 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
682 {
683 "gid": int(genre.item_id),
684 "mt": MediaType.ARTIST.value,
685 "mid": int(artist.item_id),
686 },
687 limit=1,
688 )
689 assert len(rows) == 1
690
691 async def test_mapping_preserves_alias_string(
692 self, mass: MusicAssistant, genre_ctrl: GenreController
693 ) -> None:
694 """The alias column records which alias caused the mapping."""
695 genre = await genre_ctrl.add_item_to_library(_make_genre("Afrobeat"))
696 await genre_ctrl.add_alias(genre.item_id, "Highlife")
697 track = await _add_test_track(mass, "Afrobeat Track")
698 await genre_ctrl.add_media_mapping(
699 genre.item_id, MediaType.TRACK, track.item_id, "Highlife"
700 )
701 rows = await mass.music.database.get_rows_from_query(
702 f"SELECT alias FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
703 "WHERE genre_id = :gid AND media_id = :mid",
704 {"gid": int(genre.item_id), "mid": int(track.item_id)},
705 limit=1,
706 )
707 assert len(rows) == 1
708 assert rows[0]["alias"] == "Highlife"
709
710 async def test_multiple_genres_same_track(
711 self, mass: MusicAssistant, genre_ctrl: GenreController
712 ) -> None:
713 """A track can be mapped to multiple genres."""
714 genre1 = await genre_ctrl.add_item_to_library(_make_genre("Genre1"))
715 genre2 = await genre_ctrl.add_item_to_library(_make_genre("Genre2"))
716 track = await _add_test_track(mass, "Multi Genre Track")
717 await genre_ctrl.add_media_mapping(genre1.item_id, MediaType.TRACK, track.item_id, "Genre1")
718 await genre_ctrl.add_media_mapping(genre2.item_id, MediaType.TRACK, track.item_id, "Genre2")
719 rows = await mass.music.database.get_rows_from_query(
720 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
721 "WHERE media_id = :mid AND media_type = 'track'",
722 {"mid": int(track.item_id)},
723 limit=0,
724 )
725 assert len(rows) == 2
726
727 async def test_multiple_tracks_same_genre(
728 self, mass: MusicAssistant, genre_ctrl: GenreController
729 ) -> None:
730 """Multiple tracks can be mapped to the same genre."""
731 genre = await genre_ctrl.add_item_to_library(_make_genre("SharedGenre"))
732 track1 = await _add_test_track(mass, "Shared Track 1")
733 track2 = await _add_test_track(mass, "Shared Track 2")
734 await genre_ctrl.add_media_mapping(
735 genre.item_id, MediaType.TRACK, track1.item_id, "SharedGenre"
736 )
737 await genre_ctrl.add_media_mapping(
738 genre.item_id, MediaType.TRACK, track2.item_id, "SharedGenre"
739 )
740 rows = await mass.music.database.get_rows_from_query(
741 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
742 "WHERE genre_id = :gid AND media_type = 'track'",
743 {"gid": int(genre.item_id)},
744 limit=0,
745 )
746 assert len(rows) == 2
747
748 async def test_remove_nonexistent_mapping(self, genre_ctrl: GenreController) -> None:
749 """Removing a mapping that doesn't exist doesn't raise."""
750 genre = await genre_ctrl.add_item_to_library(_make_genre("NoMapping"))
751 await genre_ctrl.remove_media_mapping(genre.item_id, MediaType.TRACK, 999999)
752
753
754# ===================================================================
755# Group E: sync_media_item_genres (8 tests)
756# ===================================================================
757
758
759class TestSyncMediaItemGenres:
760 """Tests for sync_media_item_genres."""
761
762 async def test_sync_creates_genre(
763 self, mass: MusicAssistant, genre_ctrl: GenreController
764 ) -> None:
765 """New genre created, mapping exists."""
766 track = await _add_test_track(mass, "Sync Track 1")
767 unique_genre = "SzTestSyncGenreXYZ"
768 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {unique_genre})
769 rows = await mass.music.database.get_rows_from_query(
770 f"SELECT * FROM {DB_TABLE_GENRES} WHERE name = :name",
771 {"name": unique_genre},
772 limit=1,
773 )
774 assert len(rows) == 1
775
776 async def test_sync_uses_existing_genre(
777 self, mass: MusicAssistant, genre_ctrl: GenreController
778 ) -> None:
779 """No duplicate genre created."""
780 await genre_ctrl.add_item_to_library(_make_genre("Punk"))
781 track = await _add_test_track(mass, "Sync Track 2")
782 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"Punk"})
783 rows = await mass.music.database.get_rows_from_query(
784 f"SELECT * FROM {DB_TABLE_GENRES} WHERE name = :name",
785 {"name": "Punk"},
786 limit=0,
787 )
788 assert len(rows) == 1
789
790 async def test_sync_adds_new_mappings(
791 self, mass: MusicAssistant, genre_ctrl: GenreController
792 ) -> None:
793 """Multiple genres creates both mappings."""
794 track = await _add_test_track(mass, "Sync Track 3")
795 await genre_ctrl.sync_media_item_genres(
796 MediaType.TRACK, track.item_id, {"SyncRock", "SyncJazz"}
797 )
798 rows = await mass.music.database.get_rows_from_query(
799 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
800 "WHERE media_id = :mid AND media_type = 'track'",
801 {"mid": int(track.item_id)},
802 limit=0,
803 )
804 assert len(rows) == 2
805
806 async def test_sync_removes_stale_mappings(
807 self, mass: MusicAssistant, genre_ctrl: GenreController
808 ) -> None:
809 """Re-sync with subset removes stale mapping."""
810 track = await _add_test_track(mass, "Sync Track 4")
811 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncA", "SyncB"})
812 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncA"})
813 rows = await mass.music.database.get_rows_from_query(
814 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
815 "WHERE media_id = :mid AND media_type = 'track'",
816 {"mid": int(track.item_id)},
817 limit=0,
818 )
819 assert len(rows) == 1
820
821 async def test_sync_empty_set_removes_all(
822 self, mass: MusicAssistant, genre_ctrl: GenreController
823 ) -> None:
824 """Empty set removes all mappings."""
825 track = await _add_test_track(mass, "Sync Track 5")
826 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncX"})
827 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, set())
828 rows = await mass.music.database.get_rows_from_query(
829 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
830 "WHERE media_id = :mid AND media_type = 'track'",
831 {"mid": int(track.item_id)},
832 limit=0,
833 )
834 assert len(rows) == 0
835
836 async def test_sync_idempotent(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
837 """Second call with same set is a no-op."""
838 track = await _add_test_track(mass, "Sync Track 6")
839 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncIdem"})
840 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncIdem"})
841 rows = await mass.music.database.get_rows_from_query(
842 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
843 "WHERE media_id = :mid AND media_type = 'track'",
844 {"mid": int(track.item_id)},
845 limit=0,
846 )
847 assert len(rows) == 1
848
849 async def test_sync_skips_empty_names(
850 self, mass: MusicAssistant, genre_ctrl: GenreController
851 ) -> None:
852 """Empty and whitespace-only names are skipped."""
853 track = await _add_test_track(mass, "Sync Track 7")
854 await genre_ctrl.sync_media_item_genres(
855 MediaType.TRACK, track.item_id, {"SyncValid", "", " "}
856 )
857 rows = await mass.music.database.get_rows_from_query(
858 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
859 "WHERE media_id = :mid AND media_type = 'track'",
860 {"mid": int(track.item_id)},
861 limit=0,
862 )
863 assert len(rows) == 1
864
865 async def test_sync_concurrent(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
866 """asyncio.gather with different sets doesn't crash."""
867 track1 = await _add_test_track(mass, "Conc Track 1")
868 track2 = await _add_test_track(mass, "Conc Track 2")
869 await asyncio.gather(
870 genre_ctrl.sync_media_item_genres(MediaType.TRACK, track1.item_id, {"ConcA"}),
871 genre_ctrl.sync_media_item_genres(MediaType.TRACK, track2.item_id, {"ConcB"}),
872 )
873
874 async def test_sync_one_alias_maps_to_multiple_genres(
875 self, mass: MusicAssistant, genre_ctrl: GenreController
876 ) -> None:
877 """One alias shared by two genres creates mappings to both (n:n)."""
878 genre_a = await genre_ctrl.add_item_to_library(_make_genre("GenreA"))
879 genre_b = await genre_ctrl.add_item_to_library(_make_genre("GenreB"))
880 # Both genres claim "shared-alias"
881 await genre_ctrl.add_alias(genre_a.item_id, "shared-alias")
882 await genre_ctrl.add_alias(genre_b.item_id, "shared-alias")
883 track = await _add_test_track(mass, "SharedAlias Track")
884 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"shared-alias"})
885 rows = await mass.music.database.get_rows_from_query(
886 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
887 "WHERE media_id = :mid AND media_type = 'track'",
888 {"mid": int(track.item_id)},
889 limit=0,
890 )
891 mapped_genre_ids = {int(r["genre_id"]) for r in rows}
892 assert int(genre_a.item_id) in mapped_genre_ids
893 assert int(genre_b.item_id) in mapped_genre_ids
894
895 async def test_sync_picks_up_genre_created_between_syncs(
896 self, mass: MusicAssistant, genre_ctrl: GenreController
897 ) -> None:
898 """A genre created between syncs re-routes an already-stored alias mapping."""
899 electro = await genre_ctrl.add_item_to_library(_make_genre("SyncElectro"))
900 await genre_ctrl.add_alias(electro.item_id, "SyncWaveAlias")
901 track = await _add_test_track(mass, "Sync Track NewGenre")
902 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncWaveAlias"})
903 # user now creates a genre whose primary name matches the stored alias;
904 # primary-name resolution takes priority, so a re-sync must remap the item
905 new_genre = await genre_ctrl.add_item_to_library(_make_genre("SyncWaveAlias"))
906 genre_ctrl._sync_lookup_cache.clear() # simulate the cached lookup expiring
907 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncWaveAlias"})
908 rows = await mass.music.database.get_rows_from_query(
909 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
910 "WHERE media_id = :mid AND media_type = 'track'",
911 {"mid": int(track.item_id)},
912 limit=0,
913 )
914 assert {int(r["genre_id"]) for r in rows} == {int(new_genre.item_id)}
915
916
917# ===================================================================
918# Group F: promote_alias_to_genre (4 tests)
919# ===================================================================
920
921
922class TestPromoteAlias:
923 """Tests for promote_alias_to_genre."""
924
925 async def test_promote_alias(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
926 """New genre created, media mappings moved to new genre."""
927 parent = await genre_ctrl.add_item_to_library(_make_genre("ParentGenre"))
928 await genre_ctrl.add_alias(parent.item_id, "SubGenre")
929 # Add a media mapping via the alias
930 track = await _add_test_track(mass, "Promote Track")
931 await genre_ctrl.add_media_mapping(
932 parent.item_id, MediaType.TRACK, track.item_id, "SubGenre"
933 )
934
935 new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "SubGenre")
936 assert new_genre.name == "SubGenre"
937 assert int(new_genre.item_id) != int(parent.item_id)
938
939 # Media mapping should have moved to new genre
940 rows = await mass.music.database.get_rows_from_query(
941 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
942 "WHERE media_id = :mid AND media_type = 'track' AND alias = 'SubGenre'",
943 {"mid": int(track.item_id)},
944 limit=1,
945 )
946 assert len(rows) == 1
947 assert int(rows[0]["genre_id"]) == int(new_genre.item_id)
948
949 async def test_promote_creates_self_alias(self, genre_ctrl: GenreController) -> None:
950 """New genre has its own name as alias."""
951 parent = await genre_ctrl.add_item_to_library(_make_genre("PromParent"))
952 await genre_ctrl.add_alias(parent.item_id, "PromChild")
953
954 new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromChild")
955 assert new_genre.genre_aliases is not None
956 assert "PromChild" in new_genre.genre_aliases
957
958 async def test_promote_self_alias_raises(self, genre_ctrl: GenreController) -> None:
959 """Raises ValueError for self-alias."""
960 genre = await genre_ctrl.add_item_to_library(_make_genre("PromSelf"))
961 with pytest.raises(ValueError, match="Cannot promote self-alias"):
962 await genre_ctrl.promote_alias_to_genre(genre.item_id, "PromSelf")
963
964 async def test_promote_removes_alias_from_source(self, genre_ctrl: GenreController) -> None:
965 """Alias is removed from source genre after promotion."""
966 parent = await genre_ctrl.add_item_to_library(_make_genre("PromComplete"))
967 await genre_ctrl.add_alias(parent.item_id, "PromAlias")
968
969 await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromAlias")
970 updated_parent = await genre_ctrl.get_library_item(int(parent.item_id))
971 assert updated_parent.genre_aliases is not None
972 assert "PromAlias" not in updated_parent.genre_aliases
973 assert "PromComplete" in updated_parent.genre_aliases
974
975 async def test_promote_alias_shared_across_genres(
976 self, mass: MusicAssistant, genre_ctrl: GenreController
977 ) -> None:
978 """
979 Promotion handles aliases shared across multiple genres.
980
981 Every genre that claimed the alias loses it and all mappings made via
982 the alias are moved to the new genre, regardless of which source the
983 caller invoked the promotion from.
984 """
985 folk = await genre_ctrl.add_item_to_library(_make_genre("PromFolk"))
986 pop = await genre_ctrl.add_item_to_library(_make_genre("PromPop"))
987 await genre_ctrl.add_alias(folk.item_id, "PromManele")
988 await genre_ctrl.add_alias(pop.item_id, "PromManele")
989 track = await _add_test_track(mass, "Manele Track")
990 # Track is mapped to both genres via the same alias (n:n).
991 await genre_ctrl.add_media_mapping(
992 folk.item_id, MediaType.TRACK, track.item_id, "PromManele"
993 )
994 await genre_ctrl.add_media_mapping(
995 pop.item_id, MediaType.TRACK, track.item_id, "PromManele"
996 )
997
998 # Invoke from one of the owning genres; both should be cleared.
999 new_genre = await genre_ctrl.promote_alias_to_genre(folk.item_id, "PromManele")
1000
1001 rows = await mass.music.database.get_rows_from_query(
1002 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1003 "WHERE media_id = :mid AND media_type = 'track'",
1004 {"mid": int(track.item_id)},
1005 limit=0,
1006 )
1007 genre_ids = {int(r["genre_id"]) for r in rows}
1008 assert genre_ids == {int(new_genre.item_id)}
1009
1010 updated_folk = await genre_ctrl.get_library_item(int(folk.item_id))
1011 updated_pop = await genre_ctrl.get_library_item(int(pop.item_id))
1012 assert "PromManele" not in (updated_folk.genre_aliases or [])
1013 assert "PromManele" not in (updated_pop.genre_aliases or [])
1014
1015 async def test_promote_rebuilds_derived_album_mappings(
1016 self, mass: MusicAssistant, genre_ctrl: GenreController
1017 ) -> None:
1018 """
1019 Derived album rows are cleared from the source genre after promotion.
1020
1021 Without this, propagation-derived (alias=NULL, is_derived=1) rows would
1022 still link the album to the source genre even though the underlying
1023 tracks have moved to the new one.
1024 """
1025 parent = await genre_ctrl.add_item_to_library(_make_genre("PromHipHop"))
1026 await genre_ctrl.add_alias(parent.item_id, "PromRap")
1027 track = await _add_test_track(mass, "Rap Track")
1028 album = await _add_test_album(mass, "Rap Album")
1029 await genre_ctrl.add_media_mapping(
1030 parent.item_id, MediaType.TRACK, track.item_id, "PromRap"
1031 )
1032 # Seed a propagation-derived album row (alias=NULL, is_derived=1) as if
1033 # it had been written by _propagate_genre_mappings_to_parents.
1034 await mass.music.database.execute(
1035 f"INSERT INTO {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1036 "(genre_id, media_id, media_type, alias, is_derived) "
1037 "VALUES (:gid, :mid, 'album', NULL, 1)",
1038 {"gid": int(parent.item_id), "mid": int(album.item_id)},
1039 )
1040
1041 await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromRap")
1042
1043 rows = await mass.music.database.get_rows_from_query(
1044 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1045 "WHERE media_id = :mid AND media_type = 'album'",
1046 {"mid": int(album.item_id)},
1047 limit=0,
1048 )
1049 assert all(int(r["genre_id"]) != int(parent.item_id) for r in rows)
1050
1051 async def test_promote_alias_inherits_content_type(self, genre_ctrl: GenreController) -> None:
1052 """A genre promoted from an alias stays in its source's taxonomy."""
1053 parent = await genre_ctrl.add_item_to_library(
1054 Genre(
1055 item_id="0",
1056 provider="library",
1057 name="PromTrueCrime",
1058 provider_mappings=set(),
1059 content_type=MediaType.PODCAST,
1060 )
1061 )
1062 await genre_ctrl.add_alias(parent.item_id, "PromSerialKillers")
1063
1064 new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromSerialKillers")
1065 assert new_genre.content_type is MediaType.PODCAST
1066 fetched = await genre_ctrl.get_library_item(int(new_genre.item_id))
1067 assert fetched.content_type is MediaType.PODCAST
1068
1069
1070# ===================================================================
1071# Group F2: merge_genres (7 tests)
1072# ===================================================================
1073
1074
1075class TestMergeGenres:
1076 """Tests for merge_genres."""
1077
1078 async def test_merge_transfers_aliases(
1079 self, mass: MusicAssistant, genre_ctrl: GenreController
1080 ) -> None:
1081 """Aliases from source genres are added to the target."""
1082 target = await genre_ctrl.add_item_to_library(_make_genre("MergeTarget"))
1083 source = await genre_ctrl.add_item_to_library(_make_genre("MergeSource"))
1084 await genre_ctrl.add_alias(source.item_id, "SourceAlias")
1085
1086 result = await genre_ctrl.merge_genres([source.item_id], target.item_id)
1087 assert result.genre_aliases is not None
1088 assert "MergeTarget" in result.genre_aliases
1089 assert "MergeSource" in result.genre_aliases
1090 assert "SourceAlias" in result.genre_aliases
1091
1092 async def test_merge_transfers_media_mappings(
1093 self, mass: MusicAssistant, genre_ctrl: GenreController
1094 ) -> None:
1095 """Media mappings from source genres are moved to the target."""
1096 target = await genre_ctrl.add_item_to_library(_make_genre("MergeMapTarget"))
1097 source = await genre_ctrl.add_item_to_library(_make_genre("MergeMapSource"))
1098 track = await _add_test_track(mass, "Merge Track")
1099 await genre_ctrl.add_media_mapping(
1100 source.item_id, MediaType.TRACK, track.item_id, "MergeMapSource"
1101 )
1102
1103 await genre_ctrl.merge_genres([source.item_id], target.item_id)
1104 rows = await mass.music.database.get_rows_from_query(
1105 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1106 "WHERE genre_id = :gid AND media_type = 'track' AND media_id = :mid",
1107 {"gid": int(target.item_id), "mid": int(track.item_id)},
1108 limit=1,
1109 )
1110 assert len(rows) == 1
1111
1112 async def test_merge_deletes_source_genres(
1113 self, mass: MusicAssistant, genre_ctrl: GenreController
1114 ) -> None:
1115 """Source genres are deleted after merge."""
1116 target = await genre_ctrl.add_item_to_library(_make_genre("MergeDelTarget"))
1117 source = await genre_ctrl.add_item_to_library(_make_genre("MergeDelSource"))
1118
1119 await genre_ctrl.merge_genres([source.item_id], target.item_id)
1120 with pytest.raises(MediaNotFoundError):
1121 await genre_ctrl.get_library_item(int(source.item_id))
1122
1123 async def test_merge_deduplicates_aliases(
1124 self, mass: MusicAssistant, genre_ctrl: GenreController
1125 ) -> None:
1126 """Overlapping aliases are not duplicated on the target."""
1127 target = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupTarget"))
1128 await genre_ctrl.add_alias(target.item_id, "SharedAlias")
1129 source = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupSource"))
1130 await genre_ctrl.add_alias(source.item_id, "SharedAlias")
1131
1132 result = await genre_ctrl.merge_genres([source.item_id], target.item_id)
1133 assert result.genre_aliases is not None
1134 alias_list = list(result.genre_aliases)
1135 norm_aliases = [a for a in alias_list if a.lower().replace(" ", "") == "sharedalias"]
1136 assert len(norm_aliases) == 1
1137
1138 async def test_merge_deduplicates_media_mappings(
1139 self, mass: MusicAssistant, genre_ctrl: GenreController
1140 ) -> None:
1141 """Overlapping media mappings do not create duplicates."""
1142 target = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupMapTarget"))
1143 source = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupMapSource"))
1144 track = await _add_test_track(mass, "Merge Dedup Track")
1145 # Both genres map the same track
1146 await genre_ctrl.add_media_mapping(
1147 target.item_id, MediaType.TRACK, track.item_id, "MergeDedupMapTarget"
1148 )
1149 await genre_ctrl.add_media_mapping(
1150 source.item_id, MediaType.TRACK, track.item_id, "MergeDedupMapSource"
1151 )
1152
1153 await genre_ctrl.merge_genres([source.item_id], target.item_id)
1154 rows = await mass.music.database.get_rows_from_query(
1155 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1156 "WHERE genre_id = :gid AND media_type = 'track' AND media_id = :mid",
1157 {"gid": int(target.item_id), "mid": int(track.item_id)},
1158 limit=0,
1159 )
1160 assert len(rows) == 1
1161
1162 async def test_merge_multiple_sources(
1163 self, mass: MusicAssistant, genre_ctrl: GenreController
1164 ) -> None:
1165 """Multiple source genres can be merged at once."""
1166 target = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiTarget"))
1167 source1 = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiSrc1"))
1168 source2 = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiSrc2"))
1169 track1 = await _add_test_track(mass, "Multi Merge Track 1")
1170 track2 = await _add_test_track(mass, "Multi Merge Track 2")
1171 await genre_ctrl.add_media_mapping(
1172 source1.item_id, MediaType.TRACK, track1.item_id, "MergeMultiSrc1"
1173 )
1174 await genre_ctrl.add_media_mapping(
1175 source2.item_id, MediaType.TRACK, track2.item_id, "MergeMultiSrc2"
1176 )
1177
1178 result = await genre_ctrl.merge_genres([source1.item_id, source2.item_id], target.item_id)
1179 assert result.genre_aliases is not None
1180 assert "MergeMultiSrc1" in result.genre_aliases
1181 assert "MergeMultiSrc2" in result.genre_aliases
1182
1183 # Both tracks mapped to target
1184 rows = await mass.music.database.get_rows_from_query(
1185 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1186 "WHERE genre_id = :gid AND media_type = 'track'",
1187 {"gid": int(target.item_id)},
1188 limit=0,
1189 )
1190 assert len(rows) == 2
1191
1192 # Both sources deleted
1193 for src in (source1, source2):
1194 with pytest.raises(MediaNotFoundError):
1195 await genre_ctrl.get_library_item(int(src.item_id))
1196
1197 async def test_merge_target_in_source_raises(self, genre_ctrl: GenreController) -> None:
1198 """Raises ValueError when target is in the source list."""
1199 genre = await genre_ctrl.add_item_to_library(_make_genre("MergeSelfTarget"))
1200 with pytest.raises(ValueError, match="Target genre cannot be in the list"):
1201 await genre_ctrl.merge_genres([genre.item_id], genre.item_id)
1202
1203 async def test_merge_empty_source_raises(self, genre_ctrl: GenreController) -> None:
1204 """Raises ValueError when source list is empty."""
1205 target = await genre_ctrl.add_item_to_library(_make_genre("MergeEmptyTarget"))
1206 with pytest.raises(ValueError, match="No genre IDs provided"):
1207 await genre_ctrl.merge_genres([], target.item_id)
1208
1209 async def test_merge_cross_taxonomy_raises(self, genre_ctrl: GenreController) -> None:
1210 """Raises ValueError when source and target belong to different taxonomies."""
1211 target = await genre_ctrl.add_item_to_library(_make_genre("MergeMusicTarget"))
1212 source = await genre_ctrl.add_item_to_library(
1213 Genre(
1214 item_id="0",
1215 provider="library",
1216 name="MergePodcastSource",
1217 provider_mappings=set(),
1218 content_type=MediaType.PODCAST,
1219 )
1220 )
1221 with pytest.raises(ValueError, match="same taxonomy"):
1222 await genre_ctrl.merge_genres([source.item_id], target.item_id)
1223 # the source genre must survive a rejected merge
1224 assert await genre_ctrl.get_library_item(int(source.item_id)) is not None
1225
1226
1227# ===================================================================
1228# Group G: restore_default_genres (5 tests)
1229# ===================================================================
1230
1231
1232class TestRestoreDefaultGenres:
1233 """Tests for restore_default_genres."""
1234
1235 async def test_restore_partial_on_empty(self, genre_ctrl: GenreController) -> None:
1236 """Partial restore on pre-seeded DB returns empty (nothing to add)."""
1237 # Genres are already seeded during startup (_setup_database), so a partial
1238 # restore is idempotent and returns no new genres.
1239 created = await genre_ctrl.restore_default_genres(full_restore=False)
1240 assert len(created) == 0
1241 # Verify the default genres are actually present
1242 count = await genre_ctrl.library_count()
1243 assert count >= len(DEFAULT_GENRE_MAPPING)
1244
1245 async def test_restore_partial_idempotent(self, genre_ctrl: GenreController) -> None:
1246 """Second call returns empty list (no duplicates)."""
1247 await genre_ctrl.restore_default_genres(full_restore=False)
1248 second = await genre_ctrl.restore_default_genres(full_restore=False)
1249 assert len(second) == 0
1250
1251 async def test_restore_partial_adds_missing(
1252 self, mass: MusicAssistant, genre_ctrl: GenreController
1253 ) -> None:
1254 """Pre-existing genres not duplicated, missing ones added."""
1255 first_default = DEFAULT_GENRE_MAPPING[0]["genre"]
1256 await genre_ctrl.add_item_to_library(_make_genre(first_default))
1257 before = await genre_ctrl.library_count()
1258 created = await genre_ctrl.restore_default_genres(full_restore=False)
1259 after = await genre_ctrl.library_count()
1260 assert len(created) == after - before
1261
1262 async def test_restore_full_clears_all(self, genre_ctrl: GenreController) -> None:
1263 """Full restore: custom genres gone, only defaults remain."""
1264 await genre_ctrl.add_item_to_library(_make_genre("MyCustomGenre"))
1265 await genre_ctrl.restore_default_genres(full_restore=True)
1266 items = await genre_ctrl.library_items(limit=0, hide_empty=False)
1267 names = {g.name for g in items}
1268 assert "MyCustomGenre" not in names
1269 # full restore seeds every taxonomy (music + podcast + audiobook)
1270 assert len(items) == (
1271 len(DEFAULT_GENRE_MAPPING)
1272 + len(DEFAULT_PODCAST_GENRE_MAPPING)
1273 + len(DEFAULT_AUDIOBOOK_GENRE_MAPPING)
1274 )
1275
1276 async def test_restore_creates_configured_aliases(self, genre_ctrl: GenreController) -> None:
1277 """Genres have aliases from genre_mapping.json."""
1278 await genre_ctrl.restore_default_genres(full_restore=True)
1279 entries_with_aliases = [e for e in DEFAULT_GENRE_MAPPING if e.get("aliases")]
1280 if not entries_with_aliases:
1281 pytest.skip("No default genres with aliases configured")
1282 entry = entries_with_aliases[0]
1283 items = await genre_ctrl.library_items(
1284 search=entry["genre"], hide_empty=False, summary=False
1285 )
1286 assert len(items) > 0
1287 genre = items[0]
1288 assert genre.genre_aliases is not None
1289 # Self-alias should be present
1290 assert entry["genre"] in genre.genre_aliases
1291 # Configured aliases should be present
1292 for alias in entry["aliases"]:
1293 assert alias in genre.genre_aliases
1294
1295
1296# ===================================================================
1297# Group H: Query Methods (7 tests)
1298# ===================================================================
1299
1300
1301class TestQueryMethods:
1302 """Tests for the tracks, albums, mapped_media, and overview endpoints."""
1303
1304 async def test_genre_tracks_empty(self, genre_ctrl: GenreController) -> None:
1305 """A genre with no mapped tracks returns an empty list."""
1306 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyGenre"))
1307 assert await genre_ctrl.tracks(genre.item_id) == []
1308
1309 async def test_genre_tracks_returns_mapped(
1310 self, mass: MusicAssistant, genre_ctrl: GenreController
1311 ) -> None:
1312 """Tracks mapped to a genre are returned."""
1313 genre = await genre_ctrl.add_item_to_library(_make_genre("TracksGenre"))
1314 track = await _add_test_track(mass, "Genre Track")
1315 await genre_ctrl.add_media_mapping(
1316 genre.item_id, MediaType.TRACK, track.item_id, "TracksGenre"
1317 )
1318 tracks = await genre_ctrl.tracks(genre.item_id)
1319 assert any(t.name == "Genre Track" for t in tracks)
1320
1321 async def test_genre_tracks_respects_limit(
1322 self, mass: MusicAssistant, genre_ctrl: GenreController
1323 ) -> None:
1324 """The limit parameter caps the number of tracks returned."""
1325 genre = await genre_ctrl.add_item_to_library(_make_genre("LimitGenre"))
1326 for i in range(3):
1327 track = await _add_test_track(mass, f"Limit Track {i}")
1328 await genre_ctrl.add_media_mapping(
1329 genre.item_id, MediaType.TRACK, track.item_id, "LimitGenre"
1330 )
1331 assert len(await genre_ctrl.tracks(genre.item_id, limit=2)) == 2
1332
1333 async def test_genre_albums_returns_mapped(
1334 self, mass: MusicAssistant, genre_ctrl: GenreController
1335 ) -> None:
1336 """Albums mapped to a genre are returned."""
1337 genre = await genre_ctrl.add_item_to_library(_make_genre("AlbumsGenre"))
1338 album = await _add_test_album(mass, "Genre Album")
1339 await genre_ctrl.add_media_mapping(
1340 genre.item_id, MediaType.ALBUM, album.item_id, "AlbumsGenre"
1341 )
1342 albums = await genre_ctrl.albums(genre.item_id)
1343 assert any(a.name == "Genre Album" for a in albums)
1344
1345 async def test_mapped_media_returns_all_types(
1346 self, mass: MusicAssistant, genre_ctrl: GenreController
1347 ) -> None:
1348 """Returns (tracks, albums, artists) tuple."""
1349 genre = await genre_ctrl.add_item_to_library(_make_genre("MappedMedia"))
1350 result = await genre_ctrl.mapped_media(genre)
1351 assert isinstance(result, tuple)
1352 assert len(result) == 3
1353 tracks, albums, artists = result
1354 assert isinstance(tracks, list)
1355 assert isinstance(albums, list)
1356 assert isinstance(artists, list)
1357
1358 async def test_mapped_media_empty(self, genre_ctrl: GenreController) -> None:
1359 """No mappings returns ([], [], [])."""
1360 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyMapped"))
1361 tracks, albums, artists = await genre_ctrl.mapped_media(genre)
1362 assert tracks == []
1363 assert albums == []
1364 assert artists == []
1365
1366 async def test_overview_returns_folders(
1367 self, mass: MusicAssistant, genre_ctrl: GenreController
1368 ) -> None:
1369 """Returns RecommendationFolder items when mappings exist."""
1370 genre = await genre_ctrl.add_item_to_library(_make_genre("OverviewGenre"))
1371 track = await _add_test_track(mass, "Overview Track")
1372 await genre_ctrl.add_media_mapping(
1373 genre.item_id, MediaType.TRACK, track.item_id, "OverviewGenre"
1374 )
1375 folders = await genre_ctrl.get_overview(genre.item_id)
1376 assert len(folders) >= 1
1377 assert folders[0].name == "Tracks"
1378
1379 async def test_overview_empty(self, genre_ctrl: GenreController) -> None:
1380 """No mappings returns empty list."""
1381 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyOverview"))
1382 folders = await genre_ctrl.get_overview(genre.item_id)
1383 assert folders == []
1384
1385 async def test_get_genres_for_media_item(
1386 self, mass: MusicAssistant, genre_ctrl: GenreController
1387 ) -> None:
1388 """Returns genres mapped to a specific media item."""
1389 genre1 = await genre_ctrl.add_item_to_library(_make_genre("GenreForItem1"))
1390 genre2 = await genre_ctrl.add_item_to_library(_make_genre("GenreForItem2"))
1391 track = await _add_test_track(mass, "Track With Genres")
1392 await genre_ctrl.add_media_mapping(
1393 genre1.item_id, MediaType.TRACK, track.item_id, "GenreForItem1"
1394 )
1395 await genre_ctrl.add_media_mapping(
1396 genre2.item_id, MediaType.TRACK, track.item_id, "GenreForItem2"
1397 )
1398 genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id)
1399 genre_names = {g.name for g in genres}
1400 assert "GenreForItem1" in genre_names
1401 assert "GenreForItem2" in genre_names
1402
1403 async def test_get_genres_for_media_item_empty(
1404 self, mass: MusicAssistant, genre_ctrl: GenreController
1405 ) -> None:
1406 """Returns empty list for unmapped media item."""
1407 track = await _add_test_track(mass, "Track Without Genres")
1408 genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id)
1409 assert genres == []
1410
1411 async def test_get_genres_for_media_item_non_integer_id(
1412 self, mass: MusicAssistant, genre_ctrl: GenreController
1413 ) -> None:
1414 """Returns empty list for non-integer provider item IDs (e.g. Bandcamp compound IDs)."""
1415 genres = await genre_ctrl.get_genres_for_media_item(MediaType.ALBUM, "3957198221-190478553")
1416 assert genres == []
1417
1418 async def test_library_items_hide_empty_true(
1419 self, mass: MusicAssistant, genre_ctrl: GenreController
1420 ) -> None:
1421 """hide_empty=True returns only genres with mappings."""
1422 mapped = await genre_ctrl.add_item_to_library(_make_genre("HasMappingGenre"))
1423 unmapped = await genre_ctrl.add_item_to_library(_make_genre("NoMappingGenre"))
1424 track = await _add_test_track(mass, "HasMapping Track")
1425 await genre_ctrl.add_media_mapping(
1426 mapped.item_id, MediaType.TRACK, track.item_id, "HasMappingGenre"
1427 )
1428 results = await genre_ctrl.library_items(hide_empty=True)
1429 result_ids = {int(g.item_id) for g in results}
1430 assert int(mapped.item_id) in result_ids
1431 assert int(unmapped.item_id) not in result_ids
1432
1433 async def test_library_items_hide_empty_default(self, genre_ctrl: GenreController) -> None:
1434 """Default (hide_empty=None) returns only default genres (translation_key IS NOT NULL)."""
1435 await genre_ctrl.restore_default_genres()
1436 scanned = await genre_ctrl._find_genres_for_alias("DefaultFilterScannedXyz", None)
1437 assert scanned
1438
1439 default_genre_name = DEFAULT_GENRE_MAPPING[0]["genre"]
1440 results = await genre_ctrl.library_items()
1441 names = {g.name for g in results}
1442 assert default_genre_name in names
1443 assert "DefaultFilterScannedXyz" not in names
1444
1445 async def test_library_items_show_all(
1446 self, mass: MusicAssistant, genre_ctrl: GenreController
1447 ) -> None:
1448 """hide_empty=False returns all genres including unmapped."""
1449 mapped = await genre_ctrl.add_item_to_library(_make_genre("ShowAllMapped"))
1450 unmapped = await genre_ctrl.add_item_to_library(_make_genre("ShowAllUnmapped"))
1451 track = await _add_test_track(mass, "ShowAll Track")
1452 await genre_ctrl.add_media_mapping(
1453 mapped.item_id, MediaType.TRACK, track.item_id, "ShowAllMapped"
1454 )
1455 results = await genre_ctrl.library_items(hide_empty=False)
1456 result_ids = {int(g.item_id) for g in results}
1457 assert int(mapped.item_id) in result_ids
1458 assert int(unmapped.item_id) in result_ids
1459
1460
1461# ===================================================================
1462# Group I: Genre Lookup & Scanner (5 tests)
1463# ===================================================================
1464
1465
1466class TestGenreLookupAndScanner:
1467 """Tests for genre/alias lookup and scanner status."""
1468
1469 async def test_find_genres_for_alias_existing(self, genre_ctrl: GenreController) -> None:
1470 """Finds existing genre by name."""
1471 genre = await genre_ctrl.add_item_to_library(_make_genre("Garage"))
1472 found = await genre_ctrl._find_genres_for_alias("Garage", None)
1473 assert isinstance(found, list)
1474 assert int(genre.item_id) in found
1475
1476 async def test_find_genres_for_alias_by_alias(self, genre_ctrl: GenreController) -> None:
1477 """Finds existing genre by alias string in genre_aliases JSON."""
1478 genre = await genre_ctrl.add_item_to_library(_make_genre("Breakbeat"))
1479 await genre_ctrl.add_alias(genre.item_id, "Big Beat")
1480 found = await genre_ctrl._find_genres_for_alias("Big Beat", None)
1481 assert isinstance(found, list)
1482 assert int(genre.item_id) in found
1483
1484 async def test_find_genres_for_alias_primary_name_takes_priority(
1485 self, genre_ctrl: GenreController
1486 ) -> None:
1487 """
1488 Primary name match returns only that genre, ignoring secondary alias matches.
1489
1490 Regression test: a bare "pop" tag must not fan out to Rock/Punk/etc. that
1491 accumulated "pop" as a side-effect alias, when a dedicated Pop genre exists.
1492 """
1493 # Use the pre-seeded Pop and Rock genres (seeded during startup).
1494 pop_items = await genre_ctrl.library_items(search="Pop", hide_empty=False)
1495 rock_items = await genre_ctrl.library_items(search="Rock", hide_empty=False)
1496 pop_genre = next(g for g in pop_items if g.name == "pop")
1497 rock_genre = next(g for g in rock_items if g.name == "rock")
1498 # Simulate "pop" being written as a secondary alias on Rock (the bug scenario)
1499 await genre_ctrl.add_alias(rock_genre.item_id, "pop")
1500
1501 found = await genre_ctrl._find_genres_for_alias("Pop", None)
1502 assert found == [int(pop_genre.item_id)]
1503
1504 async def test_find_genres_for_alias_creates_new(self, genre_ctrl: GenreController) -> None:
1505 """Creates new genre when no match found."""
1506 found = await genre_ctrl._find_genres_for_alias("BrandNewGenre12345", None)
1507 assert isinstance(found, list)
1508 assert len(found) == 1
1509 genre = await genre_ctrl.get_library_item(found[0])
1510 assert genre.name == "BrandNewGenre12345"
1511
1512 async def test_scanner_status(self, genre_ctrl: GenreController) -> None:
1513 """Returns dict with expected keys."""
1514 status = await genre_ctrl.get_scanner_status()
1515 assert "running" in status
1516 assert "last_scan_time" in status
1517
1518 async def test_scan_mappings_trigger(self, genre_ctrl: GenreController) -> None:
1519 """Returns 'triggered' status."""
1520 result = await genre_ctrl.scan_mappings()
1521 assert result["status"] == "triggered"
1522
1523
1524# ===================================================================
1525# Group J: Base Class Integration (3 tests)
1526# ===================================================================
1527
1528
1529class TestBaseClassIntegration:
1530 """Tests for base class query patterns (genre_aliases column, pagination, favorites)."""
1531
1532 async def test_genre_aliases_inline(self, genre_ctrl: GenreController) -> None:
1533 """genre_aliases column populates genre_aliases on fetched Genre."""
1534 genre = await genre_ctrl.add_item_to_library(_make_genre("InlineTest"))
1535 await genre_ctrl.add_alias(genre.item_id, "Inline Alias")
1536 # Fetch via library_items (uses base_query); request full items so genre_aliases hydrates
1537 items = await genre_ctrl.library_items(search="InlineTest", hide_empty=False, summary=False)
1538 assert len(items) >= 1
1539 fetched = items[0]
1540 assert fetched.genre_aliases is not None
1541 assert "InlineTest" in fetched.genre_aliases
1542 assert "Inline Alias" in fetched.genre_aliases
1543
1544 async def test_pagination(self, genre_ctrl: GenreController) -> None:
1545 """limit/offset work correctly."""
1546 for i in range(5):
1547 await genre_ctrl.add_item_to_library(_make_genre(f"Page{i}"))
1548 page1 = await genre_ctrl.library_items(limit=2, offset=0, order_by="name", hide_empty=False)
1549 page2 = await genre_ctrl.library_items(limit=2, offset=2, order_by="name", hide_empty=False)
1550 assert len(page1) == 2
1551 assert len(page2) == 2
1552 ids1 = {g.item_id for g in page1}
1553 ids2 = {g.item_id for g in page2}
1554 assert ids1.isdisjoint(ids2)
1555
1556 async def test_favorite_filter(self, genre_ctrl: GenreController) -> None:
1557 """favorite=True filters correctly."""
1558 await genre_ctrl.add_item_to_library(_make_genre("FavYes", favorite=True))
1559 await genre_ctrl.add_item_to_library(_make_genre("FavNo", favorite=False))
1560 favs = await genre_ctrl.library_items(favorite=True, hide_empty=False)
1561 assert all(g.favorite for g in favs)
1562 assert any(g.name == "FavYes" for g in favs)
1563
1564
1565# ===================================================================
1566# Group K: _cleanup_stale_genre_mappings (7 tests)
1567# ===================================================================
1568
1569
1570async def _set_track_genres(mass: MusicAssistant, track_id: int, genres: list[str]) -> None:
1571 """Set metadata.genres on a track row directly in the DB."""
1572 await mass.music.database.execute(
1573 f"UPDATE {DB_TABLE_TRACKS} "
1574 "SET metadata = json_set(metadata, '$.genres', json(:genres)) "
1575 "WHERE item_id = :id",
1576 {"genres": json.dumps(genres), "id": track_id},
1577 )
1578 await mass.music.database.commit()
1579
1580
1581async def _set_album_genres(mass: MusicAssistant, album_id: int, genres: list[str]) -> None:
1582 """Set metadata.genres on an album row directly in the DB."""
1583 await mass.music.database.execute(
1584 f"UPDATE {DB_TABLE_ALBUMS} "
1585 "SET metadata = json_set(metadata, '$.genres', json(:genres)) "
1586 "WHERE item_id = :id",
1587 {"genres": json.dumps(genres), "id": album_id},
1588 )
1589 await mass.music.database.commit()
1590
1591
1592class TestCleanupStaleMappings:
1593 """Tests for _cleanup_stale_genre_mappings."""
1594
1595 async def test_stale_mapping_removed_on_call(
1596 self, mass: MusicAssistant, genre_ctrl: GenreController
1597 ) -> None:
1598 """A stale scanner mapping is removed when cleanup is called."""
1599 genre = await genre_ctrl.add_item_to_library(_make_genre("CsCountGenre"))
1600 track = await _add_test_track(mass, "CsCount Track")
1601 genre_id = int(genre.item_id)
1602 track_id = int(track.item_id)
1603 # insert a non-manual (scanner-style) row; track has no metadata.genres
1604 await mass.music.database.insert(
1605 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
1606 {
1607 "genre_id": genre_id,
1608 "media_id": track_id,
1609 "media_type": MediaType.TRACK.value,
1610 "alias": "CsCountGenre",
1611 },
1612 )
1613 await genre_ctrl._cleanup_stale_genre_mappings()
1614 rows = await mass.music.database.get_rows_from_query(
1615 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1616 "WHERE genre_id = :gid AND media_id = :mid",
1617 {"gid": genre_id, "mid": track_id},
1618 limit=0,
1619 )
1620 assert len(rows) == 0
1621
1622 async def test_empty_metadata_genres_removes_mapping(
1623 self, mass: MusicAssistant, genre_ctrl: GenreController
1624 ) -> None:
1625 """Scanner mapping is removed when the track has no genres in metadata."""
1626 genre = await genre_ctrl.add_item_to_library(_make_genre("CsStale1Genre"))
1627 track = await _add_test_track(mass, "CsStale1 Track")
1628 genre_id = int(genre.item_id)
1629 track_id = int(track.item_id)
1630 # insert a non-manual (scanner-style) row
1631 await mass.music.database.insert(
1632 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
1633 {
1634 "genre_id": genre_id,
1635 "media_id": track_id,
1636 "media_type": MediaType.TRACK.value,
1637 "alias": "CsStale1Genre",
1638 },
1639 )
1640
1641 await genre_ctrl._cleanup_stale_genre_mappings()
1642
1643 rows = await mass.music.database.get_rows_from_query(
1644 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1645 "WHERE genre_id = :gid AND media_id = :mid",
1646 {"gid": genre_id, "mid": track_id},
1647 limit=0,
1648 )
1649 assert len(rows) == 0
1650
1651 async def test_live_alias_mapping_preserved(
1652 self, mass: MusicAssistant, genre_ctrl: GenreController
1653 ) -> None:
1654 """Mapping is kept when the alias is still present in track metadata.genres."""
1655 genre = await genre_ctrl.add_item_to_library(_make_genre("CsLive1Genre"))
1656 track = await _add_test_track(mass, "CsLive1 Track")
1657 genre_id = int(genre.item_id)
1658 track_id = int(track.item_id)
1659 await _set_track_genres(mass, track_id, ["CsLive1Genre"])
1660 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track.item_id, "CsLive1Genre")
1661
1662 await genre_ctrl._cleanup_stale_genre_mappings()
1663
1664 rows = await mass.music.database.get_rows_from_query(
1665 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1666 "WHERE genre_id = :gid AND media_id = :mid",
1667 {"gid": genre_id, "mid": track_id},
1668 limit=0,
1669 )
1670 assert len(rows) == 1
1671
1672 async def test_orphaned_mapping_removed(
1673 self, mass: MusicAssistant, genre_ctrl: GenreController
1674 ) -> None:
1675 """Mapping is removed when the media item no longer exists in the DB."""
1676 genre = await genre_ctrl.add_item_to_library(_make_genre("CsOrphan1Genre"))
1677 track = await _add_test_track(mass, "CsOrphan1 Track")
1678 genre_id = int(genre.item_id)
1679 track_id = int(track.item_id)
1680 await _set_track_genres(mass, track_id, ["CsOrphan1Genre"])
1681 await genre_ctrl.add_media_mapping(
1682 genre_id, MediaType.TRACK, track.item_id, "CsOrphan1Genre"
1683 )
1684
1685 # Delete the track directly, leaving behind an orphaned mapping row
1686 await mass.music.database.execute(
1687 f"DELETE FROM {DB_TABLE_TRACKS} WHERE item_id = :id", {"id": track_id}
1688 )
1689 await mass.music.database.commit()
1690
1691 await genre_ctrl._cleanup_stale_genre_mappings()
1692
1693 rows = await mass.music.database.get_rows_from_query(
1694 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1695 "WHERE genre_id = :gid AND media_id = :mid",
1696 {"gid": genre_id, "mid": track_id},
1697 limit=0,
1698 )
1699 assert len(rows) == 0
1700
1701 async def test_empty_nondefault_genre_deleted(
1702 self, mass: MusicAssistant, genre_ctrl: GenreController
1703 ) -> None:
1704 """Non-default genre (is_default = 0) with no mappings is deleted."""
1705 # _find_genres_for_alias creates genres with is_default = 0
1706 found = await genre_ctrl._find_genres_for_alias("CsNonDefault1XYZ99", None)
1707 assert len(found) == 1
1708 genre_id = found[0]
1709 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1710 assert row is not None
1711 assert row["translation_key"] is None
1712
1713 await genre_ctrl._cleanup_stale_genre_mappings()
1714
1715 row_after = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1716 assert row_after is None
1717
1718 async def test_default_genre_without_mappings_preserved(
1719 self, genre_ctrl: GenreController, mass: MusicAssistant
1720 ) -> None:
1721 """Default genre (translation_key IS NOT NULL) is never deleted by cleanup."""
1722 await genre_ctrl.restore_default_genres(full_restore=False)
1723 default_entry = next(e for e in DEFAULT_GENRE_MAPPING if e.get("translation_key"))
1724 rows = await mass.music.database.get_rows_from_query(
1725 f"SELECT item_id FROM {DB_TABLE_GENRES} WHERE translation_key = :tk",
1726 {"tk": default_entry["translation_key"]},
1727 limit=1,
1728 )
1729 assert len(rows) == 1
1730 genre_id = int(rows[0]["item_id"])
1731 # Confirm no active mappings for this genre
1732 mapping_rows = await mass.music.database.get_rows_from_query(
1733 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} WHERE genre_id = :gid",
1734 {"gid": genre_id},
1735 limit=0,
1736 )
1737 if mapping_rows:
1738 pytest.skip("Default genre already has mappings")
1739
1740 await genre_ctrl._cleanup_stale_genre_mappings()
1741
1742 row_after = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1743 assert row_after is not None
1744
1745 async def test_nondefault_genre_with_active_mappings_preserved(
1746 self, mass: MusicAssistant, genre_ctrl: GenreController
1747 ) -> None:
1748 """Non-default genre with at least one active mapping is NOT deleted."""
1749 track = await _add_test_track(mass, "CsKeep Track")
1750 track_id = int(track.item_id)
1751 await _set_track_genres(mass, track_id, ["CsKeepGenreXYZ"])
1752 found = await genre_ctrl._find_genres_for_alias("CsKeepGenreXYZ", None)
1753 genre_id = found[0]
1754 await genre_ctrl.add_media_mapping(
1755 genre_id, MediaType.TRACK, track.item_id, "CsKeepGenreXYZ"
1756 )
1757
1758 await genre_ctrl._cleanup_stale_genre_mappings()
1759
1760 row_after = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1761 assert row_after is not None
1762
1763 async def test_manual_mapping_preserved_when_alias_not_in_metadata(
1764 self, mass: MusicAssistant, genre_ctrl: GenreController
1765 ) -> None:
1766 """
1767 Manually-added mappings survive cleanup when alias is not in metadata.
1768
1769 Regression for music-assistant/support#5310: a user creates a custom
1770 ("music type") genre via the UI and links an album to it. The album's
1771 metadata.genres reflects only the source-file tags and never contains
1772 the custom genre name, so the previous cleanup query wiped the mapping
1773 — and, transitively, the empty-non-default genre row.
1774 """
1775 genre = await genre_ctrl.add_item_to_library(_make_genre("MyCustomTypeXYZ"))
1776 genre_id = int(genre.item_id)
1777 album = await _add_test_album(mass, "Custom Type Album XYZ")
1778 album_id = int(album.item_id)
1779 # album source tags are unrelated to the custom genre
1780 await _set_album_genres(mass, album_id, ["Rock"])
1781 # user links album -> custom genre (no explicit alias, matches UI flow)
1782 await genre_ctrl.add_media_mapping(genre_id, MediaType.ALBUM, album_id)
1783
1784 await genre_ctrl._cleanup_stale_genre_mappings()
1785
1786 mapping_rows = await mass.music.database.get_rows_from_query(
1787 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1788 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album'",
1789 {"gid": genre_id, "mid": album_id},
1790 limit=0,
1791 )
1792 assert len(mapping_rows) == 1, "manual mapping was deleted by cleanup"
1793 genre_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1794 assert genre_row is not None, "custom genre was deleted after its mapping disappeared"
1795
1796 async def test_playlog_entries_cleaned_for_deleted_genre(
1797 self, mass: MusicAssistant, genre_ctrl: GenreController
1798 ) -> None:
1799 """Playlog entries for a deleted empty non-default genre are removed."""
1800 found = await genre_ctrl._find_genres_for_alias("CsPlaylog1XYZ99", None)
1801 genre_id = found[0]
1802 # Insert a fake playlog entry for this genre
1803 cols = (
1804 "(item_id, provider, media_type, name, fully_played, seconds_played, timestamp, userid)"
1805 )
1806 await mass.music.database.execute(
1807 f"INSERT OR IGNORE INTO {DB_TABLE_PLAYLOG} {cols} "
1808 "VALUES (:item_id, 'library', :media_type, 'CsPlaylog1XYZ99', 0, 0, 0, 'testuser')",
1809 {"item_id": str(genre_id), "media_type": "genre"},
1810 )
1811 await mass.music.database.commit()
1812
1813 await genre_ctrl._cleanup_stale_genre_mappings()
1814
1815 # Both the genre and its playlog entry should be gone
1816 genre_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1817 assert genre_row is None
1818 playlog_rows = await mass.music.database.get_rows_from_query(
1819 f"SELECT * FROM {DB_TABLE_PLAYLOG} WHERE media_type = 'genre' AND item_id = :id",
1820 {"id": str(genre_id)},
1821 limit=0,
1822 )
1823 assert len(playlog_rows) == 0
1824
1825
1826# ===================================================================
1827# Group L: Genre Exclusion (5 tests)
1828# ===================================================================
1829
1830
1831class TestGenreExclusion:
1832 """Tests for exclude_genre_from_media_item and remove_genre_exclusion."""
1833
1834 async def test_exclude_inserts_row(
1835 self, mass: MusicAssistant, genre_ctrl: GenreController
1836 ) -> None:
1837 """exclude_genre_from_media_item inserts a row into the exclusion table."""
1838 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclGenre1"))
1839 track = await _add_test_track(mass, "ExclTrack1")
1840 genre_id = int(genre.item_id)
1841 track_id = int(track.item_id)
1842
1843 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1844
1845 rows = await mass.music.database.get_rows_from_query(
1846 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION} "
1847 "WHERE genre_id = :gid AND media_id = :mid AND media_type = :mt",
1848 {"gid": genre_id, "mid": track_id, "mt": "track"},
1849 limit=0,
1850 )
1851 assert len(rows) == 1
1852
1853 async def test_exclude_removes_existing_mapping(
1854 self, mass: MusicAssistant, genre_ctrl: GenreController
1855 ) -> None:
1856 """exclude_genre_from_media_item immediately deletes any existing mapping."""
1857 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclGenre2"))
1858 track = await _add_test_track(mass, "ExclTrack2")
1859 genre_id = int(genre.item_id)
1860 track_id = int(track.item_id)
1861
1862 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "ExclGenre2")
1863 pre_rows = await mass.music.database.get_rows_from_query(
1864 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1865 "WHERE genre_id = :gid AND media_id = :mid",
1866 {"gid": genre_id, "mid": track_id},
1867 limit=0,
1868 )
1869 assert len(pre_rows) == 1
1870
1871 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1872
1873 post_rows = await mass.music.database.get_rows_from_query(
1874 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1875 "WHERE genre_id = :gid AND media_id = :mid",
1876 {"gid": genre_id, "mid": track_id},
1877 limit=0,
1878 )
1879 assert len(post_rows) == 0
1880
1881 async def test_exclude_idempotent(
1882 self, mass: MusicAssistant, genre_ctrl: GenreController
1883 ) -> None:
1884 """Calling exclude_genre_from_media_item twice is idempotent."""
1885 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclGenre3"))
1886 track = await _add_test_track(mass, "ExclTrack3")
1887 genre_id = int(genre.item_id)
1888 track_id = int(track.item_id)
1889
1890 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1891 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1892
1893 rows = await mass.music.database.get_rows_from_query(
1894 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION} "
1895 "WHERE genre_id = :gid AND media_id = :mid AND media_type = :mt",
1896 {"gid": genre_id, "mid": track_id, "mt": "track"},
1897 limit=0,
1898 )
1899 assert len(rows) == 1
1900
1901 async def test_remove_exclusion_deletes_row(
1902 self, mass: MusicAssistant, genre_ctrl: GenreController
1903 ) -> None:
1904 """remove_genre_exclusion deletes the exclusion row."""
1905 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclGenre4"))
1906 track = await _add_test_track(mass, "ExclTrack4")
1907 genre_id = int(genre.item_id)
1908 track_id = int(track.item_id)
1909
1910 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1911 await genre_ctrl.remove_genre_exclusion(genre_id, MediaType.TRACK, track_id)
1912
1913 rows = await mass.music.database.get_rows_from_query(
1914 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION} "
1915 "WHERE genre_id = :gid AND media_id = :mid",
1916 {"gid": genre_id, "mid": track_id},
1917 limit=0,
1918 )
1919 assert len(rows) == 0
1920
1921 async def test_scanner_respects_exclusion(
1922 self, mass: MusicAssistant, genre_ctrl: GenreController
1923 ) -> None:
1924 """_bulk_scan_unmapped_genres does not create a mapping for an excluded pair."""
1925 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclScanGenre"))
1926 track = await _add_test_track(mass, "ExclScan Track")
1927 genre_id = int(genre.item_id)
1928 track_id = int(track.item_id)
1929
1930 await _set_track_genres(mass, track_id, ["ExclScanGenre"])
1931 await genre_ctrl.add_alias(genre_id, "ExclScanGenre")
1932 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1933 await genre_ctrl._bulk_scan_unmapped_genres()
1934
1935 mapping_rows = await mass.music.database.get_rows_from_query(
1936 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1937 "WHERE genre_id = :gid AND media_id = :mid",
1938 {"gid": genre_id, "mid": track_id},
1939 limit=0,
1940 )
1941 assert len(mapping_rows) == 0
1942
1943 async def test_full_scanner_respects_exclusion(
1944 self, mass: MusicAssistant, genre_ctrl: GenreController
1945 ) -> None:
1946 """_bulk_scan_media_genres does not create a mapping for an excluded pair."""
1947 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclFullScanGenre"))
1948 track = await _add_test_track(mass, "ExclFullScan Track")
1949 genre_id = int(genre.item_id)
1950 track_id = int(track.item_id)
1951
1952 await _set_track_genres(mass, track_id, ["ExclFullScanGenre"])
1953 await genre_ctrl.add_alias(genre_id, "ExclFullScanGenre")
1954 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1955 await genre_ctrl._bulk_scan_media_genres()
1956
1957 mapping_rows = await mass.music.database.get_rows_from_query(
1958 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1959 "WHERE genre_id = :gid AND media_id = :mid",
1960 {"gid": genre_id, "mid": track_id},
1961 limit=0,
1962 )
1963 assert len(mapping_rows) == 0
1964
1965 async def test_cleanup_preserves_genre_with_exclusion(
1966 self, mass: MusicAssistant, genre_ctrl: GenreController
1967 ) -> None:
1968 """
1969 _cleanup_stale_genre_mappings keeps a genre that has an exclusion but no mappings.
1970
1971 Verifies both that the genre row survives and that a playlog entry for it is
1972 also preserved (the playlog DELETE uses the same exclusion guard).
1973 """
1974 genre = await genre_ctrl.add_item_to_library(_make_genre("CleanupPreservedGenre"))
1975 track = await _add_test_track(mass, "CleanupPreserved Track")
1976 genre_id = int(genre.item_id)
1977 track_id = int(track.item_id)
1978
1979 # Exclude the genre from the track (also removes any mapping that might exist)
1980 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1981
1982 # Insert a playlog entry for the genre so we can confirm it is also kept
1983 cols = (
1984 "(item_id, provider, media_type, name, fully_played, seconds_played, timestamp, userid)"
1985 )
1986 await mass.music.database.execute(
1987 f"INSERT OR IGNORE INTO {DB_TABLE_PLAYLOG} {cols} "
1988 "VALUES (:item_id, 'library', 'genre', :name, 0, 0, 0, 'testuser')",
1989 {"item_id": str(genre_id), "name": "CleanupPreservedGenre"},
1990 )
1991 await mass.music.database.commit()
1992
1993 await genre_ctrl._cleanup_stale_genre_mappings()
1994
1995 genre_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1996 assert genre_row is not None, "genre with an exclusion must not be deleted by cleanup"
1997
1998 playlog_rows = await mass.music.database.get_rows_from_query(
1999 f"SELECT * FROM {DB_TABLE_PLAYLOG} WHERE media_type = 'genre' AND item_id = :id",
2000 {"id": str(genre_id)},
2001 limit=0,
2002 )
2003 assert len(playlog_rows) == 1, "playlog entry for an excluded genre must not be deleted"
2004
2005 async def test_get_genre_exclusions_for_media_item(
2006 self, mass: MusicAssistant, genre_ctrl: GenreController
2007 ) -> None:
2008 """Returns genres excluded from a specific media item."""
2009 genre1 = await genre_ctrl.add_item_to_library(_make_genre("ExclQueryGenre1"))
2010 genre2 = await genre_ctrl.add_item_to_library(_make_genre("ExclQueryGenre2"))
2011 track = await _add_test_track(mass, "ExclQuery Track")
2012 await genre_ctrl.exclude_genre_from_media_item(
2013 genre1.item_id, MediaType.TRACK, track.item_id
2014 )
2015 await genre_ctrl.exclude_genre_from_media_item(
2016 genre2.item_id, MediaType.TRACK, track.item_id
2017 )
2018 result = await genre_ctrl.get_genre_exclusions_for_media_item(
2019 MediaType.TRACK, track.item_id
2020 )
2021 names = {g.name for g in result}
2022 assert "ExclQueryGenre1" in names
2023 assert "ExclQueryGenre2" in names
2024
2025 async def test_get_genre_exclusions_for_media_item_empty(
2026 self, mass: MusicAssistant, genre_ctrl: GenreController
2027 ) -> None:
2028 """Returns empty list when no genres are excluded from the media item."""
2029 track = await _add_test_track(mass, "ExclQuery No Exclusions Track")
2030 result = await genre_ctrl.get_genre_exclusions_for_media_item(
2031 MediaType.TRACK, track.item_id
2032 )
2033 assert result == []
2034
2035 async def test_get_genre_exclusions_for_media_item_non_integer_id(
2036 self, mass: MusicAssistant, genre_ctrl: GenreController
2037 ) -> None:
2038 """Returns empty list for non-integer media IDs."""
2039 result = await genre_ctrl.get_genre_exclusions_for_media_item(
2040 MediaType.ALBUM, "3957198221-190478553"
2041 )
2042 assert result == []
2043
2044
2045class TestPropagateGenreMappings:
2046 """Tests for _propagate_genre_mappings_to_parents."""
2047
2048 async def test_propagation_derives_album_genre(
2049 self, mass: MusicAssistant, genre_ctrl: GenreController
2050 ) -> None:
2051 """Track genre mappings are propagated to the track's album."""
2052 instance_id = "fs_local_instance_album"
2053 genre = await genre_ctrl.add_item_to_library(_make_genre("PropAlbumGenre"))
2054 artist = await _add_test_artist(mass, "PropAlbum Artist")
2055 album = await _add_test_album(mass, "PropAlbum Album")
2056 track = await mass.music.tracks.add_item_to_library(
2057 Track(
2058 item_id="0",
2059 provider="library",
2060 name="PropAlbum Track",
2061 provider_mappings=set(),
2062 artists=UniqueList([artist]),
2063 )
2064 )
2065 track_id = int(track.item_id)
2066 album_id = int(album.item_id)
2067 genre_id = int(genre.item_id)
2068
2069 await mass.music.database.insert(
2070 DB_TABLE_ALBUM_TRACKS,
2071 {"track_id": track_id, "album_id": album_id, "disc_number": 1, "track_number": 1},
2072 )
2073 await mass.music.database.insert(
2074 DB_TABLE_PROVIDER_MAPPINGS,
2075 {
2076 "media_type": "track",
2077 "item_id": track_id,
2078 "provider_domain": "filesystem_local",
2079 "provider_instance": instance_id,
2080 "provider_item_id": f"track_{track_id}",
2081 },
2082 )
2083 await mass.music.database.commit()
2084 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "PropAlbumGenre")
2085
2086 mock_provider = MagicMock()
2087 mock_provider.domain = "filesystem_local"
2088 mock_provider.instance_id = instance_id
2089 with (
2090 patch.object(
2091 type(mass.music),
2092 "providers",
2093 new_callable=PropertyMock,
2094 return_value=[mock_provider],
2095 ),
2096 patch.object(
2097 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2098 ),
2099 ):
2100 await genre_ctrl._propagate_genre_mappings_to_parents()
2101
2102 rows = await mass.music.database.get_rows_from_query(
2103 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2104 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album' AND is_derived = 1",
2105 {"gid": genre_id, "mid": album_id},
2106 limit=0,
2107 )
2108 assert len(rows) == 1
2109
2110 async def test_propagation_derives_artist_genre(
2111 self, mass: MusicAssistant, genre_ctrl: GenreController
2112 ) -> None:
2113 """Track genre mappings are propagated to the track's artist."""
2114 instance_id = "fs_local_instance_artist"
2115 genre = await genre_ctrl.add_item_to_library(_make_genre("PropArtistGenre"))
2116 track = await _add_test_track(mass, "PropArtist Track")
2117 track_id = int(track.item_id)
2118 artist_id = int(track.artists[0].item_id)
2119 genre_id = int(genre.item_id)
2120
2121 await mass.music.database.insert(
2122 DB_TABLE_PROVIDER_MAPPINGS,
2123 {
2124 "media_type": "track",
2125 "item_id": track_id,
2126 "provider_domain": "filesystem_local",
2127 "provider_instance": instance_id,
2128 "provider_item_id": f"track_{track_id}",
2129 },
2130 )
2131 await mass.music.database.commit()
2132 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "PropArtistGenre")
2133
2134 mock_provider = MagicMock()
2135 mock_provider.domain = "filesystem_local"
2136 mock_provider.instance_id = instance_id
2137 with (
2138 patch.object(
2139 type(mass.music),
2140 "providers",
2141 new_callable=PropertyMock,
2142 return_value=[mock_provider],
2143 ),
2144 patch.object(
2145 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2146 ),
2147 ):
2148 await genre_ctrl._propagate_genre_mappings_to_parents()
2149
2150 rows = await mass.music.database.get_rows_from_query(
2151 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2152 "WHERE genre_id = :gid AND media_id = :mid "
2153 "AND media_type = 'artist' AND is_derived = 1",
2154 {"gid": genre_id, "mid": artist_id},
2155 limit=0,
2156 )
2157 assert len(rows) == 1
2158
2159 async def test_propagation_respects_exclusion(
2160 self, mass: MusicAssistant, genre_ctrl: GenreController
2161 ) -> None:
2162 """Excluded genre-album pairs are not derived even when propagation is enabled."""
2163 instance_id = "fs_local_instance_excl"
2164 genre = await genre_ctrl.add_item_to_library(_make_genre("PropExclGenre"))
2165 artist = await _add_test_artist(mass, "PropExcl Artist")
2166 album = await _add_test_album(mass, "PropExcl Album")
2167 track = await mass.music.tracks.add_item_to_library(
2168 Track(
2169 item_id="0",
2170 provider="library",
2171 name="PropExcl Track",
2172 provider_mappings=set(),
2173 artists=UniqueList([artist]),
2174 )
2175 )
2176 track_id = int(track.item_id)
2177 album_id = int(album.item_id)
2178 genre_id = int(genre.item_id)
2179
2180 await mass.music.database.insert(
2181 DB_TABLE_ALBUM_TRACKS,
2182 {"track_id": track_id, "album_id": album_id, "disc_number": 1, "track_number": 1},
2183 )
2184 await mass.music.database.insert(
2185 DB_TABLE_PROVIDER_MAPPINGS,
2186 {
2187 "media_type": "track",
2188 "item_id": track_id,
2189 "provider_domain": "filesystem_local",
2190 "provider_instance": instance_id,
2191 "provider_item_id": f"track_{track_id}",
2192 },
2193 )
2194 await mass.music.database.commit()
2195 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "PropExclGenre")
2196 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.ALBUM, album_id)
2197
2198 mock_provider = MagicMock()
2199 mock_provider.domain = "filesystem_local"
2200 mock_provider.instance_id = instance_id
2201 with (
2202 patch.object(
2203 type(mass.music),
2204 "providers",
2205 new_callable=PropertyMock,
2206 return_value=[mock_provider],
2207 ),
2208 patch.object(
2209 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2210 ),
2211 ):
2212 await genre_ctrl._propagate_genre_mappings_to_parents()
2213
2214 rows = await mass.music.database.get_rows_from_query(
2215 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2216 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album'",
2217 {"gid": genre_id, "mid": album_id},
2218 limit=0,
2219 )
2220 assert len(rows) == 0
2221
2222 async def test_propagation_disabled_removes_derived(
2223 self, mass: MusicAssistant, genre_ctrl: GenreController
2224 ) -> None:
2225 """Disabling propagation on all providers removes previously derived mappings."""
2226 instance_id = "fs_local_instance_disable"
2227 genre = await genre_ctrl.add_item_to_library(_make_genre("PropDisableGenre"))
2228 artist = await _add_test_artist(mass, "PropDisable Artist")
2229 album = await _add_test_album(mass, "PropDisable Album")
2230 track = await mass.music.tracks.add_item_to_library(
2231 Track(
2232 item_id="0",
2233 provider="library",
2234 name="PropDisable Track",
2235 provider_mappings=set(),
2236 artists=UniqueList([artist]),
2237 )
2238 )
2239 track_id = int(track.item_id)
2240 album_id = int(album.item_id)
2241 genre_id = int(genre.item_id)
2242
2243 await mass.music.database.insert(
2244 DB_TABLE_ALBUM_TRACKS,
2245 {"track_id": track_id, "album_id": album_id, "disc_number": 1, "track_number": 1},
2246 )
2247 await mass.music.database.insert(
2248 DB_TABLE_PROVIDER_MAPPINGS,
2249 {
2250 "media_type": "track",
2251 "item_id": track_id,
2252 "provider_domain": "filesystem_local",
2253 "provider_instance": instance_id,
2254 "provider_item_id": f"track_{track_id}",
2255 },
2256 )
2257 await mass.music.database.commit()
2258 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "PropDisableGenre")
2259
2260 mock_provider = MagicMock()
2261 mock_provider.domain = "filesystem_local"
2262 mock_provider.instance_id = instance_id
2263
2264 with (
2265 patch.object(
2266 type(mass.music),
2267 "providers",
2268 new_callable=PropertyMock,
2269 return_value=[mock_provider],
2270 ),
2271 patch.object(
2272 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2273 ),
2274 ):
2275 await genre_ctrl._propagate_genre_mappings_to_parents()
2276
2277 rows_after_enable = await mass.music.database.get_rows_from_query(
2278 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2279 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album' AND is_derived = 1",
2280 {"gid": genre_id, "mid": album_id},
2281 limit=0,
2282 )
2283 assert len(rows_after_enable) == 1
2284
2285 with (
2286 patch.object(
2287 type(mass.music),
2288 "providers",
2289 new_callable=PropertyMock,
2290 return_value=[mock_provider],
2291 ),
2292 patch.object(
2293 mass.config, "get_provider_config_value", new=AsyncMock(return_value=False)
2294 ),
2295 ):
2296 await genre_ctrl._propagate_genre_mappings_to_parents()
2297
2298 rows_after_disable = await mass.music.database.get_rows_from_query(
2299 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2300 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album' AND is_derived = 1",
2301 {"gid": genre_id, "mid": album_id},
2302 limit=0,
2303 )
2304 assert len(rows_after_disable) == 0
2305
2306 async def test_derived_mapping_replaced_by_direct_when_album_gains_genres(
2307 self, mass: MusicAssistant, genre_ctrl: GenreController
2308 ) -> None:
2309 """
2310 Derived mapping is replaced by direct when album gains own genre metadata.
2311
2312 Verifies the mapping becomes a direct one instead of leaving the album with no mapping.
2313 """
2314 instance_id = "fs_local_instance_transition"
2315 genre = await genre_ctrl.add_item_to_library(_make_genre("TransitionGenre"))
2316 artist = await _add_test_artist(mass, "Transition Artist")
2317 album = await _add_test_album(mass, "Transition Album")
2318 track = await mass.music.tracks.add_item_to_library(
2319 Track(
2320 item_id="0",
2321 provider="library",
2322 name="Transition Track",
2323 provider_mappings=set(),
2324 artists=UniqueList([artist]),
2325 )
2326 )
2327 track_id = int(track.item_id)
2328 album_id = int(album.item_id)
2329 genre_id = int(genre.item_id)
2330
2331 await mass.music.database.insert(
2332 DB_TABLE_ALBUM_TRACKS,
2333 {"track_id": track_id, "album_id": album_id, "disc_number": 1, "track_number": 1},
2334 )
2335 await mass.music.database.insert(
2336 DB_TABLE_PROVIDER_MAPPINGS,
2337 {
2338 "media_type": "track",
2339 "item_id": track_id,
2340 "provider_domain": "filesystem_local",
2341 "provider_instance": instance_id,
2342 "provider_item_id": f"track_{track_id}",
2343 },
2344 )
2345 await mass.music.database.commit()
2346 await _set_track_genres(mass, track_id, ["TransitionGenre"])
2347 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "TransitionGenre")
2348
2349 mock_provider = MagicMock()
2350 mock_provider.domain = "filesystem_local"
2351 mock_provider.instance_id = instance_id
2352
2353 # Step 1: album has no genres yet → propagation creates a derived mapping.
2354 with (
2355 patch.object(
2356 type(mass.music),
2357 "providers",
2358 new_callable=PropertyMock,
2359 return_value=[mock_provider],
2360 ),
2361 patch.object(
2362 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2363 ),
2364 ):
2365 await genre_ctrl._propagate_genre_mappings_to_parents()
2366
2367 derived_rows = await mass.music.database.get_rows_from_query(
2368 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2369 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album'",
2370 {"gid": genre_id, "mid": album_id},
2371 limit=0,
2372 )
2373 assert len(derived_rows) == 1
2374 assert derived_rows[0]["is_derived"] == 1
2375
2376 # Step 2: album gains its own genre metadata.
2377 await _set_album_genres(mass, album_id, ["TransitionGenre"])
2378
2379 # Step 3: incremental scan must replace the derived mapping with a direct one,
2380 # not leave the album with no mapping at all.
2381 with (
2382 patch.object(
2383 type(mass.music),
2384 "providers",
2385 new_callable=PropertyMock,
2386 return_value=[mock_provider],
2387 ),
2388 patch.object(
2389 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2390 ),
2391 ):
2392 await genre_ctrl._bulk_scan_unmapped_genres()
2393
2394 final_rows = await mass.music.database.get_rows_from_query(
2395 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2396 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album'",
2397 {"gid": genre_id, "mid": album_id},
2398 limit=0,
2399 )
2400 assert len(final_rows) == 1
2401 assert final_rows[0]["is_derived"] == 0
2402 assert final_rows[0]["alias"] == "TransitionGenre"
2403
2404
2405# ===================================================================
2406# Group N: Genre Media Counts (4 tests)
2407# ===================================================================
2408
2409
2410class TestGetGenreMediaCounts:
2411 """Tests for get_genre_media_counts."""
2412
2413 async def test_empty_ids_returns_empty(self, genre_ctrl: GenreController) -> None:
2414 """Empty input returns empty dict without hitting the database."""
2415 result = await genre_ctrl.get_genre_media_counts([])
2416 assert result == {}
2417
2418 async def test_all_media_types_present_with_zero_default(
2419 self, genre_ctrl: GenreController
2420 ) -> None:
2421 """Result contains every MEDIA_TABLES media type, defaulting to 0."""
2422 genre = await genre_ctrl.add_item_to_library(_make_genre("CountDefaults"))
2423 gid = genre.item_id
2424 result = await genre_ctrl.get_genre_media_counts([gid])
2425 assert gid in result
2426 expected_keys = {"track", "album", "artist", "playlist", "radio", "audiobook", "podcast"}
2427 assert set(result[gid].keys()) == expected_keys
2428 assert all(v == 0 for v in result[gid].values())
2429
2430 async def test_counts_track_mappings(
2431 self, mass: MusicAssistant, genre_ctrl: GenreController
2432 ) -> None:
2433 """Track mappings are reflected in the track count; other types remain 0."""
2434 genre = await genre_ctrl.add_item_to_library(_make_genre("CountTracks"))
2435 track1 = await _add_test_track(mass, "CountTrack1")
2436 track2 = await _add_test_track(mass, "CountTrack2")
2437 gid = genre.item_id
2438 await genre_ctrl.add_media_mapping(gid, MediaType.TRACK, track1.item_id, "CountTracks")
2439 await genre_ctrl.add_media_mapping(gid, MediaType.TRACK, track2.item_id, "CountTracks")
2440 result = await genre_ctrl.get_genre_media_counts([gid])
2441 assert result[gid]["track"] == 2
2442 assert result[gid]["album"] == 0
2443
2444 async def test_counts_multiple_genres_independently(
2445 self, mass: MusicAssistant, genre_ctrl: GenreController
2446 ) -> None:
2447 """Counts for multiple genre IDs are computed independently."""
2448 g1 = await genre_ctrl.add_item_to_library(_make_genre("MultiCountA"))
2449 g2 = await genre_ctrl.add_item_to_library(_make_genre("MultiCountB"))
2450 track = await _add_test_track(mass, "MultiCount Track")
2451 album = await _add_test_album(mass, "MultiCount Album")
2452 await genre_ctrl.add_media_mapping(
2453 g1.item_id, MediaType.TRACK, track.item_id, "MultiCountA"
2454 )
2455 await genre_ctrl.add_media_mapping(
2456 g2.item_id, MediaType.ALBUM, album.item_id, "MultiCountB"
2457 )
2458 result = await genre_ctrl.get_genre_media_counts([g1.item_id, g2.item_id])
2459 assert result[g1.item_id]["track"] == 1
2460 assert result[g1.item_id]["album"] == 0
2461 assert result[g2.item_id]["album"] == 1
2462 assert result[g2.item_id]["track"] == 0
2463
2464
2465# ===================================================================
2466# Group O: Global Genre Exclusion (9 tests)
2467# ===================================================================
2468
2469# Two distinct default entries with a translation_key for the tests below.
2470_tk_entries = [e for e in DEFAULT_GENRE_MAPPING if e.get("translation_key")]
2471assert len(_tk_entries) >= 2, (
2472 "DEFAULT_GENRE_MAPPING must contain at least two entries with a translation_key "
2473 "for global genre exclusion tests"
2474)
2475_DEFAULT_ENTRY_A = _tk_entries[0] # used for deletion-only test
2476_DEFAULT_ENTRY_B = _tk_entries[1] # used for delete-then-restore test
2477
2478
2479class TestGlobalGenreExclusion:
2480 """Tests for the global genre exclusion API and scanner guard."""
2481
2482 async def test_delete_sets_is_excluded_flag(
2483 self, mass: MusicAssistant, genre_ctrl: GenreController
2484 ) -> None:
2485 """remove_item_from_library sets is_excluded = 1 on the genre row."""
2486 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExcl1"))
2487 genre_id = int(genre.item_id)
2488 await genre_ctrl.remove_item_from_library(genre_id)
2489 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
2490 assert row is not None
2491 assert row["is_excluded"] == 1
2492
2493 async def test_get_exclusions_lists_deleted_genre(
2494 self, mass: MusicAssistant, genre_ctrl: GenreController
2495 ) -> None:
2496 """get_global_genre_exclusions includes a genre after it is deleted."""
2497 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExcl2"))
2498 await genre_ctrl.remove_item_from_library(int(genre.item_id))
2499 exclusions = await genre_ctrl.get_global_genre_exclusions()
2500 names = {e["name"] for e in exclusions}
2501 assert "GblExcl2" in names
2502
2503 async def test_default_genre_exclusion_preserves_translation_key(
2504 self, mass: MusicAssistant, genre_ctrl: GenreController
2505 ) -> None:
2506 """Excluding a default genre preserves its translation_key on the row."""
2507 tk = _DEFAULT_ENTRY_A["translation_key"]
2508 search_name = create_safe_string(_DEFAULT_ENTRY_A["genre"], True, True)
2509 db_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"search_name": search_name})
2510 assert db_row is not None, "default genre must be seeded at startup"
2511 await genre_ctrl.remove_item_from_library(int(db_row["item_id"]))
2512 excl_row = await mass.music.database.get_row(
2513 DB_TABLE_GENRES, {"item_id": int(db_row["item_id"]), "is_excluded": 1}
2514 )
2515 assert excl_row is not None
2516 assert excl_row["translation_key"] == tk
2517
2518 async def test_scanner_guard_blocks_excluded_name(self, genre_ctrl: GenreController) -> None:
2519 """_find_genres_for_alias returns [] for a globally excluded genre name."""
2520 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExclScan"))
2521 await genre_ctrl.remove_item_from_library(int(genre.item_id))
2522 result = await genre_ctrl._find_genres_for_alias("GblExclScan", None)
2523 assert result == []
2524
2525 async def test_restore_custom_genre_is_not_default(
2526 self, mass: MusicAssistant, genre_ctrl: GenreController
2527 ) -> None:
2528 """Restoring a custom (non-default) genre leaves is_default as 0."""
2529 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExclCustom"))
2530 genre_id = int(genre.item_id)
2531 await genre_ctrl.remove_item_from_library(genre_id)
2532 restored = await genre_ctrl.remove_global_genre_exclusion(genre_id)
2533 db_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
2534 assert db_row is not None
2535 assert db_row["is_excluded"] == 0
2536 assert db_row["is_default"] == 0
2537 assert int(restored.item_id) == genre_id
2538
2539 async def test_restore_default_genre_translation_key_preserved(
2540 self, mass: MusicAssistant, genre_ctrl: GenreController
2541 ) -> None:
2542 """Restoring a default genre retains the correct translation_key (row was never deleted)."""
2543 tk = _DEFAULT_ENTRY_B["translation_key"]
2544 search_name = create_safe_string(_DEFAULT_ENTRY_B["genre"], True, True)
2545 db_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"search_name": search_name})
2546 assert db_row is not None, "default genre must be seeded at startup"
2547 genre_id = int(db_row["item_id"])
2548 await genre_ctrl.remove_item_from_library(genre_id)
2549 await genre_ctrl.remove_global_genre_exclusion(genre_id)
2550 restored_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
2551 assert restored_row is not None
2552 assert restored_row["is_excluded"] == 0
2553 assert restored_row["translation_key"] == tk
2554
2555 async def test_restore_clears_exclusion_flag(
2556 self, mass: MusicAssistant, genre_ctrl: GenreController
2557 ) -> None:
2558 """is_excluded is set back to 0 after a successful restore."""
2559 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExclClean"))
2560 genre_id = int(genre.item_id)
2561 await genre_ctrl.remove_item_from_library(genre_id)
2562 await genre_ctrl.remove_global_genre_exclusion(genre_id)
2563 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
2564 assert row is not None
2565 assert row["is_excluded"] == 0
2566
2567 async def test_restore_nonexistent_raises_key_error(self, genre_ctrl: GenreController) -> None:
2568 """remove_global_genre_exclusion raises KeyError for an unknown genre id."""
2569 with pytest.raises(KeyError):
2570 await genre_ctrl.remove_global_genre_exclusion(999_999_999)
2571
2572 async def test_merge_does_not_exclude_source_genre(
2573 self, mass: MusicAssistant, genre_ctrl: GenreController
2574 ) -> None:
2575 """merge_genres hard-deletes the source: it must not appear in the exclusion list."""
2576 source = await genre_ctrl.add_item_to_library(_make_genre("GblMergeSource"))
2577 target = await genre_ctrl.add_item_to_library(_make_genre("GblMergeTarget"))
2578 await genre_ctrl.merge_genres([source.item_id], target.item_id)
2579 exclusions = await genre_ctrl.get_global_genre_exclusions()
2580 names = {e["name"] for e in exclusions}
2581 assert "GblMergeSource" not in names
2582
2583
2584class TestGenreContentTypeNamespacing:
2585 """Genre resolution and mappings are scoped per content_type taxonomy (2b)."""
2586
2587 async def test_find_genres_for_alias_namespaces_by_content_type(
2588 self, genre_ctrl: GenreController
2589 ) -> None:
2590 """The same name resolves to a distinct genre per taxonomy, each tagged correctly."""
2591 music = await genre_ctrl._find_genres_for_alias("Comedy", None)
2592 podcast = await genre_ctrl._find_genres_for_alias("Comedy", MediaType.PODCAST)
2593 audiobook = await genre_ctrl._find_genres_for_alias("Comedy", MediaType.AUDIOBOOK)
2594
2595 assert len({music[0], podcast[0], audiobook[0]}) == 3
2596 assert (await genre_ctrl.get_library_item(music[0])).content_type is None
2597 assert (await genre_ctrl.get_library_item(podcast[0])).content_type is MediaType.PODCAST
2598 assert (await genre_ctrl.get_library_item(audiobook[0])).content_type is MediaType.AUDIOBOOK
2599
2600 async def test_find_genres_for_alias_does_not_cross_namespaces(
2601 self, genre_ctrl: GenreController
2602 ) -> None:
2603 """A lookup in one taxonomy never returns a genre that lives in another."""
2604 music = await genre_ctrl._find_genres_for_alias("Zzklezmertest", None)
2605 podcast = await genre_ctrl._find_genres_for_alias("Zzklezmertest", MediaType.PODCAST)
2606 assert music[0] not in podcast
2607 assert (await genre_ctrl.get_library_item(music[0])).content_type is None
2608
2609 async def test_scanner_buckets_genres_by_content_type(
2610 self, mass: MusicAssistant, genre_ctrl: GenreController
2611 ) -> None:
2612 """The scanner files a track's and a podcast's same-named genre as distinct entities."""
2613 track = await _add_test_track(mass, "NsTrack")
2614 await _set_track_genres(mass, int(track.item_id), ["Zmystery9"])
2615 podcast = await _add_test_podcast(mass, "NsPodcast")
2616 await _set_podcast_genres(mass, int(podcast.item_id), ["Zmystery9"])
2617
2618 await genre_ctrl._bulk_scan_unmapped_genres()
2619
2620 rows = await mass.music.database.get_rows_from_query(
2621 f"SELECT item_id, content_type FROM {DB_TABLE_GENRES} WHERE search_name = :sn",
2622 {"sn": create_safe_string("Zmystery9", True, True)},
2623 limit=0,
2624 )
2625 by_content_type = {row["content_type"]: int(row["item_id"]) for row in rows}
2626 # one music genre (NULL) and one podcast genre exist for the same name
2627 assert None in by_content_type
2628 assert MediaType.PODCAST.value in by_content_type
2629 # the podcast maps to the podcast-namespace genre, never the music one
2630 pod_maps = await mass.music.database.get_rows_from_query(
2631 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2632 "WHERE media_type = :mt AND media_id = :mid",
2633 {"mt": MediaType.PODCAST.value, "mid": int(podcast.item_id)},
2634 limit=0,
2635 )
2636 assert {int(r["genre_id"]) for r in pod_maps} == {by_content_type[MediaType.PODCAST.value]}
2637
2638 async def test_cleanup_rehomes_legacy_cross_namespace_mapping(
2639 self, mass: MusicAssistant, genre_ctrl: GenreController
2640 ) -> None:
2641 """Cleanup removes a legacy podcast→music-genre mapping so the item can re-home."""
2642 podcast = await _add_test_podcast(mass, "RehomePod")
2643 await _set_podcast_genres(mass, int(podcast.item_id), ["Spoken Word"])
2644 # simulate the pre-namespacing state: the podcast mapped to a music (NULL) genre
2645 music_genre = await genre_ctrl._find_genres_for_alias("Spoken Word", None)
2646 await mass.music.database.insert(
2647 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
2648 {
2649 "genre_id": music_genre[0],
2650 "media_id": int(podcast.item_id),
2651 "media_type": MediaType.PODCAST.value,
2652 "alias": "Spoken Word",
2653 "is_derived": 0,
2654 "is_manual": 0,
2655 },
2656 )
2657
2658 await genre_ctrl._cleanup_stale_genre_mappings()
2659
2660 remaining = await mass.music.database.get_rows_from_query(
2661 f"SELECT 1 FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2662 "WHERE media_type = :mt AND media_id = :mid AND genre_id = :gid",
2663 {
2664 "mt": MediaType.PODCAST.value,
2665 "mid": int(podcast.item_id),
2666 "gid": music_genre[0],
2667 },
2668 limit=0,
2669 )
2670 assert remaining == []
2671
2672 async def test_soft_delete_restore_scoped_by_content_type(
2673 self, genre_ctrl: GenreController
2674 ) -> None:
2675 """
2676 A re-add restores only a soft-deleted genre of the same taxonomy.
2677
2678 A soft-deleted podcast "Comedy" must not be revived (and re-tagged) when a music
2679 "Comedy" is added; the music genre is inserted as a distinct, new row.
2680 """
2681 podcast = await genre_ctrl.add_item_to_library(
2682 Genre(
2683 item_id="0",
2684 provider="library",
2685 name="ScopedComedy",
2686 provider_mappings=set(),
2687 content_type=MediaType.PODCAST,
2688 )
2689 )
2690 await genre_ctrl.mass.music.database.update(
2691 DB_TABLE_GENRES, {"item_id": int(podcast.item_id)}, {"is_excluded": 1}
2692 )
2693
2694 music = await genre_ctrl.add_item_to_library(_make_genre("ScopedComedy"))
2695
2696 assert int(music.item_id) != int(podcast.item_id)
2697 assert music.content_type is None
2698 # the podcast row is left untouched (still soft-deleted)
2699 pod_rows = await genre_ctrl.mass.music.database.get_rows_from_query(
2700 f"SELECT is_excluded FROM {DB_TABLE_GENRES} WHERE item_id = :id",
2701 {"id": int(podcast.item_id)},
2702 limit=1,
2703 )
2704 assert pod_rows[0]["is_excluded"] == 1
2705
2706 # re-adding within the same taxonomy DOES restore the soft-deleted row
2707 revived = await genre_ctrl.add_item_to_library(
2708 Genre(
2709 item_id="0",
2710 provider="library",
2711 name="ScopedComedy",
2712 provider_mappings=set(),
2713 content_type=MediaType.PODCAST,
2714 )
2715 )
2716 assert int(revived.item_id) == int(podcast.item_id)
2717
2718
2719class TestDefaultTaxonomySeeding:
2720 """restore_default_genres seeds curated music, podcast, and audiobook taxonomies (2c)."""
2721
2722 async def test_full_restore_seeds_every_taxonomy(
2723 self, mass: MusicAssistant, genre_ctrl: GenreController
2724 ) -> None:
2725 """Full restore seeds the curated default genres of all three taxonomies."""
2726 await genre_ctrl.restore_default_genres(full_restore=True)
2727 rows = await mass.music.database.get_rows_from_query(
2728 f"SELECT content_type, COUNT(*) AS cnt FROM {DB_TABLE_GENRES} "
2729 "WHERE is_default = 1 GROUP BY content_type",
2730 limit=0,
2731 )
2732 counts = {row["content_type"]: row["cnt"] for row in rows}
2733 assert counts.get(None) == len(DEFAULT_GENRE_MAPPING)
2734 assert counts.get(MediaType.PODCAST.value) == len(DEFAULT_PODCAST_GENRE_MAPPING)
2735 assert counts.get(MediaType.AUDIOBOOK.value) == len(DEFAULT_AUDIOBOOK_GENRE_MAPPING)
2736
2737 async def test_curated_genre_carries_translation_key_and_content_type(
2738 self, genre_ctrl: GenreController
2739 ) -> None:
2740 """A seeded podcast default (True Crime) lands in the podcast namespace with its key."""
2741 await genre_ctrl.restore_default_genres(full_restore=True)
2742 items = await genre_ctrl.library_items(search="True Crime", hide_empty=False)
2743 match = next((g for g in items if g.content_type is MediaType.PODCAST), None)
2744 assert match is not None
2745 assert match.translation_key == "true_crime"
2746
2747 async def test_same_name_distinct_per_taxonomy(
2748 self, mass: MusicAssistant, genre_ctrl: GenreController
2749 ) -> None:
2750 """A name in both spoken-word lists (History) yields one genre per taxonomy."""
2751 await genre_ctrl.restore_default_genres(full_restore=True)
2752 rows = await mass.music.database.get_rows_from_query(
2753 f"SELECT content_type FROM {DB_TABLE_GENRES} WHERE search_name = :sn",
2754 {"sn": create_safe_string("History", True, True)},
2755 limit=0,
2756 )
2757 content_types = {row["content_type"] for row in rows}
2758 assert MediaType.PODCAST.value in content_types
2759 assert MediaType.AUDIOBOOK.value in content_types
2760
2761 async def test_partial_restore_is_idempotent(
2762 self, mass: MusicAssistant, genre_ctrl: GenreController
2763 ) -> None:
2764 """Re-running a partial restore does not create duplicate genres."""
2765 await genre_ctrl.restore_default_genres(full_restore=False)
2766 before = await mass.music.database.get_count(DB_TABLE_GENRES)
2767 await genre_ctrl.restore_default_genres(full_restore=False)
2768 after = await mass.music.database.get_count(DB_TABLE_GENRES)
2769 assert before == after
2770
2771 async def test_content_type_filter_composes_with_hide_empty(
2772 self, genre_ctrl: GenreController
2773 ) -> None:
2774 """content_type narrows library_items to a taxonomy and composes with hide_empty."""
2775 await genre_ctrl.restore_default_genres(full_restore=True)
2776 # default-only (hide_empty=None) within the podcast taxonomy = the curated podcast defaults
2777 podcast_defaults = await genre_ctrl.library_items(
2778 content_type="podcast", hide_empty=None, limit=0
2779 )
2780 assert len(podcast_defaults) == len(DEFAULT_PODCAST_GENRE_MAPPING)
2781 assert all(g.content_type is MediaType.PODCAST for g in podcast_defaults)
2782
2783 async def test_targeted_restore_seeds_only_requested_taxonomy(
2784 self, mass: MusicAssistant, genre_ctrl: GenreController
2785 ) -> None:
2786 """A non-destructive restore scoped to one taxonomy touches only that taxonomy."""
2787
2788 async def default_counts() -> dict[str | None, int]:
2789 rows = await mass.music.database.get_rows_from_query(
2790 f"SELECT content_type, COUNT(*) AS cnt FROM {DB_TABLE_GENRES} "
2791 "WHERE is_default = 1 GROUP BY content_type",
2792 limit=0,
2793 )
2794 return {row["content_type"]: row["cnt"] for row in rows}
2795
2796 # establish a deterministic, fully-seeded baseline, then drop one podcast default
2797 await genre_ctrl.restore_default_genres(full_restore=True)
2798 before = await default_counts()
2799 victim = await mass.music.database.get_rows_from_query(
2800 f"SELECT item_id FROM {DB_TABLE_GENRES} WHERE content_type = :ct AND is_default = 1",
2801 {"ct": MediaType.PODCAST.value},
2802 limit=1,
2803 )
2804 await mass.music.database.delete(DB_TABLE_GENRES, {"item_id": victim[0]["item_id"]})
2805
2806 created = await genre_ctrl.restore_default_genres(
2807 full_restore=False, content_type="podcast"
2808 )
2809 assert created
2810 assert all(g.content_type is MediaType.PODCAST for g in created)
2811
2812 after = await default_counts()
2813 # the podcast default was restored, the other taxonomies were left untouched
2814 assert after[MediaType.PODCAST.value] == before[MediaType.PODCAST.value]
2815 assert after.get(None) == before.get(None)
2816 assert after.get(MediaType.AUDIOBOOK.value) == before.get(MediaType.AUDIOBOOK.value)
2817
2818 async def test_targeted_restore_unknown_taxonomy_raises(
2819 self, genre_ctrl: GenreController
2820 ) -> None:
2821 """An unrecognised taxonomy is rejected rather than silently restoring nothing."""
2822 with pytest.raises(ValueError, match="Unknown genre taxonomy"):
2823 await genre_ctrl.restore_default_genres(full_restore=False, content_type="bogus")
2824 # show-all within the taxonomy never leaks genres from another taxonomy
2825 podcast_all = await genre_ctrl.library_items(
2826 content_type="podcast", hide_empty=False, limit=0
2827 )
2828 assert podcast_all
2829 assert all(g.content_type is MediaType.PODCAST for g in podcast_all)
2830
2831 async def test_content_type_music_filter(self, genre_ctrl: GenreController) -> None:
2832 """content_type="music" returns only the music/general (NULL) taxonomy genres."""
2833 await genre_ctrl.restore_default_genres(full_restore=True)
2834 music = await genre_ctrl.library_items(content_type="music", hide_empty=False, limit=0)
2835 assert music
2836 assert all(g.content_type is None for g in music)
2837 # spoken-word genres are distinct entities and never appear in the music taxonomy
2838 podcast = await genre_ctrl.library_items(content_type="podcast", hide_empty=False, limit=0)
2839 music_ids = {g.item_id for g in music}
2840 assert not any(g.item_id in music_ids for g in podcast)
2841
2842
2843class TestGenreIconMetadata:
2844 """_get_genre_icon_metadata prefers a taxonomy subfolder icon, falling back to flat."""
2845
2846 @staticmethod
2847 def _make_icons(tmp_path: Path, *rel_paths: str) -> None:
2848 for rel in rel_paths:
2849 icon = tmp_path / "genres" / rel
2850 icon.parent.mkdir(parents=True, exist_ok=True)
2851 icon.write_text("<svg/>")
2852
2853 def test_subfolder_icon_preferred(self, tmp_path: Path, monkeypatch: Any) -> None:
2854 """A taxonomy-specific icon wins over the flat one."""
2855 self._make_icons(tmp_path, "history.svg", "podcast/history.svg")
2856 monkeypatch.setattr(
2857 "music_assistant.controllers.music.media.genres.RESOURCES_DIR", tmp_path
2858 )
2859 md = GenreController._get_genre_icon_metadata("history", MediaType.PODCAST)
2860 assert md is not None
2861 assert md.images is not None
2862 assert md.images[0].path == "genres/podcast/history.svg"
2863
2864 def test_falls_back_to_flat(self, tmp_path: Path, monkeypatch: Any) -> None:
2865 """Without a taxonomy override, the flat/shared icon is used."""
2866 self._make_icons(tmp_path, "history.svg")
2867 monkeypatch.setattr(
2868 "music_assistant.controllers.music.media.genres.RESOURCES_DIR", tmp_path
2869 )
2870 md = GenreController._get_genre_icon_metadata("history", MediaType.PODCAST)
2871 assert md is not None
2872 assert md.images is not None
2873 assert md.images[0].path == "genres/history.svg"
2874
2875 def test_music_uses_flat(self, tmp_path: Path, monkeypatch: Any) -> None:
2876 """Music genres (content_type None) resolve to the flat path."""
2877 self._make_icons(tmp_path, "blues.svg")
2878 monkeypatch.setattr(
2879 "music_assistant.controllers.music.media.genres.RESOURCES_DIR", tmp_path
2880 )
2881 md = GenreController._get_genre_icon_metadata("blues", None)
2882 assert md is not None
2883 assert md.images is not None
2884 assert md.images[0].path == "genres/blues.svg"
2885
2886 def test_missing_icon_returns_none(self, tmp_path: Path, monkeypatch: Any) -> None:
2887 """No matching SVG (subfolder or flat) yields no metadata."""
2888 self._make_icons(tmp_path)
2889 monkeypatch.setattr(
2890 "music_assistant.controllers.music.media.genres.RESOURCES_DIR", tmp_path
2891 )
2892 assert GenreController._get_genre_icon_metadata("nope", MediaType.PODCAST) is None
2893