music-assistant-server

130.3 KBPY
test_genres.py
130.3 KB2,893 lines • python
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