Feat/genres-v2-implementation (#3164)
authorJozef Kruszynski <60214390+jozefKruszynski@users.noreply.github.com>
Fri, 20 Feb 2026 17:04:41 +0000 (18:04 +0100)
committerGitHub <noreply@github.com>
Fri, 20 Feb 2026 17:04:41 +0000 (18:04 +0100)
* feat(genres): Core genre system

* feat(genres): add background scanner for metadata.genres

* refactor(genres): add genres to base

* test(genres): add test suite

* fix(genres): eixsting genres are not recreated at migration

* feat(genres): guard against infinite loops in scanner

* fix(genres): remove duplicate, improve tests

* fix(genres): limit genre tracks return

* refactor(genres): use logger rather than print

* refactor(genres): use asyncio.gather rather than sequential calls

* refactor(genres): various fixes after review comments

* refactor(genres): randomize selection, use asyncio gether

* refactor(genres): remove alias object

* fix(genres): fix n:n relationship, safeguard comparisons

* refactor(genres): address review comments

* fix(genres): fix issues after rebase

* test(genres): add missing param to library sync tests

17 files changed:
music_assistant/constants.py
music_assistant/controllers/media/albums.py
music_assistant/controllers/media/artists.py
music_assistant/controllers/media/audiobooks.py
music_assistant/controllers/media/base.py
music_assistant/controllers/media/genres.py
music_assistant/controllers/media/podcasts.py
music_assistant/controllers/media/tracks.py
music_assistant/controllers/music.py
music_assistant/controllers/player_queues.py
music_assistant/helpers/resources/genre_mapping.json [new file with mode: 0644]
music_assistant/models/music_provider.py
music_assistant/providers/test/__init__.py
pyproject.toml
tests/core/test_genre_helpers.py [new file with mode: 0644]
tests/core/test_genres.py [new file with mode: 0644]
tests/test_library_sync.py

index 761ca953bebd8d05e22219d659b70eb695d4ae6a..b477b9e5aced02d64ac01ffd4ee10059726f4a89 100644 (file)
@@ -1,8 +1,9 @@
 """All constants for Music Assistant."""
 
+import json
 import pathlib
 from copy import deepcopy
-from typing import Final, cast
+from typing import Any, Final, cast
 
 from music_assistant_models.config_entries import (
     MULTI_VALUE_SPLITTER,
@@ -26,8 +27,8 @@ PLAYLIST_MEDIA_TYPES: Final[tuple[MediaType, ...]] = (
 )
 
 
-API_SCHEMA_VERSION: Final[int] = 28
-MIN_SCHEMA_VERSION: Final[int] = 28
+API_SCHEMA_VERSION: Final[int] = 29
+MIN_SCHEMA_VERSION: Final[int] = 29
 
 
 MASS_LOGGER_NAME: Final[str] = "music_assistant"
@@ -44,6 +45,7 @@ VARIOUS_ARTISTS_MBID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377"
 RESOURCES_DIR: Final[pathlib.Path] = (
     pathlib.Path(__file__).parent.resolve().joinpath("helpers/resources")
 )
+GENRE_MAPPING_FILE: Final[pathlib.Path] = RESOURCES_DIR.joinpath("genre_mapping.json")
 
 ANNOUNCE_ALERT_FILE: Final[str] = str(RESOURCES_DIR.joinpath("announce.mp3"))
 SILENCE_FILE: Final[str] = str(RESOURCES_DIR.joinpath("silence.mp3"))
@@ -146,6 +148,47 @@ DB_TABLE_TRACK_ARTISTS: Final[str] = "track_artists"
 DB_TABLE_ALBUM_ARTISTS: Final[str] = "album_artists"
 DB_TABLE_LOUDNESS_MEASUREMENTS: Final[str] = "loudness_measurements"
 DB_TABLE_SMART_FADES_ANALYSIS: Final[str] = "smart_fades_analysis"
+DB_TABLE_GENRES: Final[str] = "genres"
+DB_TABLE_GENRE_MEDIA_ITEM_MAPPING: Final[str] = "genre_media_item_mapping"
+
+
+def load_genre_mapping() -> list[dict[str, Any]]:
+    """Load default genre mapping from JSON file.
+
+    :return: List of genre mapping dictionaries with 'genre' and 'aliases' keys.
+    :raises FileNotFoundError: If genre_mapping.json is missing.
+    :raises ValueError: If JSON is malformed or missing required fields.
+    """
+    try:
+        content = GENRE_MAPPING_FILE.read_text(encoding="utf-8")
+        data = json.loads(content)
+    except FileNotFoundError as err:
+        msg = f"Genre mapping file not found: {GENRE_MAPPING_FILE}"
+        raise FileNotFoundError(msg) from err
+    except json.JSONDecodeError as err:
+        msg = f"Invalid JSON in genre mapping file: {GENRE_MAPPING_FILE}"
+        raise ValueError(msg) from err
+
+    if not isinstance(data, list):
+        msg = f"Genre mapping must be a list, got {type(data).__name__}"
+        raise TypeError(msg)
+
+    for idx, entry in enumerate(data):
+        if not isinstance(entry, dict):
+            msg = f"Genre mapping entry {idx} must be a dict, got {type(entry).__name__}"
+            raise TypeError(msg)
+        if "genre" not in entry:
+            msg = f"Genre mapping entry {idx} missing required field 'genre'"
+            raise ValueError(msg)
+        if "aliases" not in entry:
+            msg = f"Genre mapping entry {idx} missing required field 'aliases'"
+            raise ValueError(msg)
+
+    return cast("list[dict[str, Any]]", data)
+
+
+DEFAULT_GENRE_MAPPING: Final[list[dict[str, Any]]] = load_genre_mapping()
+DEFAULT_GENRES: Final[tuple[str, ...]] = tuple(entry["genre"] for entry in DEFAULT_GENRE_MAPPING)
 
 
 # all other
index 4df52f8a0f65ba0dad16261ef8727a04e289c155..eb65e0a68b8675cc1018f6b0da52a236cdcaf17a 100644 (file)
@@ -113,6 +113,7 @@ class AlbumsController(MediaControllerBase[Album]):
         offset: int = 0,
         order_by: str = "sort_name",
         provider: str | list[str] | None = None,
+        genre: int | list[int] | None = None,
         album_types: list[AlbumType] | None = None,
         **kwargs: Any,
     ) -> list[Album]:
@@ -125,6 +126,7 @@ class AlbumsController(MediaControllerBase[Album]):
         :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
         :param provider: Filter by provider instance ID (single string or list).
         :param album_types: Filter by album types.
+        :param genre: Filter by genre id(s).
         """
         extra_query_params: dict[str, Any] = {}
         extra_query_parts: list[str] = []
@@ -162,6 +164,7 @@ class AlbumsController(MediaControllerBase[Album]):
         result = await self.get_library_items_by_query(
             favorite=favorite,
             search=search,
+            genre_ids=genre,
             limit=limit,
             offset=offset,
             order_by=order_by,
index 9175e3b38b5c55eccc6f6f6d9ed9dff9eaccaa21..62a93514e3bd6828f4e3c0ef9f4cb59acfcd03bb 100644 (file)
@@ -72,6 +72,7 @@ class ArtistsController(MediaControllerBase[Artist]):
         offset: int = 0,
         order_by: str = "sort_name",
         provider: str | list[str] | None = None,
+        genre: int | list[int] | None = None,
         album_artists_only: bool = False,
         **kwargs: Any,
     ) -> list[Artist]:
@@ -84,6 +85,7 @@ class ArtistsController(MediaControllerBase[Artist]):
         :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
         :param provider: Filter by provider instance ID (single string or list).
         :param album_artists_only: Only return artists that have albums.
+        :param genre: Filter by genre id(s).
         """
         extra_query_params: dict[str, Any] = {}
         extra_query_parts: list[str] = []
@@ -95,6 +97,7 @@ class ArtistsController(MediaControllerBase[Artist]):
         return await self.get_library_items_by_query(
             favorite=favorite,
             search=search,
+            genre_ids=genre,
             limit=limit,
             offset=offset,
             order_by=order_by,
index c7d71c5242859203589b26071df30f1b68f33c61..ad2cc4765ebda33a3a79ac0b5cd5a5192e625765 100644 (file)
@@ -69,6 +69,7 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
         offset: int = 0,
         order_by: str = "sort_name",
         provider: str | list[str] | None = None,
+        genre: int | list[int] | None = None,
         **kwargs: Any,
     ) -> list[Audiobook]:
         """Get in-database audiobooks.
@@ -79,12 +80,14 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
         :param offset: Number of items to skip.
         :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
         :param provider: Filter by provider instance ID (single string or list).
+        :param genre: Filter by genre id(s).
         """
         extra_query_params: dict[str, Any] = {}
         extra_query_parts: list[str] = []
         result = await self.get_library_items_by_query(
             favorite=favorite,
             search=search,
+            genre_ids=genre,
             limit=limit,
             offset=offset,
             order_by=order_by,
@@ -102,6 +105,7 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
             return result + await self.get_library_items_by_query(
                 favorite=favorite,
                 search=None,
+                genre_ids=genre,
                 limit=limit,
                 order_by=order_by,
                 provider_filter=self._ensure_provider_filter(provider),
index f14f1da56a89a49bb7323a709ae1ab38ee65177b..f07b3cb92f5d565fd6500ca23976157bee883ca6 100644 (file)
@@ -24,7 +24,12 @@ from music_assistant_models.media_items import (
     Track,
 )
 
-from music_assistant.constants import DB_TABLE_PLAYLOG, DB_TABLE_PROVIDER_MAPPINGS, MASS_LOGGER_NAME
+from music_assistant.constants import (
+    DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
+    DB_TABLE_PLAYLOG,
+    DB_TABLE_PROVIDER_MAPPINGS,
+    MASS_LOGGER_NAME,
+)
 from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
 from music_assistant.helpers.compare import compare_media_item, create_safe_string
 from music_assistant.helpers.database import UNSET
@@ -49,6 +54,7 @@ JSON_KEYS = (
     "external_ids",
     "narrators",
     "authors",
+    "genre_aliases",
 )
 
 SORT_KEYS = {
@@ -250,6 +256,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         offset: int = 0,
         order_by: str = "sort_name",
         provider: str | list[str] | None = None,
+        genre: int | list[int] | None = None,
         **kwargs: Any,
     ) -> list[ItemCls]:
         """
@@ -261,6 +268,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         :param offset: Number of items to skip.
         :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
         :param provider: Filter by provider instance ID (single string or list).
+        :param genre: Filter by genre id(s).
         """
         return await self.get_library_items_by_query(
             favorite=favorite,
@@ -269,6 +277,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             offset=offset,
             order_by=order_by,
             provider_filter=self._ensure_provider_filter(provider),
+            genre_ids=genre,
             in_library_only=True,
         )
 
@@ -278,6 +287,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         search: str | None = None,
         order_by: str = "sort_name",
         provider: str | list[str] | None = None,
+        genre: int | list[int] | None = None,
         library_items_only: bool = True,
     ) -> AsyncGenerator[ItemCls, None]:
         """Iterate all in-database items."""
@@ -291,6 +301,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             next_items = await self.get_library_items_by_query(
                 favorite=favorite,
                 search=search,
+                genre_ids=genre,
                 limit=limit,
                 offset=offset,
                 order_by=order_by,
@@ -852,7 +863,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         """
 
     @final
-    async def get_library_items_by_query(
+    async def get_library_items_by_query(  # noqa: PLR0913
         self,
         favorite: bool | None = None,
         search: str | None = None,
@@ -863,6 +874,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         extra_query_parts: list[str] | None = None,
         extra_query_params: dict[str, Any] | None = None,
         extra_join_parts: list[str] | None = None,
+        genre_ids: int | list[int] | None = None,
         in_library_only: bool = False,
     ) -> list[ItemCls]:
         """Fetch MediaItem records from database by building the query."""
@@ -870,6 +882,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         query_parts: list[str] = list(extra_query_parts) if extra_query_parts else []
         join_parts: list[str] = list(extra_join_parts) if extra_join_parts else []
         search = self._preprocess_search(search, query_params)
+        genre_ids = self._preprocess_genre_ids(genre_ids)
         # create special performant random query
         if order_by and order_by.startswith("random"):
             self._apply_random_subquery(
@@ -878,6 +891,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                 join_parts=join_parts,
                 favorite=favorite,
                 search=search,
+                genre_ids=genre_ids,
                 provider_filter=provider_filter,
                 limit=limit,
                 in_library_only=in_library_only,
@@ -890,6 +904,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                 join_parts=join_parts,
                 favorite=favorite,
                 search=search,
+                genre_ids=genre_ids,
                 provider_filter=provider_filter,
                 in_library_only=in_library_only,
             )
@@ -903,6 +918,11 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             )
         ]
 
+    @property
+    def _search_filter_clause(self) -> str:
+        """Return the SQL WHERE clause fragment used for search filtering."""
+        return f"{self.db_table}.search_name LIKE :search"
+
     @final
     def _preprocess_search(self, search: str | None, query_params: dict[str, Any]) -> str | None:
         """Preprocess search string and add to query params."""
@@ -911,6 +931,17 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             query_params["search"] = f"%{search}%"
         return search
 
+    @final
+    @staticmethod
+    def _preprocess_genre_ids(genre_ids: int | list[int] | None) -> list[int] | None:
+        if genre_ids is None:
+            return None
+        if isinstance(genre_ids, list):
+            normalized = [int(x) for x in genre_ids]
+        else:
+            normalized = [int(genre_ids)]
+        return normalized or None
+
     @final
     @staticmethod
     def _clean_query_parts(query_parts: list[str]) -> list[str]:
@@ -925,6 +956,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         join_parts: list[str],
         favorite: bool | None,
         search: str | None,
+        genre_ids: list[int] | None,
         provider_filter: list[str] | None,
         limit: int,
         in_library_only: bool = False,
@@ -940,6 +972,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             join_parts=sub_join_parts,
             favorite=favorite,
             search=search,
+            genre_ids=genre_ids,
             provider_filter=provider_filter,
             in_library_only=in_library_only,
         )
@@ -969,17 +1002,29 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         join_parts: list[str],
         favorite: bool | None,
         search: str | None,
+        genre_ids: list[int] | None,
         provider_filter: list[str] | None,
         in_library_only: bool = False,
     ) -> None:
         """Apply search, favorite, and provider filters."""
         # handle search
         if search:
-            query_parts.append(f"{self.db_table}.search_name LIKE :search")
+            query_parts.append(self._search_filter_clause)
         # handle favorite filter
         if favorite is not None:
             query_parts.append(f"{self.db_table}.favorite = :favorite")
             query_params["favorite"] = favorite
+        # handle genre filter
+        if genre_ids:
+            query_params["genre_ids"] = genre_ids
+            query_params["genre_media_type"] = self.media_type.value
+            query_parts.append(
+                f"EXISTS("
+                f"SELECT 1 FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} gm "
+                f"WHERE gm.media_id = {self.db_table}.item_id "
+                "AND gm.media_type = :genre_media_type "
+                "AND gm.genre_id IN :genre_ids)"
+            )
         # Apply the provider filter
         if provider_filter:
             provider_conditions = []
index bb940dd7396bff01c10c467219d95add54b1bcef..b0ff61d1929e4c16dc1371dbb382dffcf497b985 100644 (file)
-"""Manage MediaItems of type Genre - Stub Implementation."""
+"""Manage MediaItems of type Genre."""
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+import asyncio
+import json
+import logging
+import time
+from typing import TYPE_CHECKING, Any
 
-from music_assistant_models.enums import MediaType
-from music_assistant_models.media_items import Genre, Track
+from music_assistant_models.enums import EventType, MediaType
+from music_assistant_models.media_items import (
+    Album,
+    Artist,
+    Genre,
+    RecommendationFolder,
+    Track,
+)
+from music_assistant_models.unique_list import UniqueList
 
-from .base import MediaControllerBase
+from music_assistant.constants import (
+    DB_TABLE_ALBUMS,
+    DB_TABLE_ARTISTS,
+    DB_TABLE_AUDIOBOOKS,
+    DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
+    DB_TABLE_GENRES,
+    DB_TABLE_PLAYLISTS,
+    DB_TABLE_PODCASTS,
+    DB_TABLE_RADIOS,
+    DB_TABLE_TRACKS,
+    DEFAULT_GENRE_MAPPING,
+)
+from music_assistant.helpers.compare import create_safe_string
+from music_assistant.helpers.database import UNSET
+from music_assistant.helpers.json import serialize_to_json
 
-# NOTE: Genre support is not yet fully implemented.
-# This is a stub controller to prevent errors when Genre MediaType is encountered.
+from .base import MediaControllerBase
 
 if TYPE_CHECKING:
+    from music_assistant_models.event import MassEvent
+
     from music_assistant import MusicAssistant
 
 
 class GenreController(MediaControllerBase[Genre]):
-    """Stub controller for Genre MediaType - not yet fully implemented."""
+    """Controller for Genre entities."""
 
-    db_table = "genres"  # Not actually used yet
+    db_table = DB_TABLE_GENRES
     media_type = MediaType.GENRE
     item_cls = Genre
 
     def __init__(self, mass: MusicAssistant) -> None:
         """Initialize class."""
         super().__init__(mass)
+        # Background scanner state tracking
+        self._scanner_running: bool = False
+        self._last_scan_time: float = 0
+        self._last_scan_mapped: int = 0
+        self.base_query = f"""
+        SELECT
+            {DB_TABLE_GENRES}.*,
+            (SELECT JSON_GROUP_ARRAY(
+                json_object(
+                    'item_id', provider_mappings.provider_item_id,
+                    'provider_domain', provider_mappings.provider_domain,
+                    'provider_instance', provider_mappings.provider_instance,
+                    'available', provider_mappings.available,
+                    'audio_format', json(provider_mappings.audio_format),
+                    'url', provider_mappings.url,
+                    'details', provider_mappings.details,
+                    'in_library', provider_mappings.in_library,
+                    'is_unique', provider_mappings.is_unique
+                )) FROM provider_mappings
+                WHERE provider_mappings.item_id = {DB_TABLE_GENRES}.item_id
+                AND provider_mappings.media_type = '{MediaType.GENRE.value}'
+            ) AS provider_mappings
+        FROM {DB_TABLE_GENRES}"""
+
+        # register extra api handlers
+        self.mass.register_api_command(
+            "music/genres/add_alias", self.add_alias, required_role="admin"
+        )
+        self.mass.register_api_command(
+            "music/genres/remove_alias", self.remove_alias, required_role="admin"
+        )
+        self.mass.register_api_command(
+            "music/genres/add_media_mapping", self.add_media_mapping, required_role="admin"
+        )
+        self.mass.register_api_command(
+            "music/genres/remove_media_mapping",
+            self.remove_media_mapping,
+            required_role="admin",
+        )
+        self.mass.register_api_command(
+            "music/genres/promote_alias",
+            self.promote_alias_to_genre,
+            required_role="admin",
+        )
+        self.mass.register_api_command(
+            "music/genres/restore_defaults",
+            self.restore_default_genres,
+            required_role="admin",
+        )
+        self.mass.register_api_command(
+            "music/genres/add",
+            self.add_item_to_library,
+            required_role="admin",
+        )
+        self.mass.register_api_command(
+            "music/genres/overview",
+            self.get_overview,
+        )
+        self.mass.register_api_command(
+            "music/genres/radio_mode_base_tracks",
+            self.get_radio_mode_base_tracks,
+        )
+        self.mass.register_api_command(
+            "music/genres/scan_mappings",
+            self.scan_mappings,
+            required_role="admin",
+        )
+        self.mass.register_api_command(
+            "music/genres/scanner_status",
+            self.get_scanner_status,
+        )
+        self.mass.register_api_command(
+            "music/genres/genres_for_media_item",
+            self.get_genres_for_media_item,
+        )
+
+        # Run genre mapping scanner after library sync completes
+        self.mass.subscribe(self._on_sync_tasks_updated, EventType.SYNC_TASKS_UPDATED)
+
+    @staticmethod
+    def _dedup_aliases(existing: list[str], new: list[str]) -> list[str]:
+        """Merge alias lists, deduplicating by normalized form (create_safe_string).
+
+        Preserves the first occurrence's original casing.
+
+        :param existing: Current aliases (ordering preserved).
+        :param new: New aliases to add if not already present.
+        """
+        seen: set[str] = set()
+        result: list[str] = []
+        for alias in [*existing, *new]:
+            norm = create_safe_string(alias, True, True)
+            if norm and norm not in seen:
+                seen.add(norm)
+                result.append(alias)
+        return result
+
+    @property
+    def _search_filter_clause(self) -> str:
+        """Return search filter that also matches genre aliases."""
+        return (
+            f"({self.db_table}.search_name LIKE :search"
+            " OR EXISTS("
+            f"SELECT 1 FROM json_each({self.db_table}.genre_aliases) "
+            "WHERE LOWER(json_each.value) LIKE :search_raw))"
+        )
 
     async def _add_library_item(self, item: Genre, overwrite_existing: bool = False) -> int:
-        """Add a new item record to the database - stub implementation."""
-        raise NotImplementedError("Genre support is not yet implemented")
+        """Add a new genre record to the database."""
+        aliases: list[str] = list(item.genre_aliases) if item.genre_aliases else [item.name]
+        # Ensure the genre's own name is always in aliases (normalized comparison)
+        name_norm = create_safe_string(item.name, True, True)
+        if not any(create_safe_string(a, True, True) == name_norm for a in aliases):
+            aliases.insert(0, item.name)
+        db_id = await self.mass.music.database.insert(
+            self.db_table,
+            {
+                "name": item.name,
+                "sort_name": item.sort_name,
+                "translation_key": item.translation_key,
+                "description": item.metadata.description if item.metadata else None,
+                "favorite": item.favorite,
+                "metadata": serialize_to_json(item.metadata),
+                "external_ids": serialize_to_json(item.external_ids),
+                "genre_aliases": serialize_to_json(aliases),
+                "play_count": 0,
+                "last_played": 0,
+                "search_name": create_safe_string(item.name, True, True),
+                "search_sort_name": create_safe_string(item.sort_name or "", True, True),
+                "timestamp_added": UNSET,
+            },
+        )
+        self.logger.debug("added %s to database (id: %s)", item.name, db_id)
+        return db_id
 
     async def _update_library_item(
         self, item_id: str | int, update: Genre, overwrite: bool = False
     ) -> None:
-        """Update existing record in the database - stub implementation."""
-        raise NotImplementedError("Genre support is not yet implemented")
+        """Update existing genre record in the database."""
+        db_id = int(item_id)
+        cur_item = await self.get_library_item(db_id)
+        metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata)
+        cur_item.external_ids.update(update.external_ids)
+        name = update.name if overwrite else cur_item.name
+        sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name
+        existing_description = await self._get_description(db_id)
+        description = (
+            update.metadata.description
+            if update.metadata and update.metadata.description is not None
+            else None
+            if overwrite
+            else existing_description
+        )
+        # Merge aliases: keep existing, add any new from update (normalized dedup)
+        existing_aliases = list(cur_item.genre_aliases) if cur_item.genre_aliases else []
+        update_aliases = list(update.genre_aliases) if update.genre_aliases else []
+        if overwrite:
+            merged_aliases = self._dedup_aliases(update_aliases, [name])
+        else:
+            merged_aliases = self._dedup_aliases(existing_aliases, [*update_aliases, name])
+
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {
+                "name": name,
+                "sort_name": sort_name,
+                "translation_key": update.translation_key
+                if overwrite
+                else cur_item.translation_key,
+                "description": description,
+                "favorite": update.favorite,
+                "metadata": serialize_to_json(metadata),
+                "external_ids": serialize_to_json(
+                    update.external_ids if overwrite else cur_item.external_ids
+                ),
+                "genre_aliases": serialize_to_json(merged_aliases),
+                "search_name": create_safe_string(name, True, True),
+                "search_sort_name": create_safe_string(sort_name or "", True, True),
+                "timestamp_added": UNSET,
+            },
+        )
+        self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
 
-    async def search(
+    async def library_items(
         self,
-        query: str,
-        provider_instance_id_or_domain: str | None = None,
-        limit: int = 25,
+        favorite: bool | None = None,
+        search: str | None = None,
+        limit: int = 500,
+        offset: int = 0,
+        order_by: str = "sort_name",
+        provider: str | list[str] | None = None,
+        genre: int | list[int] | None = None,
+        **kwargs: Any,
     ) -> list[Genre]:
-        """Search for genres - stub implementation."""
-        return []
+        """Get genres in the library.
+
+        :param genre: NOT SUPPORTED - Filtering genres by genres doesn't make sense.
+        """
+        if genre is not None:
+            msg = "genre parameter is not supported for Genre.library_items()"
+            raise ValueError(msg)
+        # Genres are library-only items without provider_mappings, so ignore
+        # the provider filter (the frontend always sends provider="library").
+        # Pass raw lowered search for alias matching (search_raw),
+        # since the normalized :search param strips spaces/special chars.
+        extra_params: dict[str, Any] | None = None
+        if search:
+            extra_params = {"search_raw": f"%{search.strip().lower()}%"}
+        return await self.get_library_items_by_query(
+            favorite=favorite,
+            search=search,
+            limit=limit,
+            offset=offset,
+            order_by=order_by,
+            extra_query_params=extra_params,
+        )
 
     async def radio_mode_base_tracks(
         self,
         item: Genre,
         preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """
-        Get the list of base tracks from the controller - stub implementation.
+        """Get the list of base tracks for a genre.
 
         :param item: The Genre to get base tracks for.
         :param preferred_provider_instances: List of preferred provider instance IDs to use.
         """
-        raise NotImplementedError("Genre support is not yet implemented")
+        db_id = int(item.item_id)
+        gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
+        query = (
+            f"EXISTS(SELECT 1 FROM {gm} gm "
+            "WHERE gm.media_id = tracks.item_id "
+            "AND gm.media_type = 'track' "
+            "AND gm.genre_id = :genre_id)"
+        )
+        return await self.mass.music.tracks.get_library_items_by_query(
+            extra_query_parts=[query],
+            extra_query_params={"genre_id": db_id},
+            limit=50,
+            order_by="random",
+        )
+
+    async def mapped_media(
+        self,
+        item: Genre,
+        limit: int = 0,
+        offset: int = 0,
+        track_limit: int | None = None,
+        album_limit: int | None = None,
+        artist_limit: int | None = None,
+        order_by: str | None = None,
+    ) -> tuple[list[Track], list[Album], list[Artist]]:
+        """Return tracks, albums, and artists mapped to a genre.
+
+        :param item: The genre to fetch mapped media for.
+        :param limit: Default limit applied to all media types (0 = unlimited).
+        :param offset: Offset for pagination.
+        :param track_limit: Override limit for tracks (defaults to limit).
+        :param album_limit: Override limit for albums (defaults to limit).
+        :param artist_limit: Override limit for artists (defaults to limit).
+        :param order_by: Sort order for all queries (e.g. "random").
+        """
+        db_id = int(item.item_id)
+        gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
+        t_limit = track_limit if track_limit is not None else limit
+        a_limit = album_limit if album_limit is not None else limit
+        ar_limit = artist_limit if artist_limit is not None else limit
+
+        track_query = (
+            f"EXISTS(SELECT 1 FROM {gm} gm "
+            "WHERE gm.media_id = tracks.item_id "
+            "AND gm.media_type = 'track' AND gm.genre_id = :genre_id)"
+        )
+        album_query = (
+            f"EXISTS(SELECT 1 FROM {gm} gm "
+            "WHERE gm.media_id = albums.item_id "
+            "AND gm.media_type = 'album' AND gm.genre_id = :genre_id)"
+        )
+        artist_query = (
+            f"EXISTS(SELECT 1 FROM {gm} gm "
+            "WHERE gm.media_id = artists.item_id "
+            "AND gm.media_type = 'artist' AND gm.genre_id = :genre_id)"
+        )
+
+        tracks, albums, artists = await asyncio.gather(
+            self.mass.music.tracks.get_library_items_by_query(
+                extra_query_parts=[track_query],
+                extra_query_params={"genre_id": db_id},
+                limit=t_limit,
+                offset=offset,
+                order_by=order_by,
+            ),
+            self.mass.music.albums.get_library_items_by_query(
+                extra_query_parts=[album_query],
+                extra_query_params={"genre_id": db_id},
+                limit=a_limit,
+                offset=offset,
+                order_by=order_by,
+            ),
+            self.mass.music.artists.get_library_items_by_query(
+                extra_query_parts=[artist_query],
+                extra_query_params={"genre_id": db_id},
+                limit=ar_limit,
+                offset=offset,
+                order_by=order_by,
+            ),
+        )
+        return tracks, albums, artists
+
+    async def get_genres_for_media_item(
+        self, media_type: MediaType, media_id: str | int
+    ) -> list[Genre]:
+        """Return all genres mapped to a given media item.
+
+        :param media_type: The type of media item.
+        :param media_id: The database ID of the media item.
+        """
+        media_id_int = int(media_id)
+        gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
+        query = (
+            f"EXISTS(SELECT 1 FROM {gm} gm "
+            f"WHERE gm.genre_id = {self.db_table}.item_id "
+            "AND gm.media_type = :media_type AND gm.media_id = :media_id)"
+        )
+        return await self.get_library_items_by_query(
+            extra_query_parts=[query],
+            extra_query_params={
+                "media_type": media_type.value,
+                "media_id": media_id_int,
+            },
+        )
+
+    async def get_radio_mode_base_tracks(
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str | None = None,
+        preferred_provider_instances: list[str] | None = None,
+    ) -> list[Track]:
+        """Return base tracks for genre radio mode."""
+        provider = provider_instance_id_or_domain or "library"
+        item = await self.get(item_id, provider)
+        return await self.radio_mode_base_tracks(item, preferred_provider_instances)
+
+    async def get_overview(
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str | None = None,
+        limit: int = 25,
+    ) -> list[RecommendationFolder]:
+        """Return overview rows for a genre (all media types)."""
+        provider = provider_instance_id_or_domain or "library"
+        item = await self.get(item_id, provider)
+        db_id = int(item.item_id)
+        gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
+        media_rows: list[tuple[MediaType, str]] = [
+            (MediaType.ARTIST, "Artists"),
+            (MediaType.ALBUM, "Albums"),
+            (MediaType.TRACK, "Tracks"),
+            (MediaType.PLAYLIST, "Playlists"),
+            (MediaType.RADIO, "Radio"),
+            (MediaType.PODCAST, "Podcasts"),
+            (MediaType.AUDIOBOOK, "Audiobooks"),
+        ]
+
+        async def _fetch_media_type(
+            media_type: MediaType, title: str
+        ) -> RecommendationFolder | None:
+            ctrl = self.mass.music.get_controller(media_type)
+            query = (
+                f"EXISTS(SELECT 1 FROM {gm} gm "
+                f"WHERE gm.media_id = {ctrl.db_table}.item_id "
+                "AND gm.media_type = :media_type "
+                "AND gm.genre_id = :genre_id)"
+            )
+            items = await ctrl.get_library_items_by_query(
+                extra_query_parts=[query],
+                extra_query_params={
+                    "genre_id": db_id,
+                    "media_type": media_type.value,
+                },
+                limit=limit,
+            )
+            if not items:
+                return None
+            return RecommendationFolder(
+                item_id=f"genre_{media_type.value}",
+                name=title,
+                provider="library",
+                items=UniqueList(items[:limit]),
+            )
+
+        results = await asyncio.gather(*[_fetch_media_type(mt, title) for mt, title in media_rows])
+        return [r for r in results if r is not None]
 
     async def match_providers(self, db_item: Genre) -> None:
-        """Try to find match on all providers - stub implementation."""
-        raise NotImplementedError("Genre support is not yet implemented")
+        """No provider matching for genres at this time."""
+        return
+
+    async def restore_default_genres(self, full_restore: bool = False) -> list[Genre]:
+        """Restore default genres from genre_mapping.json.
+
+        :param full_restore: If True, delete all existing genres and recreate from defaults.
+                            If False (default), only add missing genres and ensure aliases exist.
+        """
+        if full_restore:
+            self.logger.warning("Performing FULL restore - deleting all existing genres")
+            await self.mass.music.database.delete(DB_TABLE_GENRE_MEDIA_ITEM_MAPPING)
+            await self.mass.music.database.delete(DB_TABLE_GENRES)
+            existing = set()
+        else:
+            rows = await self.mass.music.database.get_rows_from_query(
+                f"SELECT search_name FROM {DB_TABLE_GENRES}", limit=0
+            )
+            existing = {row["search_name"] for row in rows}
+
+        created_ids: list[int] = []
+        for entry in DEFAULT_GENRE_MAPPING:
+            name = entry.get("genre")
+            if not name:
+                continue
+            normalized = self._normalize_genre_name(name)
+            if not normalized:
+                continue
+            name_value, sort_name, search_name, search_sort_name = normalized
+            all_aliases = [name_value, *entry.get("aliases", [])]
+
+            # Partial restore: Ensure aliases are up to date
+            if search_name in existing:
+                if db_row := await self.mass.music.database.get_row(
+                    DB_TABLE_GENRES, {"search_name": search_name}
+                ):
+                    genre_id = int(db_row["item_id"])
+                    await self._ensure_aliases(genre_id, all_aliases)
+                continue
+
+            # Create new genre
+            genre_id = await self.mass.music.database.insert(
+                DB_TABLE_GENRES,
+                {
+                    "name": name_value,
+                    "sort_name": sort_name,
+                    "translation_key": entry.get("translation_key"),
+                    "description": None,
+                    "favorite": 0,
+                    "metadata": serialize_to_json({}),
+                    "external_ids": serialize_to_json(set()),
+                    "genre_aliases": serialize_to_json(all_aliases),
+                    "play_count": 0,
+                    "last_played": 0,
+                    "search_name": search_name,
+                    "search_sort_name": search_sort_name,
+                    "timestamp_added": UNSET,
+                },
+            )
+            created_ids.append(genre_id)
+            existing.add(search_name)
+
+        if full_restore:
+            await self._bulk_scan_media_genres()
+
+        if not created_ids:
+            return []
+        return [await self.get_library_item(item_id) for item_id in created_ids]
+
+    async def _bulk_scan_media_genres(self) -> None:
+        """Bulk-scan all media items and rebuild genre mappings using CTE.
+
+        Uses the same approach as the initial migration: extracts all unique genre names
+        from metadata.genres across all media tables, resolves them to genre IDs via alias
+        lookup, then does a single INSERT per media type using a CTE join.
+        """
+        db = self.mass.music.database
+
+        media_tables = (
+            (DB_TABLE_TRACKS, MediaType.TRACK),
+            (DB_TABLE_ALBUMS, MediaType.ALBUM),
+            (DB_TABLE_ARTISTS, MediaType.ARTIST),
+            (DB_TABLE_PLAYLISTS, MediaType.PLAYLIST),
+            (DB_TABLE_RADIOS, MediaType.RADIO),
+            (DB_TABLE_AUDIOBOOKS, MediaType.AUDIOBOOK),
+            (DB_TABLE_PODCASTS, MediaType.PODCAST),
+        )
+
+        # Build alias -> genre_ids lookup from all genres in the database.
+        # One alias can map to multiple genres (n:n relationship).
+        alias_to_genre: dict[str, list[int]] = {}
+        genre_rows = await db.get_rows_from_query(
+            f"SELECT item_id, genre_aliases FROM {DB_TABLE_GENRES}", limit=0
+        )
+        for row in genre_rows:
+            genre_id = int(row["item_id"])
+            aliases = json.loads(row["genre_aliases"]) if row["genre_aliases"] else []
+            for alias in aliases:
+                norm = create_safe_string(alias.strip(), True, True)
+                if norm:
+                    alias_to_genre.setdefault(norm, [])
+                    if genre_id not in alias_to_genre[norm]:
+                        alias_to_genre[norm].append(genre_id)
+
+        # Extract all unique raw genre names from metadata across all media tables
+        union_parts = [
+            f"SELECT DISTINCT TRIM(g.value) AS raw_name "
+            f"FROM {table}, json_each(json_extract({table}.metadata, '$.genres')) AS g "
+            f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
+            f"AND json_extract({table}.metadata, '$.genres') != '[]'"
+            for table, _ in media_tables
+        ]
+        unique_names_sql = " UNION ".join(union_parts)
+        rows = await db.get_rows_from_query(unique_names_sql, limit=0)
+        unique_raw_names = [row["raw_name"] for row in rows if row["raw_name"]]
+
+        self.logger.debug(
+            "Bulk genre scan - discovered %d unique genre names", len(unique_raw_names)
+        )
+
+        # Resolve each raw name to genre_ids via alias lookup.
+        # One raw name can map to multiple genres (n:n).
+        raw_name_to_genres: dict[str, list[int]] = {}
+        for raw_name in unique_raw_names:
+            norm = create_safe_string(raw_name.strip(), True, True)
+            if not norm:
+                continue
+            if norm in alias_to_genre:
+                raw_name_to_genres[raw_name] = alias_to_genre[norm]
+                self.logger.debug(
+                    "Bulk scan - resolved %r -> genre_ids %s (alias match)",
+                    raw_name,
+                    alias_to_genre[norm],
+                )
+            else:
+                resolved_ids = await self._find_genres_for_alias(raw_name)
+                if resolved_ids:
+                    raw_name_to_genres[raw_name] = resolved_ids
+                    alias_to_genre[norm] = resolved_ids
+                    self.logger.debug(
+                        "Bulk scan - resolved %r -> genre_ids %s (new genre)",
+                        raw_name,
+                        resolved_ids,
+                    )
+
+        self.logger.info(
+            "Bulk genre scan - resolved %d unique genre names", len(raw_name_to_genres)
+        )
+
+        # Add discovered raw names as aliases to their resolved genres so that
+        # future searches by raw name (e.g. "Synthpop") find the parent genre
+        # even when the stored alias differs (e.g. "synth-pop").
+        genre_new_aliases: dict[int, list[str]] = {}
+        for raw_name, gids in raw_name_to_genres.items():
+            for gid in gids:
+                genre_new_aliases.setdefault(gid, []).append(raw_name)
+        for gid, new_aliases in genre_new_aliases.items():
+            await self._ensure_aliases(gid, new_aliases)
+
+        # Build CTE with (raw_name, genre_id) pairs. One raw name can produce
+        # multiple rows when it maps to multiple genres (n:n).
+        if raw_name_to_genres:
+            cte_values = ", ".join(
+                f"(LOWER('{name.replace(chr(39), chr(39) + chr(39))}'), {gid})"
+                for name, gids in raw_name_to_genres.items()
+                for gid in gids
+            )
+            cte = f"WITH genre_lookup(raw_name, genre_id) AS (VALUES {cte_values})"
+
+            for table, media_type in media_tables:
+                full_query = (
+                    f"{cte} INSERT OR REPLACE INTO {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING}"
+                    f"(genre_id, media_id, media_type, alias) "
+                    f"SELECT gl.genre_id, {table}.item_id, "
+                    f"'{media_type.value}', TRIM(g.value) "
+                    f"FROM {table}, "
+                    f"json_each(json_extract({table}.metadata, '$.genres')) AS g "
+                    f"JOIN genre_lookup gl ON gl.raw_name = LOWER(TRIM(g.value)) "
+                    f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
+                    f"AND json_extract({table}.metadata, '$.genres') != '[]'"
+                )
+                await db.execute(full_query)
+            await db.commit()
+
+        self.logger.info(
+            "Bulk genre scan completed - mapped %d unique names to genres",
+            len(raw_name_to_genres),
+        )
+
+    async def _bulk_scan_unmapped_genres(self) -> int:
+        """Scan only unmapped media items and create genre mappings using CTE.
+
+        Similar to _bulk_scan_media_genres but filters to items not yet in
+        genre_media_item_mapping. Used by the incremental scanner after syncs.
+
+        :return: Total number of items mapped.
+        """
+        db = self.mass.music.database
+        gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
+
+        media_tables = (
+            (DB_TABLE_TRACKS, MediaType.TRACK),
+            (DB_TABLE_ALBUMS, MediaType.ALBUM),
+            (DB_TABLE_ARTISTS, MediaType.ARTIST),
+            (DB_TABLE_PLAYLISTS, MediaType.PLAYLIST),
+            (DB_TABLE_RADIOS, MediaType.RADIO),
+            (DB_TABLE_AUDIOBOOKS, MediaType.AUDIOBOOK),
+            (DB_TABLE_PODCASTS, MediaType.PODCAST),
+        )
+
+        # Build alias -> genre_ids lookup (n:n) from all genres in the database.
+        alias_to_genre: dict[str, list[int]] = {}
+        genre_rows = await db.get_rows_from_query(
+            f"SELECT item_id, genre_aliases FROM {DB_TABLE_GENRES}", limit=0
+        )
+        for row in genre_rows:
+            genre_id = int(row["item_id"])
+            aliases = json.loads(row["genre_aliases"]) if row["genre_aliases"] else []
+            for alias in aliases:
+                norm = create_safe_string(alias.strip(), True, True)
+                if norm:
+                    alias_to_genre.setdefault(norm, [])
+                    if genre_id not in alias_to_genre[norm]:
+                        alias_to_genre[norm].append(genre_id)
+
+        # Extract all unique raw genre names from media items.
+        # We don't filter by unmapped items here because a media item may
+        # have some genres mapped but not all (e.g. added a new genre tag).
+        union_parts = [
+            f"SELECT DISTINCT TRIM(g.value) AS raw_name "
+            f"FROM {table}, json_each(json_extract({table}.metadata, '$.genres')) AS g "
+            f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
+            f"AND json_extract({table}.metadata, '$.genres') != '[]'"
+            for table, _mtype in media_tables
+        ]
+        unique_names_sql = " UNION ".join(union_parts)
+        rows = await db.get_rows_from_query(unique_names_sql, limit=0)
+        unique_raw_names = [row["raw_name"] for row in rows if row["raw_name"]]
+
+        if not unique_raw_names:
+            return 0
+
+        self.logger.debug(
+            "Incremental genre scan - discovered %d unique genre names from unmapped items",
+            len(unique_raw_names),
+        )
+
+        # Resolve each raw name to genre_ids (n:n)
+        raw_name_to_genres: dict[str, list[int]] = {}
+        for raw_name in unique_raw_names:
+            norm = create_safe_string(raw_name.strip(), True, True)
+            if not norm:
+                continue
+            if norm in alias_to_genre:
+                raw_name_to_genres[raw_name] = alias_to_genre[norm]
+                self.logger.debug(
+                    "Scanner - resolved %r -> genre_ids %s (alias match)",
+                    raw_name,
+                    alias_to_genre[norm],
+                )
+            else:
+                resolved_ids = await self._find_genres_for_alias(raw_name)
+                if resolved_ids:
+                    raw_name_to_genres[raw_name] = resolved_ids
+                    alias_to_genre[norm] = resolved_ids
+                    self.logger.debug(
+                        "Scanner - resolved %r -> genre_ids %s (new genre)",
+                        raw_name,
+                        resolved_ids,
+                    )
+
+        if not raw_name_to_genres:
+            return 0
+
+        # Add discovered raw names as aliases to their resolved genres
+        genre_new_aliases: dict[int, list[str]] = {}
+        for raw_name, gids in raw_name_to_genres.items():
+            for gid in gids:
+                genre_new_aliases.setdefault(gid, []).append(raw_name)
+        for gid, new_aliases in genre_new_aliases.items():
+            await self._ensure_aliases(gid, new_aliases)
+
+        # Build CTE with n:n pairs and INSERT only for unmapped items
+        cte_values = ", ".join(
+            f"(LOWER('{name.replace(chr(39), chr(39) + chr(39))}'), {gid})"
+            for name, gids in raw_name_to_genres.items()
+            for gid in gids
+        )
+        cte = f"WITH genre_lookup(raw_name, genre_id) AS (VALUES {cte_values})"
+
+        count_before = await db.get_count(gm)
+        for table, media_type in media_tables:
+            full_query = (
+                f"{cte} INSERT OR IGNORE INTO {gm}"
+                f"(genre_id, media_id, media_type, alias) "
+                f"SELECT gl.genre_id, {table}.item_id, "
+                f"'{media_type.value}', TRIM(g.value) "
+                f"FROM {table}, "
+                f"json_each(json_extract({table}.metadata, '$.genres')) AS g "
+                f"JOIN genre_lookup gl ON gl.raw_name = LOWER(TRIM(g.value)) "
+                f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
+                f"AND json_extract({table}.metadata, '$.genres') != '[]' "
+                f"AND NOT EXISTS ("
+                f"SELECT 1 FROM {gm} ex "
+                f"WHERE ex.genre_id = gl.genre_id "
+                f"AND ex.media_id = {table}.item_id "
+                f"AND ex.media_type = '{media_type.value}')"
+            )
+            await db.execute(full_query)
+        await db.commit()
+        count_after = await db.get_count(gm)
+
+        return count_after - count_before
+
+    async def remove_item_from_library(self, item_id: str | int, recursive: bool = True) -> None:
+        """Delete genre record from the database."""
+        db_id = int(item_id)
+        await self.mass.music.database.delete(
+            DB_TABLE_GENRE_MEDIA_ITEM_MAPPING, {"genre_id": db_id}
+        )
+        await super().remove_item_from_library(item_id, recursive)
+
+    async def add_alias(self, genre_id: str | int, alias: str) -> Genre:
+        """Add an alias string to a genre.
+
+        :param genre_id: Database ID of the genre.
+        :param alias: Alias string to add.
+        """
+        db_id = int(genre_id)
+        genre = await self.get_library_item(db_id)
+        aliases = list(genre.genre_aliases) if genre.genre_aliases else []
+        aliases = self._dedup_aliases(aliases, [alias])
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {"genre_aliases": serialize_to_json(aliases)},
+        )
+        updated = await self.get_library_item(db_id)
+        self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, updated.uri, updated)
+        return updated
+
+    async def remove_alias(self, genre_id: str | int, alias: str) -> Genre:
+        """Remove an alias string from a genre.
+
+        :param genre_id: Database ID of the genre.
+        :param alias: Alias string to remove.
+        :raises ValueError: If trying to remove the genre's own name.
+        """
+        db_id = int(genre_id)
+        genre = await self.get_library_item(db_id)
+        if create_safe_string(alias, True, True) == create_safe_string(genre.name, True, True):
+            msg = (
+                f"Cannot remove self-alias '{alias}' from genre '{genre.name}'. "
+                f"Delete the genre instead."
+            )
+            raise ValueError(msg)
+        aliases = list(genre.genre_aliases) if genre.genre_aliases else []
+        alias_norm = create_safe_string(alias, True, True)
+        aliases = [a for a in aliases if create_safe_string(a, True, True) != alias_norm]
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {"genre_aliases": serialize_to_json(aliases)},
+        )
+        # Remove media mappings that were created via this alias (case-insensitive)
+        await self.mass.music.database.execute(
+            f"DELETE FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE genre_id = :genre_id AND LOWER(alias) = LOWER(:alias)",
+            {"genre_id": db_id, "alias": alias},
+        )
+        updated = await self.get_library_item(db_id)
+        self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, updated.uri, updated)
+        return updated
+
+    async def add_media_mapping(
+        self, genre_id: str | int, media_type: MediaType, media_id: str | int, alias: str
+    ) -> None:
+        """Map a media item to a genre.
+
+        :param genre_id: Database ID of the genre.
+        :param media_type: Type of media item (track, album, artist).
+        :param media_id: Database ID of the media item.
+        :param alias: The alias string that caused this mapping.
+        """
+        await self.mass.music.database.insert(
+            DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
+            {
+                "genre_id": int(genre_id),
+                "media_id": int(media_id),
+                "media_type": media_type.value,
+                "alias": alias,
+            },
+            allow_replace=True,
+        )
+
+    async def remove_media_mapping(
+        self, genre_id: str | int, media_type: MediaType, media_id: str | int
+    ) -> None:
+        """Remove a media item mapping from a genre.
+
+        :param genre_id: Database ID of the genre.
+        :param media_type: Type of media item (track, album, artist).
+        :param media_id: Database ID of the media item.
+        """
+        await self.mass.music.database.delete(
+            DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
+            {
+                "genre_id": int(genre_id),
+                "media_id": int(media_id),
+                "media_type": media_type.value,
+            },
+        )
+
+    async def promote_alias_to_genre(self, genre_id: str | int, alias: str) -> Genre:
+        """Promote an alias to become a standalone genre.
+
+        Creates a new Genre with the alias's name, moves all media mappings
+        for that alias to the new genre, and removes the alias from the
+        original genre.
+
+        :param genre_id: Database ID of the source genre.
+        :param alias: The alias string to promote.
+        :return: The newly created Genre.
+        """
+        db_genre_id = int(genre_id)
+        source_genre = await self.get_library_item(db_genre_id)
+
+        if create_safe_string(alias, True, True) == create_safe_string(
+            source_genre.name, True, True
+        ):
+            msg = (
+                f"Cannot promote self-alias '{alias}'. "
+                f"This alias is the primary name for genre '{source_genre.name}'."
+            )
+            raise ValueError(msg)
+
+        # Create new genre with the alias as its name
+        new_genre = Genre(
+            item_id="0",
+            provider="library",
+            name=alias,
+            sort_name=alias,
+            translation_key=None,
+            provider_mappings=set(),
+            favorite=False,
+        )
+        created_genre = await self.add_item_to_library(new_genre)
+        new_genre_id = int(created_genre.item_id)
+
+        # Move media mappings from source genre to new genre for this alias (case-insensitive)
+        await self.mass.music.database.execute(
+            f"UPDATE {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "SET genre_id = :new_id WHERE genre_id = :old_id AND LOWER(alias) = LOWER(:alias)",
+            {"new_id": new_genre_id, "old_id": db_genre_id, "alias": alias},
+        )
+
+        # Remove alias from source genre (normalized comparison)
+        alias_norm = create_safe_string(alias, True, True)
+        aliases = list(source_genre.genre_aliases) if source_genre.genre_aliases else []
+        aliases = [a for a in aliases if create_safe_string(a, True, True) != alias_norm]
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_genre_id},
+            {"genre_aliases": serialize_to_json(list(aliases))},
+        )
+
+        return await self.get_library_item(new_genre_id)
+
+    async def sync_media_item_genres(
+        self, media_type: MediaType, media_id: str | int, genre_names: set[str]
+    ) -> None:
+        """Sync genre mappings for a media item.
+
+        Ensures genre records exist and updates genre-media mappings.
+        Removes mappings that are no longer present in the incoming genre_names set.
+
+        :param media_type: The type of media item being synced.
+        :param media_id: The database ID of the media item.
+        :param genre_names: Set of genre names from the provider.
+        """
+        media_id_int = int(media_id)
+        gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
+
+        # Build target set: (genre_id, alias_name) from incoming names.
+        # One alias can map to multiple genres (n:n).
+        target_mappings: dict[int, str] = {}
+        for name in genre_names:
+            normalized = self._normalize_genre_name(name)
+            if not normalized:
+                continue
+            genre_ids = await self._find_genres_for_alias(normalized[0])
+            for gid in genre_ids:
+                if gid not in target_mappings:
+                    target_mappings[gid] = normalized[0]
+
+        # Get current genre_ids from database
+        rows = await self.mass.music.database.get_rows_from_query(
+            f"SELECT genre_id FROM {gm} WHERE media_type = :media_type AND media_id = :media_id",
+            {"media_type": media_type.value, "media_id": media_id_int},
+            limit=0,
+        )
+        existing_genre_ids = {int(row["genre_id"]) for row in rows}
+
+        to_add = set(target_mappings.keys()) - existing_genre_ids
+        to_remove = existing_genre_ids - set(target_mappings.keys())
+
+        for genre_id in to_remove:
+            await self.mass.music.database.delete(
+                gm,
+                {
+                    "genre_id": genre_id,
+                    "media_id": media_id_int,
+                    "media_type": media_type.value,
+                },
+            )
+
+        for genre_id in to_add:
+            await self.mass.music.database.insert(
+                gm,
+                {
+                    "genre_id": genre_id,
+                    "media_id": media_id_int,
+                    "media_type": media_type.value,
+                    "alias": target_mappings[genre_id],
+                },
+                allow_replace=True,
+            )
+
+    async def _ensure_aliases(self, genre_id: int, aliases: list[str]) -> None:
+        """Ensure a genre has all the specified aliases in its genre_aliases JSON.
+
+        :param genre_id: Database ID of the genre.
+        :param aliases: List of alias strings that should be present.
+        """
+        genre = await self.get_library_item(genre_id)
+        existing = list(genre.genre_aliases) if genre.genre_aliases else []
+        merged = self._dedup_aliases(existing, aliases)
+        if len(merged) != len(existing):
+            await self.mass.music.database.update(
+                self.db_table,
+                {"item_id": genre_id},
+                {"genre_aliases": serialize_to_json(merged)},
+            )
+
+    async def _find_genres_for_alias(self, name: str) -> list[int]:
+        """Find all genres that own the given alias name, or create a new genre.
+
+        An alias can map to multiple genres (n:n relationship). For example,
+        "anime" could be an alias of both an "Anime" genre and an "Anime Music" genre.
+        If no genre owns this alias, creates a new genre.
+
+        :param name: The alias name to find/create a genre for.
+        :return: List of genre IDs (empty if name is invalid).
+        """
+        normalized = self._normalize_genre_name(name)
+        if not normalized:
+            return []
+        name_value, sort_name, search_name, search_sort_name = normalized
+
+        async with self._db_add_lock:
+            found_ids: list[int] = []
+
+            # Check if a genre exists with this name as its own name
+            if db_row := await self.mass.music.database.get_row(
+                DB_TABLE_GENRES, {"search_name": search_name}
+            ):
+                found_ids.append(int(db_row["item_id"]))
+
+            # Search genre_aliases JSON columns (case-insensitive, can match multiple)
+            rows = await self.mass.music.database.get_rows_from_query(
+                f"SELECT item_id FROM {DB_TABLE_GENRES} "
+                "WHERE EXISTS("
+                "SELECT 1 FROM json_each(genre_aliases) "
+                "WHERE LOWER(json_each.value) = LOWER(:alias_name)"
+                ")",
+                {"alias_name": name_value},
+                limit=0,
+            )
+            for row in rows:
+                gid = int(row["item_id"])
+                if gid not in found_ids:
+                    found_ids.append(gid)
+
+            # Also check via normalized comparison (create_safe_string).
+            # This catches genres that stages 1-2 miss due to normalization
+            # differences, e.g. genre A has "synthpop", genre B has "synth-pop"
+            # — both normalize to "synthpop" but LOWER can't bridge the gap.
+            all_genres = await self.mass.music.database.get_rows_from_query(
+                f"SELECT item_id, genre_aliases FROM {DB_TABLE_GENRES}", limit=0
+            )
+            for row in all_genres:
+                aliases = json.loads(row["genre_aliases"]) if row["genre_aliases"] else []
+                for alias in aliases:
+                    if create_safe_string(alias.strip(), True, True) == search_name:
+                        gid = int(row["item_id"])
+                        if gid not in found_ids:
+                            found_ids.append(gid)
+
+            if found_ids:
+                return found_ids
+
+            # No genre owns this alias — create a new one
+            new_id = await self.mass.music.database.insert(
+                DB_TABLE_GENRES,
+                {
+                    "name": name_value,
+                    "sort_name": sort_name,
+                    "description": None,
+                    "favorite": 0,
+                    "metadata": serialize_to_json({}),
+                    "external_ids": serialize_to_json(set()),
+                    "genre_aliases": serialize_to_json([name_value]),
+                    "play_count": 0,
+                    "last_played": 0,
+                    "search_name": search_name,
+                    "search_sort_name": search_sort_name,
+                    "timestamp_added": UNSET,
+                },
+            )
+            return [new_id]
+
+    async def _get_description(self, item_id: int) -> str | None:
+        if db_row := await self.mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": item_id}):
+            return dict(db_row).get("description")
+        return None
+
+    @staticmethod
+    def _normalize_genre_name(raw_name: str) -> tuple[str, str, str, str] | None:
+        """Normalize a raw genre name for storage and search.
+
+        :param raw_name: Raw genre name from provider.
+        :return: Tuple of (name, sort_name, search_name, search_sort_name) or None if invalid.
+        """
+        name = raw_name.strip()
+        if not name:
+            return None
+        sort_name = name
+        search_name = create_safe_string(name, True, True)
+        if not search_name:
+            return None
+        search_sort_name = create_safe_string(sort_name or "", True, True)
+        return name, sort_name, search_name, search_sort_name
+
+    def _on_sync_tasks_updated(self, _event: MassEvent) -> None:
+        """Trigger genre mapping scan when all sync tasks complete."""
+        if self.mass.music.in_progress_syncs or self._scanner_running:
+            return
+        self._scanner_running = True
+        self.mass.create_task(self._scan_genre_mappings())
+
+    async def _scan_genre_mappings(self) -> None:
+        """Scan media items with metadata.genres and map them to genres.
+
+        Triggered after library sync completes or via manual API call.
+        Callers must set _scanner_running = True before calling this method.
+        """
+        # Double-check syncs haven't started since the event was dispatched
+        if self.mass.music.in_progress_syncs:
+            self.logger.debug("Syncs still in progress, deferring genre scan")
+            self._scanner_running = False
+            return
+        self._last_scan_time = time.time()
+
+        try:
+            self.logger.debug("Starting genre mapping scan...")
+            self._last_scan_mapped = await self._bulk_scan_unmapped_genres()
+            self.logger.info(
+                "Genre mapping scan completed: %d items mapped (%.1fs)",
+                self._last_scan_mapped,
+                time.time() - self._last_scan_time,
+            )
+
+        except Exception as err:
+            self.logger.error(
+                "Error in genre mapping scanner: %s",
+                str(err),
+                exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
+            )
+
+        finally:
+            self._scanner_running = False
+
+    async def scan_mappings(self) -> dict[str, Any]:
+        """Manually trigger a genre mapping scan (admin only).
+
+        :return: Status information about the scan trigger.
+        """
+        if self._scanner_running:
+            return {
+                "status": "already_running",
+                "message": "Genre mapping scanner is already running",
+            }
+
+        self._scanner_running = True
+        self.mass.create_task(self._scan_genre_mappings())
+
+        return {
+            "status": "triggered",
+            "message": "Genre mapping scan triggered",
+            "last_scan": self._last_scan_time,
+        }
+
+    async def get_scanner_status(self) -> dict[str, Any]:
+        """Get status of the genre mapping background scanner.
+
+        :return: Scanner status information.
+        """
+        return {
+            "running": self._scanner_running,
+            "last_scan_time": self._last_scan_time,
+            "last_scan_ago_seconds": (
+                int(time.time() - self._last_scan_time) if self._last_scan_time else None
+            ),
+            "last_scan_mapped": self._last_scan_mapped,
+        }
index f77153edbb9e55b5dce81b1b31c4343127bd721e..1d7778174d47cd47419516e2f035abb4bbf6a51c 100644 (file)
@@ -51,6 +51,7 @@ class PodcastsController(MediaControllerBase[Podcast]):
         offset: int = 0,
         order_by: str = "sort_name",
         provider: str | list[str] | None = None,
+        genre: int | list[int] | None = None,
         **kwargs: Any,
     ) -> list[Podcast]:
         """Get in-database podcasts.
@@ -61,10 +62,12 @@ class PodcastsController(MediaControllerBase[Podcast]):
         :param offset: Number of items to skip.
         :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
         :param provider: Filter by provider instance ID (single string or list).
+        :param genre: Filter by genre id(s).
         """
         result = await self.get_library_items_by_query(
             favorite=favorite,
             search=search,
+            genre_ids=genre,
             limit=limit,
             offset=offset,
             order_by=order_by,
@@ -82,6 +85,7 @@ class PodcastsController(MediaControllerBase[Podcast]):
             return result + await self.get_library_items_by_query(
                 favorite=favorite,
                 search=None,
+                genre_ids=genre,
                 limit=limit,
                 order_by=order_by,
                 provider_filter=self._ensure_provider_filter(provider),
index 4136149829e41b8f02385d745a2a5fc661023a67..eb549216b5d733f2afe3ea4e56ac22a466bac68f 100644 (file)
@@ -168,6 +168,7 @@ class TracksController(MediaControllerBase[Track]):
         offset: int = 0,
         order_by: str = "sort_name",
         provider: str | list[str] | None = None,
+        genre: int | list[int] | None = None,
         **kwargs: Any,
     ) -> list[Track]:
         """Get in-database tracks.
@@ -178,6 +179,7 @@ class TracksController(MediaControllerBase[Track]):
         :param offset: Number of items to skip.
         :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
         :param provider: Filter by provider instance ID (single string or list).
+        :param genre: Filter by genre id(s).
         """
         extra_query_params: dict[str, Any] = {}
         extra_query_parts: list[str] = []
@@ -200,6 +202,7 @@ class TracksController(MediaControllerBase[Track]):
         result = await self.get_library_items_by_query(
             favorite=favorite,
             search=search,
+            genre_ids=genre,
             limit=limit,
             offset=offset,
             order_by=order_by,
@@ -222,6 +225,7 @@ class TracksController(MediaControllerBase[Track]):
             for _track in await self.get_library_items_by_query(
                 favorite=favorite,
                 search=None,
+                genre_ids=genre,
                 limit=limit,
                 order_by=order_by,
                 provider_filter=self._ensure_provider_filter(provider),
index 0117b33dbf7325716c6d5d22d28b192bc5d0cfcb..16fc5180f51c62c5c4240f81fff163ba835e8dbf 100644 (file)
@@ -51,6 +51,8 @@ from music_assistant.constants import (
     DB_TABLE_ALBUMS,
     DB_TABLE_ARTISTS,
     DB_TABLE_AUDIOBOOKS,
+    DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
+    DB_TABLE_GENRES,
     DB_TABLE_LOUDNESS_MEASUREMENTS,
     DB_TABLE_PLAYLISTS,
     DB_TABLE_PLAYLOG,
@@ -61,6 +63,7 @@ from music_assistant.constants import (
     DB_TABLE_SMART_FADES_ANALYSIS,
     DB_TABLE_TRACK_ARTISTS,
     DB_TABLE_TRACKS,
+    DEFAULT_GENRE_MAPPING,
     PROVIDERS_WITH_SHAREABLE_URLS,
 )
 from music_assistant.controllers.streams.smart_fades.fades import SMART_CROSSFADE_DURATION
@@ -939,6 +942,11 @@ class MusicController(CoreController):
 
         media_type = media_item.media_type
         ctrl = self.get_controller(media_type)
+
+        # genres are library-only items with no provider mappings, nothing to refresh
+        if media_type == MediaType.GENRE:
+            return media_item
+
         library_id = media_item.item_id if media_item.provider == "library" else None
 
         # cache in_library state before the provider fetch overwrites media_item
@@ -2259,6 +2267,210 @@ class MusicController(CoreController):
                 "AND pm2.in_library = 1)"
             )
 
+        if prev_version <= 28:
+            # create genre/alias tables
+            await self.__create_database_tables()
+
+            # Use raw aiosqlite connection for bulk operations.
+            db = self._database._db
+
+            empty_metadata = serialize_to_json({})
+            empty_external_ids = serialize_to_json(set())
+
+            def _normalize_name(raw_name: str) -> tuple[str, str, str, str]:
+                name = raw_name.strip()
+                sort_name = name
+                search_name = create_safe_string(name, True, True)
+                search_sort_name = create_safe_string(sort_name or "", True, True)
+                return name, sort_name, search_name, search_sort_name
+
+            genre_cache: dict[str, int] = {}
+
+            genre_insert_sql = (
+                f"INSERT OR IGNORE INTO {DB_TABLE_GENRES}"
+                "(name, sort_name, translation_key, description, favorite, "
+                "metadata, external_ids, genre_aliases, play_count, last_played, "
+                "search_name, search_sort_name) "
+                "VALUES (?, ?, ?, NULL, 0, ?, ?, ?, 0, 0, ?, ?)"
+            )
+            genre_select_sql = f"SELECT item_id FROM {DB_TABLE_GENRES} WHERE search_name = ?"
+
+            async def _get_or_create_genre(
+                raw_name: str,
+                aliases: list[str] | None = None,
+                translation_key: str | None = None,
+            ) -> int:
+                name, sort_name, search_name, search_sort_name = _normalize_name(raw_name)
+                if not search_name:
+                    return 0
+                if search_name in genre_cache:
+                    return genre_cache[search_name]
+                aliases_json = serialize_to_json(aliases or [name])
+                row_id = await db.execute_insert(
+                    genre_insert_sql,
+                    (
+                        name,
+                        sort_name,
+                        translation_key,
+                        empty_metadata,
+                        empty_external_ids,
+                        aliases_json,
+                        search_name,
+                        search_sort_name,
+                    ),
+                )
+                if row_id and row_id[0]:
+                    genre_cache[search_name] = row_id[0]
+                    return row_id[0]
+                async with db.execute(genre_select_sql, (search_name,)) as cursor:
+                    row = await cursor.fetchone()
+                    if row:
+                        genre_cache[search_name] = row[0]
+                        return row[0]
+                return 0
+
+            # Phase 1: Seed DEFAULT_GENRE_MAPPING — create genres with aliases.
+            # Build n:n lookup: normalized alias name -> list of genre_ids.
+            # One alias can belong to multiple genres (e.g. "funk" is both
+            # a standalone genre and an alias of Soul/R&B).
+            alias_to_genre: dict[str, list[int]] = {}
+            for entry in DEFAULT_GENRE_MAPPING:
+                genre_name = entry.get("genre")
+                if not genre_name:
+                    continue
+                all_aliases = [genre_name, *entry.get("aliases", [])]
+                genre_id = await _get_or_create_genre(
+                    genre_name,
+                    aliases=all_aliases,
+                    translation_key=entry.get("translation_key"),
+                )
+                if not genre_id:
+                    continue
+                for alias in all_aliases:
+                    norm = create_safe_string(alias.strip(), True, True)
+                    if norm:
+                        alias_to_genre.setdefault(norm, [])
+                        if genre_id not in alias_to_genre[norm]:
+                            alias_to_genre[norm].append(genre_id)
+            await db.commit()
+
+            # Phase 2: Discover unique genre names from all media items,
+            # create genres for unknown names, then bulk-insert mappings.
+            media_tables = (
+                (DB_TABLE_TRACKS, MediaType.TRACK),
+                (DB_TABLE_ALBUMS, MediaType.ALBUM),
+                (DB_TABLE_ARTISTS, MediaType.ARTIST),
+                (DB_TABLE_PLAYLISTS, MediaType.PLAYLIST),
+                (DB_TABLE_RADIOS, MediaType.RADIO),
+                (DB_TABLE_AUDIOBOOKS, MediaType.AUDIOBOOK),
+                (DB_TABLE_PODCASTS, MediaType.PODCAST),
+            )
+
+            # 2a: Extract all unique raw genre names from metadata
+            union_parts = [
+                f"SELECT DISTINCT TRIM(g.value) AS raw_name "
+                f"FROM {table}, json_each(json_extract({table}.metadata, '$.genres')) AS g "
+                f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
+                f"AND json_extract({table}.metadata, '$.genres') != '[]'"
+                for table, _ in media_tables
+            ]
+            unique_names_sql = " UNION ".join(union_parts)
+            self.logger.info("Genre migration - unique names query:\n%s", unique_names_sql)
+            async with db.execute(unique_names_sql) as cursor:
+                unique_raw_names = [row[0] for row in await cursor.fetchall() if row[0]]
+            self.logger.info(
+                "Genre migration - discovered %d unique genre names", len(unique_raw_names)
+            )
+
+            # 2b: Ensure genres exist for all discovered names.
+            # Names already covered by Phase 1 aliases just reuse those genre(s).
+            # New names get their own genre. One alias can map to multiple genres (n:n).
+            raw_name_to_genres: dict[str, list[int]] = {}
+            for raw_name in unique_raw_names:
+                norm = create_safe_string(raw_name.strip(), True, True)
+                if not norm:
+                    continue
+                if norm in alias_to_genre:
+                    raw_name_to_genres[raw_name] = list(alias_to_genre[norm])
+                    self.logger.debug(
+                        "Genre migration - resolved %r -> genre_ids %s (alias match)",
+                        raw_name,
+                        alias_to_genre[norm],
+                    )
+                else:
+                    genre_id = await _get_or_create_genre(raw_name)
+                    if genre_id:
+                        raw_name_to_genres[raw_name] = [genre_id]
+                        alias_to_genre[norm] = [genre_id]
+                        self.logger.debug(
+                            "Genre migration - resolved %r -> genre_id %d (new genre)",
+                            raw_name,
+                            genre_id,
+                        )
+            await db.commit()
+            self.logger.info(
+                "Genre migration - resolved %d unique genre names", len(raw_name_to_genres)
+            )
+
+            # 2c: Add discovered raw names as aliases to their resolved genres
+            # so that frontend searches by raw name find the parent genre.
+            genre_new_aliases: dict[int, list[str]] = {}
+            for raw_name, gids in raw_name_to_genres.items():
+                for gid in gids:
+                    genre_new_aliases.setdefault(gid, []).append(raw_name)
+            for gid, new_aliases in genre_new_aliases.items():
+                async with db.execute(
+                    f"SELECT genre_aliases FROM {DB_TABLE_GENRES} WHERE item_id = :gid",
+                    {"gid": gid},
+                ) as cursor:
+                    row = await cursor.fetchone()
+                if not row:
+                    continue
+                existing = json_loads(row[0]) if row[0] else []
+                existing_norms = {create_safe_string(a, True, True) for a in existing}
+                to_add = [
+                    a
+                    for a in new_aliases
+                    if create_safe_string(a, True, True) not in existing_norms
+                ]
+                if to_add:
+                    merged = existing + to_add
+                    await db.execute(
+                        f"UPDATE {DB_TABLE_GENRES} SET genre_aliases = :aliases "
+                        "WHERE item_id = :gid",
+                        {"aliases": json_dumps(merged), "gid": gid},
+                    )
+            await db.commit()
+
+            # 2d: Build CTE with (raw_name, genre_id) and do one INSERT per
+            # media type using json_each to map media items directly to genres.
+            # One raw_name can map to multiple genre_ids (n:n).
+            if raw_name_to_genres:
+                cte_values = ", ".join(
+                    f"(LOWER('{name.replace(chr(39), chr(39) + chr(39))}'), {gid})"
+                    for name, gids in raw_name_to_genres.items()
+                    for gid in gids
+                )
+                cte = f"WITH genre_lookup(raw_name, genre_id) AS (VALUES {cte_values})"
+
+                for table, media_type in media_tables:
+                    full_query = (
+                        f"{cte} INSERT OR REPLACE INTO {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING}"
+                        f"(genre_id, media_id, media_type, alias) "
+                        f"SELECT gl.genre_id, {table}.item_id, "
+                        f"'{media_type.value}', TRIM(g.value) "
+                        f"FROM {table}, "
+                        f"json_each(json_extract({table}.metadata, '$.genres')) AS g "
+                        f"JOIN genre_lookup gl ON gl.raw_name = LOWER(TRIM(g.value)) "
+                        f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
+                        f"AND json_extract({table}.metadata, '$.genres') != '[]'"
+                    )
+                    self.logger.info(
+                        "Genre migration - %s query:\n%s", media_type.value, full_query
+                    )
+                    await db.execute(full_query)
+                    await db.commit()
+
         # save changes
         await self._database.commit()
 
@@ -2432,6 +2644,37 @@ class MusicController(CoreController):
             [search_sort_name] TEXT NOT NULL
             );"""
         )
+        await self.database.execute(
+            f"""
+            CREATE TABLE IF NOT EXISTS {DB_TABLE_GENRES}(
+            [item_id] INTEGER PRIMARY KEY AUTOINCREMENT,
+            [name] TEXT NOT NULL,
+            [sort_name] TEXT NOT NULL,
+            [translation_key] TEXT,
+            [description] TEXT,
+            [favorite] BOOLEAN NOT NULL DEFAULT 0,
+            [metadata] json NOT NULL,
+            [external_ids] json NOT NULL,
+            [genre_aliases] json NOT NULL DEFAULT '[]',
+            [play_count] INTEGER NOT NULL DEFAULT 0,
+            [last_played] INTEGER NOT NULL DEFAULT 0,
+            [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
+            [timestamp_modified] INTEGER NOT NULL DEFAULT 0,
+            [search_name] TEXT NOT NULL,
+            [search_sort_name] TEXT NOT NULL
+            );"""
+        )
+        await self.database.execute(
+            f"""
+            CREATE TABLE IF NOT EXISTS {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING}(
+            [genre_id] INTEGER NOT NULL,
+            [media_id] INTEGER NOT NULL,
+            [media_type] TEXT NOT NULL,
+            [alias] TEXT NOT NULL,
+            FOREIGN KEY([genre_id]) REFERENCES [genres]([item_id]),
+            UNIQUE(genre_id, media_id, media_type)
+            );"""
+        )
         await self.database.execute(
             f"""
             CREATE TABLE IF NOT EXISTS {DB_TABLE_ALBUM_TRACKS}(
@@ -2520,6 +2763,7 @@ class MusicController(CoreController):
             DB_TABLE_RADIOS,
             DB_TABLE_AUDIOBOOKS,
             DB_TABLE_PODCASTS,
+            DB_TABLE_GENRES,
         ):
             # index on favorite column
             await self.database.execute(
@@ -2618,6 +2862,15 @@ class MusicController(CoreController):
             f"CREATE INDEX IF NOT EXISTS {DB_TABLE_SMART_FADES_ANALYSIS}_idx "
             f"on {DB_TABLE_SMART_FADES_ANALYSIS}(item_id,provider,fragment);"
         )
+        # indexes on genre_media_item_mapping table
+        await self.database.execute(
+            f"CREATE INDEX IF NOT EXISTS {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING}_media_idx "
+            f"on {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING}(media_id,media_type);"
+        )
+        await self.database.execute(
+            f"CREATE INDEX IF NOT EXISTS {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING}_genre_alias_idx "
+            f"on {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING}(genre_id,alias);"
+        )
         # unique index on playlog table
         await self.database.execute(
             f"CREATE UNIQUE INDEX IF NOT EXISTS {DB_TABLE_PLAYLOG}_unique_idx "
@@ -2636,6 +2889,7 @@ class MusicController(CoreController):
             "radios",
             "audiobooks",
             "podcasts",
+            "genres",
         ):
             await self.database.execute(
                 f"""
index 3dd3a8270c961d2b8b3264a4956b454e98eba611..99795ccabff91c52b015c1e10017621ddb3fcb38 100644 (file)
@@ -47,6 +47,7 @@ from music_assistant_models.media_items import (
     Artist,
     Audiobook,
     BrowseFolder,
+    Genre,
     ItemMapping,
     MediaItemType,
     PlayableMediaItemType,
@@ -96,6 +97,7 @@ ENQUEUE_SELECT_ALBUM_DEFAULT_VALUE = "all_tracks"
 CONF_DEFAULT_ENQUEUE_OPTION_ARTIST = "default_enqueue_option_artist"
 CONF_DEFAULT_ENQUEUE_OPTION_ALBUM = "default_enqueue_option_album"
 CONF_DEFAULT_ENQUEUE_OPTION_TRACK = "default_enqueue_option_track"
+CONF_DEFAULT_ENQUEUE_OPTION_GENRE = "default_enqueue_option_genre"
 CONF_DEFAULT_ENQUEUE_OPTION_RADIO = "default_enqueue_option_radio"
 CONF_DEFAULT_ENQUEUE_OPTION_PLAYLIST = "default_enqueue_option_playlist"
 CONF_DEFAULT_ENQUEUE_OPTION_AUDIOBOOK = "default_enqueue_option_audiobook"
@@ -225,6 +227,14 @@ class PlayerQueuesController(CoreController):
                 options=enqueue_options,
                 description="Define the default enqueue action for this mediatype.",
             ),
+            ConfigEntry(
+                key=CONF_DEFAULT_ENQUEUE_OPTION_GENRE,
+                type=ConfigEntryType.STRING,
+                default_value=QueueOption.REPLACE.value,
+                label="Default enqueue option for Genre item(s).",
+                options=enqueue_options,
+                description="Define the default enqueue action for this mediatype.",
+            ),
             ConfigEntry(
                 key=CONF_DEFAULT_ENQUEUE_OPTION_RADIO,
                 type=ConfigEntryType.STRING,
@@ -1609,6 +1619,45 @@ class PlayerQueuesController(CoreController):
             result.append(album_track)
         return result
 
+    async def get_genre_tracks(self, genre: Genre, start_item: str | None) -> list[Track]:
+        """Return tracks for given genre, based on alias mappings.
+
+        Limits results to avoid loading thousands of tracks for broad genres.
+        Directly mapped tracks are fetched with random ordering, then supplemented
+        with tracks from a limited set of mapped albums and artists.
+        """
+        result: list[Track] = []
+        start_item_found = False
+        self.logger.info(
+            "Fetching tracks to play for genre %s",
+            genre.name,
+        )
+        tracks, albums, artists = await self.mass.music.genres.mapped_media(
+            genre,
+            track_limit=25,
+            album_limit=5,
+            artist_limit=5,
+            order_by="random",
+        )
+
+        for genre_track in tracks:
+            if not genre_track.available:
+                continue
+            if start_item in (genre_track.item_id, genre_track.uri):
+                start_item_found = True
+            if start_item is not None and not start_item_found:
+                continue
+            result.append(genre_track)
+
+        for album in albums:
+            album_tracks = await self.get_album_tracks(album, None)
+            result.extend(album_tracks[:5])
+
+        for artist in artists:
+            artist_tracks = await self.get_artist_tracks(artist)
+            result.extend(artist_tracks[:5])
+        return result
+
     async def get_playlist_tracks(
         self, playlist: Playlist, start_item: str | None
     ) -> list[PlaylistPlayableItem]:
@@ -1912,6 +1961,14 @@ class PlayerQueuesController(CoreController):
                 )
             )
             return list(await self.get_album_tracks(media_item, start_item))
+        if media_item.media_type == MediaType.GENRE:
+            media_item = cast("Genre", media_item)
+            self.mass.create_task(
+                self.mass.music.mark_item_played(
+                    media_item, userid=userid, queue_id=queue_id, user_initiated=True
+                )
+            )
+            return list(await self.get_genre_tracks(media_item, start_item))
         if media_item.media_type == MediaType.AUDIOBOOK:
             media_item = cast("Audiobook", media_item)
             # ensure we grab the correct/latest resume point info
diff --git a/music_assistant/helpers/resources/genre_mapping.json b/music_assistant/helpers/resources/genre_mapping.json
new file mode 100644 (file)
index 0000000..750f996
--- /dev/null
@@ -0,0 +1,2010 @@
+[
+  {
+    "genre": "afrobeats (West African urban/pop music)",
+    "translation_key": "afrobeats",
+    "aliases": [
+      "africa",
+      "african / arabic / bollywood and desi",
+      "african music",
+      "afro",
+      "afro / caribbean",
+      "afrobeat",
+      "afrobeats",
+      "afropiano",
+      "alté",
+      "world"
+    ]
+  },
+  {
+    "genre": "ambient",
+    "translation_key": "ambient",
+    "aliases": [
+      "ambient / wellness",
+      "ambient americana",
+      "ambient dub",
+      "ambient/new age",
+      "electronic",
+      "kankyō ongaku",
+      "new age",
+      "sound therapy / sleep",
+      "space ambient",
+      "tribal ambient"
+    ]
+  },
+  {
+    "genre": "anime & video game music",
+    "translation_key": "anime_and_video_game_music",
+    "aliases": [
+      "anime",
+      "anime/video game",
+      "j-pop",
+      "japanese music",
+      "movies & series",
+      "soundtracks and musicals",
+      "video game music",
+      "video games"
+    ]
+  },
+  {
+    "genre": "asian music",
+    "translation_key": "asian_music",
+    "aliases": [
+      "anime",
+      "asia",
+      "bollywood and desi",
+      "j-pop",
+      "japanese music",
+      "k-pop",
+      "korean music",
+      "mandopop & cantopop"
+    ]
+  },
+  {
+    "genre": "bluegrass",
+    "translation_key": "bluegrass",
+    "aliases": [
+      "bluegrass gospel",
+      "jamgrass",
+      "progressive bluegrass"
+    ]
+  },
+  {
+    "genre": "blues",
+    "translation_key": "blues",
+    "aliases": [
+      "acoustic blues",
+      "african blues",
+      "blues rock",
+      "boogie rock",
+      "boogie-woogie",
+      "classic blues",
+      "country blues",
+      "delta blues",
+      "desert blues",
+      "electric blues",
+      "electric texas blues",
+      "jazz blues",
+      "jug band",
+      "jump blues",
+      "modern blues",
+      "piano blues",
+      "piedmont blues",
+      "soul blues",
+      "swamp blues"
+    ]
+  },
+  {
+    "genre": "brazilian music",
+    "translation_key": "brazilian_music",
+    "aliases": [
+      "bossa nova",
+      "brazil",
+      "brazilian funk",
+      "forro",
+      "forró",
+      "funk",
+      "funk brasileiro",
+      "latin",
+      "mpb",
+      "pagode",
+      "samba",
+      "sertanejo"
+    ]
+  },
+  {
+    "genre": "chanson",
+    "translation_key": "chanson",
+    "aliases": [
+      "chanson française",
+      "decades / pop",
+      "french music",
+      "pop",
+      "retro french music"
+    ]
+  },
+  {
+    "genre": "children's music",
+    "translation_key": "childrens_music",
+    "aliases": [
+      "batonebi songs",
+      "children",
+      "family",
+      "kids",
+      "kids & family",
+      "kids / family",
+      "lullaby",
+      "stories and nursery rhymes"
+    ]
+  },
+  {
+    "genre": "christmas music",
+    "translation_key": "christmas_music",
+    "aliases": [
+      "holiday"
+    ]
+  },
+  {
+    "genre": "church music",
+    "translation_key": "church_music",
+    "aliases": [
+      "ambrosian chant",
+      "anglican chant",
+      "beneventan chant",
+      "byzantine chant",
+      "cantatas (sacred)",
+      "celtic chant",
+      "choirs (sacred)",
+      "christian & gospel",
+      "christian / gospel",
+      "classical",
+      "gallican chant",
+      "gospel",
+      "gospel / christian",
+      "gregorian chant",
+      "kontakion",
+      "mozarabic chant",
+      "old roman chant",
+      "plainchant",
+      "russian orthodox liturgical music",
+      "sacred vocal music",
+      "sarum chant",
+      "sticheron",
+      "troparion",
+      "zema",
+      "znamenny chant"
+    ]
+  },
+  {
+    "genre": "classical",
+    "translation_key": "classical",
+    "aliases": [
+      "alternative",
+      "ambrosian chant",
+      "ars antiqua",
+      "ars nova",
+      "ars subtilior",
+      "art song",
+      "art songs",
+      "art songs, mélodies & lieder",
+      "bagatelle",
+      "ballad opera",
+      "ballet",
+      "ballet de cour",
+      "ballets",
+      "baroque",
+      "baroque suite",
+      "beneventan chant",
+      "brass band",
+      "british brass band",
+      "burmese classical",
+      "cantata",
+      "cantatas (secular)",
+      "canzona",
+      "capriccio",
+      "cello concertos",
+      "cello solos",
+      "celtic chant",
+      "chamber music",
+      "character piece",
+      "choirs (sacred)",
+      "choral music",
+      "choral music (choirs)",
+      "choral symphony",
+      "christian & gospel",
+      "christian / gospel",
+      "cinematic classical",
+      "circus march",
+      "classical period",
+      "comédie-ballet",
+      "concert band",
+      "concertina band",
+      "concerto",
+      "concerto for orchestra",
+      "concerto grosso",
+      "concertos",
+      "concertos for trumpet",
+      "concertos for wind instruments",
+      "contemporary classical",
+      "dechovka",
+      "divertissement",
+      "duets",
+      "electronic",
+      "english pastoral school",
+      "experimental",
+      "expressionism",
+      "fantasia",
+      "fugue",
+      "full operas",
+      "funeral march",
+      "futurism",
+      "gallican chant",
+      "gamelan",
+      "gospel",
+      "gospel / christian",
+      "grand opera",
+      "gregorian chant",
+      "holy minimalism",
+      "impressionism",
+      "impromptu",
+      "indeterminacy",
+      "integral serialism",
+      "iraqi maqam",
+      "islamic modal music",
+      "j-pop",
+      "japanese classical",
+      "japanese music",
+      "japanese traditional",
+      "k-pop",
+      "kacapi suling",
+      "keyboard concertos",
+      "korean classical",
+      "korean traditional",
+      "kulintang",
+      "lied",
+      "lieder (german)",
+      "lute song",
+      "madrigal",
+      "mahori",
+      "march",
+      "mass",
+      "masses, passions, requiems",
+      "medieval",
+      "medieval lyric poetry",
+      "microtonal classical",
+      "minimal music",
+      "minimalism",
+      "modern classical",
+      "monodrama",
+      "motet",
+      "mozarabic chant",
+      "mugham",
+      "music by vocal ensembles",
+      "musique concrète instrumentale",
+      "mélodie",
+      "mélodies",
+      "neoclassicism",
+      "new complexity",
+      "nocturne",
+      "old roman chant",
+      "opera",
+      "opera buffa",
+      "opera extracts",
+      "opera semiseria",
+      "opera seria",
+      "operetta",
+      "operettas",
+      "opéra comique",
+      "oratorio",
+      "oratorios (secular)",
+      "orchestral",
+      "orchestral song",
+      "overture",
+      "overtures",
+      "passion setting",
+      "pinpeat",
+      "plainchant",
+      "post-classical",
+      "post-minimalism",
+      "prelude",
+      "process music",
+      "quartets",
+      "quintets",
+      "renaissance",
+      "requiem",
+      "ricercar",
+      "romantic classical",
+      "romantische oper",
+      "russian romance",
+      "saluang klasik",
+      "sarum chant",
+      "sawt",
+      "secular vocal music",
+      "serenade",
+      "serialism",
+      "sinfonia concertante",
+      "singspiel",
+      "solo piano",
+      "sonata",
+      "sonorism",
+      "soundtracks and musicals",
+      "soundtracks and musicals / classical",
+      "southeast asian classical",
+      "spectralism",
+      "stochastic music",
+      "string quartet",
+      "symphonic mugham",
+      "symphonic music",
+      "symphonic poem",
+      "symphonic poems",
+      "symphonies",
+      "symphony",
+      "talempong",
+      "tembang cianjuran",
+      "thai classical",
+      "theme and variations",
+      "third stream",
+      "toccata",
+      "totalism",
+      "tragédie en musique",
+      "trios",
+      "turkish classical",
+      "uyghur muqam",
+      "verismo",
+      "violin concertos",
+      "violin solos",
+      "vocal music",
+      "vocal music (secular and sacred)",
+      "vocal recitals",
+      "western classical",
+      "zarzuela",
+      "zeitoper",
+      "étude"
+    ]
+  },
+  {
+    "genre": "comedy",
+    "translation_key": "comedy",
+    "aliases": [
+      "bawdy songs",
+      "break-in",
+      "humour",
+      "prank calls",
+      "sketch comedy",
+      "standup comedy"
+    ]
+  },
+  {
+    "genre": "country",
+    "translation_key": "country",
+    "aliases": [
+      "alternative country",
+      "americana",
+      "bro-country",
+      "classic country",
+      "close harmony",
+      "contemporary country",
+      "cosmic country",
+      "country and americana",
+      "country boogie",
+      "country folk",
+      "country gospel",
+      "country pop",
+      "country rock",
+      "country soul",
+      "countrypolitan",
+      "folk & acoustic",
+      "folk / americana",
+      "gothic country",
+      "honky tonk",
+      "neo-traditional country",
+      "north america",
+      "outlaw country",
+      "progressive country",
+      "traditional country",
+      "urban cowboy",
+      "western swing"
+    ]
+  },
+  {
+    "genre": "dance",
+    "translation_key": "dance",
+    "aliases": [
+      "alternative dance",
+      "bubblegum dance",
+      "dance & edm",
+      "dance & electronic",
+      "dance and electronic",
+      "dance/electronic",
+      "electro",
+      "electronic",
+      "electronic / dance",
+      "eurobeat",
+      "eurodance",
+      "italo dance",
+      "j-euro",
+      "madchester",
+      "new rave",
+      "skweee"
+    ]
+  },
+  {
+    "genre": "dark ambient",
+    "translation_key": "dark_ambient",
+    "aliases": [
+      "black ambient",
+      "ritual ambient"
+    ]
+  },
+  {
+    "genre": "dark wave",
+    "translation_key": "dark_wave",
+    "aliases": [
+      "ethereal wave",
+      "neoclassical dark wave"
+    ]
+  },
+  {
+    "genre": "disco",
+    "translation_key": "disco",
+    "aliases": [
+      "boogie",
+      "decades / r&b and soul",
+      "electro-disco",
+      "euro-disco",
+      "funk & disco",
+      "hi-nrg",
+      "italo-disco",
+      "latin disco",
+      "red disco",
+      "soul & funk / dance & edm",
+      "soul/funk",
+      "space disco"
+    ]
+  },
+  {
+    "genre": "electronic",
+    "translation_key": "electronic",
+    "aliases": [
+      "acid breaks",
+      "acid house",
+      "acid techno",
+      "acid trance",
+      "acidcore",
+      "acousmatic",
+      "afro house",
+      "algorave",
+      "amapiano",
+      "ambient house",
+      "ambient techno",
+      "ambient trance",
+      "amigacore",
+      "aquacrunk",
+      "artcore",
+      "atmospheric drum and bass",
+      "autonomic",
+      "balani show",
+      "balearic beat",
+      "balearic trance",
+      "ballroom house",
+      "baltimore club",
+      "barber beats",
+      "bass house",
+      "belgian techno",
+      "berlin school",
+      "big beat",
+      "big room house",
+      "big room trance",
+      "birmingham sound",
+      "bit music",
+      "bitpop",
+      "black midi",
+      "bleep techno",
+      "bouncy techno",
+      "brazilian bass",
+      "breakbeat",
+      "breakbeat hardcore",
+      "breakbeat kota",
+      "breakcore",
+      "briddim",
+      "broken beat",
+      "broken transmission",
+      "brostep",
+      "bubblegum bass",
+      "bubbling",
+      "buchiage trance",
+      "budots",
+      "bytebeat",
+      "bérite club",
+      "celtic electronica",
+      "changa tuki",
+      "chicago house",
+      "chill",
+      "chill-out",
+      "chillout",
+      "chillstep",
+      "chillsynth",
+      "chillwave",
+      "chiptune",
+      "club",
+      "colour bass",
+      "comfy synth",
+      "complextro",
+      "coupé-décalé",
+      "crossbreed",
+      "cruise",
+      "crunkcore",
+      "dance",
+      "dance & edm",
+      "dance & electronic",
+      "dance and electronic",
+      "dance/electronic",
+      "dancefloor drum and bass",
+      "dariacore",
+      "dark disco",
+      "dark psytrance",
+      "darkcore",
+      "darkcore edm",
+      "darkstep",
+      "darksynth",
+      "deathchant hardcore",
+      "deathstep",
+      "deconstructed club",
+      "deep drum and bass",
+      "deep house",
+      "deep tech",
+      "deep techno",
+      "detroit techno",
+      "digital cumbia",
+      "digital fusion",
+      "digital hardcore",
+      "diva house",
+      "donk",
+      "doomcore",
+      "doskpop",
+      "downtempo",
+      "dream trance",
+      "dreampunk",
+      "drift phonk",
+      "drill and bass",
+      "drum & bass",
+      "drum and bass",
+      "drumfunk",
+      "drumstep",
+      "dub techno",
+      "dubstep",
+      "dubstyle",
+      "dubwise",
+      "dungeon sound",
+      "dungeon synth",
+      "dutch house",
+      "early hardstyle",
+      "eccojams",
+      "edm",
+      "electro house",
+      "electro latino",
+      "electro swing",
+      "electroacoustic",
+      "electroclash",
+      "electronic / dance",
+      "electronic rock",
+      "electronica",
+      "electronicore",
+      "electrotango",
+      "euphoric hardstyle",
+      "euro house",
+      "euro-trance",
+      "experimental electronic",
+      "extratone",
+      "festival progressive house",
+      "festival trap",
+      "fidget house",
+      "flashcore",
+      "florida breaks",
+      "fm synthesis",
+      "folktronica",
+      "footwork",
+      "footwork jungle",
+      "forest psytrance",
+      "frapcore",
+      "free tekno",
+      "freeform hardcore",
+      "freestyle",
+      "french electro",
+      "french house",
+      "frenchcore",
+      "full-on",
+      "funkot",
+      "funktronica",
+      "funky breaks",
+      "funky house",
+      "future bass",
+      "future bounce",
+      "future core",
+      "future funk",
+      "future garage",
+      "future house",
+      "future rave",
+      "future riddim",
+      "futurepop",
+      "g-house",
+      "gabber",
+      "garage house",
+      "ghetto funk",
+      "ghetto house",
+      "ghettotech",
+      "glitch",
+      "glitch hop",
+      "glitch hop edm",
+      "glitch pop",
+      "goa trance",
+      "gospel house",
+      "gqom",
+      "graphical sound",
+      "grime",
+      "halftime",
+      "hands up",
+      "happy hardcore",
+      "hard drum",
+      "hard house",
+      "hard nrg",
+      "hard techno",
+      "hard trance",
+      "hard trap",
+      "hardbag",
+      "hardbass",
+      "hardcore breaks",
+      "hardcore techno",
+      "hardgroove techno",
+      "hardstep",
+      "hardstyle",
+      "hardvapour",
+      "hardwave",
+      "heaven trap",
+      "hexd",
+      "hi-tech",
+      "hi-tech full-on",
+      "hip house",
+      "hip-hop / r&b",
+      "hip-hop / r&b and soul",
+      "horror synth",
+      "house",
+      "hybrid trap",
+      "hyper techno (Italo-Japanese 1990s genre)",
+      "hypertechno (2020s genre)",
+      "idm",
+      "illbient",
+      "indietronica",
+      "industrial hardcore",
+      "industrial techno",
+      "italo house",
+      "j-core",
+      "jackin house",
+      "jazz house",
+      "jazzstep",
+      "jersey club",
+      "jersey sound",
+      "juke",
+      "jump up",
+      "jumpstyle",
+      "jungle",
+      "jungle terror",
+      "kawaii future bass",
+      "krushclub",
+      "kuduro",
+      "kwaito",
+      "latin house",
+      "leftfield",
+      "lento violento",
+      "liquid funk",
+      "liquid riddim",
+      "lo-fi house",
+      "lolicore",
+      "lounge",
+      "makina",
+      "mallsoft",
+      "manyao",
+      "mashcore",
+      "melbourne bounce",
+      "melodic bass",
+      "melodic dubstep",
+      "melodic house",
+      "melodic techno",
+      "melodic trance",
+      "microfunk",
+      "microhouse",
+      "microsound",
+      "midtempo bass",
+      "minatory",
+      "minimal drum and bass",
+      "minimal synth",
+      "minimal techno",
+      "minimal wave",
+      "modern hardtek",
+      "moogsploitation",
+      "moombahcore",
+      "moombahton",
+      "musique concrète",
+      "neo-grime",
+      "nerdcore techno",
+      "neurofunk",
+      "neurohop",
+      "night full-on",
+      "nightcore",
+      "nortec",
+      "nu disco",
+      "nu jazz",
+      "nu skool breaks",
+      "nu style gabber",
+      "nustyle",
+      "organic house",
+      "ori deck",
+      "outsider house",
+      "peak time techno",
+      "philly club",
+      "phonk house",
+      "post-dubstep",
+      "powerstomp",
+      "progressive breaks",
+      "progressive electronic",
+      "progressive house",
+      "progressive psytrance",
+      "progressive trance",
+      "psybient",
+      "psybreaks",
+      "psycore",
+      "psystyle",
+      "psytrance",
+      "pumpcore",
+      "purple sound",
+      "ragga jungle",
+      "raggacore",
+      "raggatek",
+      "rap / electronic",
+      "rave",
+      "rawphoric",
+      "rawstyle",
+      "riddim dubstep",
+      "rominimal",
+      "sambass",
+      "sampledelia",
+      "schranz",
+      "seapunk",
+      "shangaan electro",
+      "singeli",
+      "skullstep",
+      "slap house",
+      "slimepunk",
+      "slushwave",
+      "sovietwave",
+      "spacesynth",
+      "speed garage",
+      "speed house",
+      "speedcore",
+      "splittercore",
+      "stutter house",
+      "suomisaundi",
+      "synthwave",
+      "tearout (older dubstep subgenre)",
+      "tearout brostep",
+      "tech house",
+      "tech trance",
+      "techno",
+      "technoid",
+      "techstep",
+      "terrorcore",
+      "trance",
+      "trancestep",
+      "trap edm",
+      "tribal guarachero",
+      "tribal house",
+      "trip hop",
+      "tropical house",
+      "twerk",
+      "uk funky",
+      "uk garage",
+      "uk hardcore",
+      "uk jackin",
+      "uptempo hardcore",
+      "utopian virtual",
+      "vapornoise",
+      "vaportrap",
+      "vaporwave",
+      "vinahouse",
+      "vocal house",
+      "vocal trance",
+      "wave",
+      "weightless",
+      "west coast breaks",
+      "winter synth",
+      "witch house",
+      "wonky",
+      "wonky techno",
+      "zenonesque"
+    ]
+  },
+  {
+    "genre": "experimental",
+    "translation_key": "experimental",
+    "aliases": [
+      "alternative",
+      "ambient noise wall",
+      "black noise",
+      "classical",
+      "conducted improvisation",
+      "data sonification",
+      "electronic / classical",
+      "harsh noise",
+      "harsh noise wall",
+      "lowercase",
+      "mad",
+      "musique concrète",
+      "noise",
+      "onkyo",
+      "plunderphonics",
+      "power electronics",
+      "reductionism",
+      "sound art",
+      "sound collage",
+      "spamwave",
+      "tape music",
+      "ytpmv"
+    ]
+  },
+  {
+    "genre": "field recording",
+    "translation_key": "field_recording",
+    "aliases": [
+      "animal sounds",
+      "birdsong",
+      "nature sounds",
+      "rain sounds",
+      "whale song"
+    ]
+  },
+  {
+    "genre": "folk",
+    "translation_key": "folk",
+    "aliases": [
+      "aboio",
+      "alternative folk",
+      "american primitive guitar",
+      "anti-folk",
+      "appalachian folk",
+      "avant-folk",
+      "bagad",
+      "baguala",
+      "biraha",
+      "cape breton fiddling",
+      "celtic",
+      "chamber folk",
+      "contemporary folk",
+      "country folk",
+      "dark folk",
+      "desert blues",
+      "falak",
+      "fife and drum blues",
+      "fijiri",
+      "filk",
+      "folk & acoustic",
+      "folk & singer-songwriter",
+      "folk / americana",
+      "folk and acoustic",
+      "folk pop",
+      "freak folk",
+      "free folk",
+      "gypsy",
+      "gypsy music",
+      "haozi",
+      "hungarian folk",
+      "indie folk",
+      "industrial folk song",
+      "ireland",
+      "irish celtic",
+      "irish folk",
+      "isa",
+      "loner folk",
+      "ländlermusik",
+      "manele",
+      "música criolla",
+      "neo-medieval folk",
+      "neofolk",
+      "neofolklore",
+      "new mexico music",
+      "néo-trad",
+      "old-time",
+      "pagan folk",
+      "progressive folk",
+      "psychedelic folk",
+      "scottish",
+      "scottish country dance music",
+      "scrumpy and western",
+      "sea shanty",
+      "seguidilla",
+      "sevdalinka",
+      "sevillanas",
+      "shan'ge",
+      "skiffle",
+      "stomp and holler",
+      "stornello",
+      "sutartinės",
+      "swiss folk music",
+      "tajaraste",
+      "talking blues",
+      "tallava",
+      "tarantella",
+      "tonada asturiana",
+      "trampská hudba",
+      "trikitixa",
+      "turbo-folk",
+      "turkish folk",
+      "udigrudi",
+      "visa",
+      "volksmusik",
+      "waulking song",
+      "white voice",
+      "work song",
+      "world fusion",
+      "wyrd folk",
+      "xuc",
+      "yodeling"
+    ]
+  },
+  {
+    "genre": "funk",
+    "translation_key": "funk",
+    "aliases": [
+      "acid jazz",
+      "afro-funk",
+      "afrobeat (funk/soul + West African sounds)",
+      "bounce beat",
+      "deep funk",
+      "electro-funk",
+      "funk & disco",
+      "funk metal",
+      "funk rock",
+      "go-go",
+      "jazz / r&b and soul",
+      "latin funk",
+      "minneapolis sound",
+      "p-funk",
+      "r&b",
+      "r&b / soul",
+      "r&b / soul/funk",
+      "r&b and soul",
+      "soul",
+      "soul & funk",
+      "soul / r&b",
+      "soul/funk",
+      "soul/funk/r&b",
+      "synth funk"
+    ]
+  },
+  {
+    "genre": "gangsta rap",
+    "translation_key": "gangsta_rap",
+    "aliases": [
+      "coke rap",
+      "scam rap"
+    ]
+  },
+  {
+    "genre": "gospel",
+    "translation_key": "gospel",
+    "aliases": [
+      "cantata",
+      "cantatas (sacred)",
+      "cantatas (secular)",
+      "choirs (sacred)",
+      "choral music",
+      "choral music (choirs)",
+      "christian & gospel",
+      "christian / gospel",
+      "church music",
+      "classical",
+      "contemporary gospel",
+      "gospel / christian",
+      "mass",
+      "masses, passions, requiems",
+      "music by vocal ensembles",
+      "oratorio",
+      "oratorios (secular)",
+      "passion setting",
+      "praise break",
+      "requiem",
+      "sacred steel",
+      "sacred vocal music",
+      "southern gospel",
+      "traditional black gospel",
+      "urban contemporary gospel"
+    ]
+  },
+  {
+    "genre": "hip hop",
+    "translation_key": "hip_hop",
+    "aliases": [
+      "abstract hip hop",
+      "acid jazz",
+      "alternative hip hop",
+      "battle rap",
+      "battle record",
+      "britcore",
+      "chap hop",
+      "chicago bop",
+      "chicano rap",
+      "chipmunk soul",
+      "chopped and screwed",
+      "chopper",
+      "christian hip hop",
+      "cloud rap",
+      "comedy hip hop",
+      "conscious hip hop",
+      "crunk",
+      "crunkcore",
+      "digicore",
+      "drumless hip hop",
+      "dungeon rap",
+      "electronic",
+      "emo rap",
+      "experimental hip hop",
+      "frat rap",
+      "g-funk",
+      "glitch hop",
+      "hardcore hip hop",
+      "hip-hop",
+      "hip-hop / r&b",
+      "hip-hop / r&b and soul",
+      "hip-hop/rap",
+      "horrorcore",
+      "industrial hip hop",
+      "instrumental hip hop",
+      "jazz rap",
+      "jerk (2020s)",
+      "lo-fi hip hop",
+      "lowend",
+      "memphis rap",
+      "nerdcore",
+      "phonk (older style, a.k.a. rare phonk)",
+      "political hip hop",
+      "pop rap",
+      "punk rap",
+      "ragga hip-hop",
+      "rap",
+      "rap / electronic",
+      "rap rock",
+      "rapcore",
+      "stoner rap",
+      "trip hop",
+      "turntablism",
+      "underground hip hop",
+      "wonky"
+    ]
+  },
+  {
+    "genre": "indian classical",
+    "translation_key": "indian_classical",
+    "aliases": [
+      "bollywood and desi",
+      "indian music",
+      "world"
+    ]
+  },
+  {
+    "genre": "industrial",
+    "translation_key": "industrial",
+    "aliases": [
+      "aggrotech",
+      "cyber metal",
+      "dark electro",
+      "death industrial",
+      "ebm",
+      "electro-industrial",
+      "epic collage",
+      "industrial metal",
+      "industrial rock",
+      "martial industrial",
+      "new beat",
+      "post-industrial",
+      "power noise"
+    ]
+  },
+  {
+    "genre": "jazz",
+    "translation_key": "jazz",
+    "aliases": [
+      "acid jazz",
+      "afro-cuban jazz",
+      "afro-jazz",
+      "afrobeat (funk/soul + West African sounds)",
+      "avant-garde jazz",
+      "bebop",
+      "big band",
+      "classic jazz",
+      "contemporary jazz",
+      "cool jazz",
+      "crime jazz",
+      "crossover",
+      "crossover jazz",
+      "dark jazz",
+      "dixieland",
+      "experimental big band",
+      "free jazz",
+      "free jazz & avant-garde",
+      "gypsy jazz",
+      "hard bop",
+      "indo jazz",
+      "instrumental jazz",
+      "jazz / r&b and soul",
+      "jazz / rock",
+      "jazz / soul",
+      "jazz blues",
+      "jazz fusion",
+      "jazz fusion & jazz rock",
+      "jazz mugham",
+      "jazz rock",
+      "jazz-funk",
+      "latin",
+      "latin jazz",
+      "latin music",
+      "modal jazz",
+      "modern creative",
+      "new orleans r&b",
+      "orchestral jazz",
+      "post-bop",
+      "smooth jazz",
+      "soul jazz",
+      "spiritual jazz",
+      "stride",
+      "sweet jazz",
+      "third stream",
+      "traditional jazz",
+      "traditional jazz & new orleans",
+      "vocal jazz",
+      "vocalese",
+      "world fusion"
+    ]
+  },
+  {
+    "genre": "klezmer",
+    "translation_key": "klezmer",
+    "aliases": [
+      "folk & acoustic",
+      "folk & singer-songwriter",
+      "folk and acoustic",
+      "yiddish & klezmer"
+    ]
+  },
+  {
+    "genre": "latin",
+    "translation_key": "latin",
+    "aliases": [
+      "bachata",
+      "bolero",
+      "boogaloo",
+      "latin jazz",
+      "latin music",
+      "spain",
+      "spanish music"
+    ]
+  },
+  {
+    "genre": "marching band",
+    "translation_key": "marching_band",
+    "aliases": [
+      "beni",
+      "drum and bugle corps",
+      "drumline",
+      "fife and drum",
+      "guggenmusik",
+      "march",
+      "military music",
+      "pep band"
+    ]
+  },
+  {
+    "genre": "metal",
+    "translation_key": "metal",
+    "aliases": [
+      "alternative metal",
+      "atmospheric black metal",
+      "atmospheric sludge metal",
+      "avant-garde metal",
+      "black 'n' roll",
+      "black metal",
+      "blackened death metal",
+      "blackgaze",
+      "brutal death metal",
+      "celtic metal",
+      "christian metal",
+      "crossover thrash",
+      "cyber metal",
+      "cybergrind",
+      "death 'n' roll",
+      "death metal",
+      "death-doom metal",
+      "deathcore",
+      "deathgrind",
+      "depressive black metal",
+      "dissonant black metal",
+      "dissonant death metal",
+      "djent",
+      "doom metal",
+      "doomgaze",
+      "downtempo deathcore",
+      "drone metal",
+      "electronicore",
+      "epic doom metal",
+      "folk metal",
+      "funeral doom metal",
+      "funk metal",
+      "goregrind",
+      "gorenoise",
+      "gothic metal",
+      "grindcore",
+      "groove metal",
+      "hard rock",
+      "heavy metal",
+      "industrial metal",
+      "kawaii metal",
+      "mathcore",
+      "medieval metal",
+      "melodic black metal",
+      "melodic death metal",
+      "melodic metalcore",
+      "metalcore",
+      "mincecore",
+      "neoclassical metal",
+      "neue deutsche härte",
+      "noisegrind",
+      "nu metal",
+      "nwobhm",
+      "old school death metal",
+      "pagan black metal",
+      "pop metal",
+      "post-metal",
+      "power metal",
+      "progressive metal",
+      "progressive metalcore",
+      "rap metal",
+      "rock",
+      "rock / indie",
+      "slam death metal",
+      "sludge metal",
+      "southern metal",
+      "speed metal",
+      "stoner metal",
+      "symphonic black metal",
+      "symphonic metal",
+      "technical death metal",
+      "technical thrash metal",
+      "thall",
+      "thrash metal",
+      "traditional doom metal",
+      "trance metal",
+      "us power metal",
+      "viking metal",
+      "war metal"
+    ]
+  },
+  {
+    "genre": "middle eastern music",
+    "translation_key": "middle_eastern_music",
+    "aliases": [
+      "arabic",
+      "oriental music",
+      "world"
+    ]
+  },
+  {
+    "genre": "musical",
+    "translation_key": "musical",
+    "aliases": [
+      "cabaret",
+      "chèo",
+      "film, tv & stage",
+      "industrial musical",
+      "minstrelsy",
+      "movies & series",
+      "murga",
+      "murga uruguaya",
+      "music hall",
+      "musical theatre",
+      "operetta",
+      "operettas",
+      "revue",
+      "rock musical",
+      "soundtracks",
+      "soundtracks and musicals",
+      "theatre music",
+      "tv & films",
+      "vaudeville"
+    ]
+  },
+  {
+    "genre": "new age",
+    "translation_key": "new_age",
+    "aliases": [
+      "ambient",
+      "ambient / wellness",
+      "ambient/new age",
+      "andean new age",
+      "celtic new age",
+      "electronic",
+      "native american new age",
+      "neoclassical new age",
+      "sound therapy / sleep"
+    ]
+  },
+  {
+    "genre": "poetry",
+    "translation_key": "poetry",
+    "aliases": [
+      "beat poetry",
+      "cowboy poetry",
+      "dub poetry",
+      "jazz poetry",
+      "kakawin",
+      "literature",
+      "ngâm thơ",
+      "punk poetry",
+      "slam poetry",
+      "sound poetry",
+      "spoken word"
+    ]
+  },
+  {
+    "genre": "polka",
+    "translation_key": "polka",
+    "aliases": [
+      "chicago polka",
+      "eastern-style polka",
+      "schottische"
+    ]
+  },
+  {
+    "genre": "pop",
+    "translation_key": "pop",
+    "aliases": [
+      "alternative pop",
+      "ambient pop",
+      "art pop",
+      "avant-garde pop",
+      "bardcore",
+      "baroque pop",
+      "beat music",
+      "bedroom pop",
+      "bitpop",
+      "bolero-beat",
+      "brill building",
+      "bro-country",
+      "bubblegum pop",
+      "burmese stereo",
+      "c-pop",
+      "c86",
+      "canción melódica",
+      "chamber pop",
+      "city pop",
+      "classical crossover",
+      "cocktail nation",
+      "contemporary christian",
+      "country pop",
+      "countrypolitan",
+      "crooners",
+      "cuddlecore",
+      "dance-pop",
+      "dansband",
+      "dansktop",
+      "denpa",
+      "donosti sound",
+      "dutch",
+      "dutch music",
+      "eastern europe",
+      "easy listening",
+      "electro hop",
+      "electropop",
+      "europe",
+      "european music",
+      "europop",
+      "exotica",
+      "flamenco pop",
+      "folk & acoustic",
+      "folk & singer-songwriter",
+      "folk / americana",
+      "folk and acoustic",
+      "folk pop",
+      "french artists",
+      "french music",
+      "germany",
+      "hyperpop",
+      "hypnagogic pop",
+      "indian pop",
+      "indie",
+      "indie & alternative",
+      "indie and alternative",
+      "indie pop",
+      "international pop",
+      "irish pop music",
+      "italian pop",
+      "italy",
+      "j-pop",
+      "jangle pop",
+      "japanese music",
+      "jazz pop",
+      "jesus music",
+      "k-pop",
+      "kayōkyoku",
+      "korean ballad",
+      "latin pop",
+      "levenslied",
+      "lounge",
+      "manele",
+      "manila sound",
+      "motown",
+      "mulatós",
+      "música cebolla",
+      "nederpop",
+      "neo-acoustic",
+      "nyū myūjikku",
+      "operatic pop",
+      "opm",
+      "orthodox pop",
+      "palingsound",
+      "persian pop",
+      "pop / afro / latin",
+      "pop / rock",
+      "pop ghazal",
+      "pop kreatif",
+      "pop minang",
+      "pop rock",
+      "pop soul",
+      "pop/rock",
+      "power pop",
+      "praise & worship",
+      "progressive pop",
+      "psychedelic pop",
+      "q-pop",
+      "rock / indie",
+      "rom kbach",
+      "romanian popcorn",
+      "russia",
+      "russian chanson",
+      "russian music",
+      "schlager",
+      "shibuya-kei",
+      "sitarsploitation",
+      "sophisti-pop",
+      "space age pop",
+      "stimmungsmusik",
+      "sundanese pop",
+      "sunshine pop",
+      "synth-pop",
+      "t-pop",
+      "tallava",
+      "tecnorumba",
+      "teen pop",
+      "tin pan alley",
+      "tontipop",
+      "township bubblegum",
+      "toytown pop",
+      "traditional pop",
+      "tropical rock",
+      "tropipop",
+      "turbo-folk",
+      "turkey",
+      "turkish music",
+      "turkish pop",
+      "twee pop",
+      "v-pop",
+      "volksmusik",
+      "volkstümliche musik",
+      "wong shadow",
+      "yé-yé"
+    ]
+  },
+  {
+    "genre": "psychedelic",
+    "translation_key": "psychedelic",
+    "aliases": [
+      "neo-psychedelia",
+      "paisley underground",
+      "psychploitation",
+      "space rock revival"
+    ]
+  },
+  {
+    "genre": "punk",
+    "translation_key": "punk",
+    "aliases": [
+      "alternative / rock",
+      "alternative punk",
+      "anarcho-punk",
+      "art punk",
+      "beatdown hardcore",
+      "blackened crust",
+      "burning spirits",
+      "celtic punk",
+      "christian hardcore",
+      "cowpunk",
+      "crack rock steady",
+      "crossover thrash",
+      "crunkcore",
+      "crust punk",
+      "cybergrind",
+      "d-beat",
+      "deathcore",
+      "deathgrind",
+      "deathrock",
+      "digital hardcore",
+      "downtempo deathcore",
+      "easycore",
+      "electronicore",
+      "electropunk",
+      "emo",
+      "emo pop",
+      "emo rap",
+      "emocore",
+      "emoviolence",
+      "folk punk",
+      "garage punk",
+      "goregrind",
+      "gorenoise",
+      "grindcore",
+      "gypsy punk",
+      "hardcore punk",
+      "horror punk",
+      "indie & alternative",
+      "könsrock",
+      "mathcore",
+      "melodic hardcore",
+      "melodic metalcore",
+      "metalcore",
+      "midwest emo",
+      "mincecore",
+      "neocrust",
+      "neon pop punk",
+      "new wave",
+      "nintendocore",
+      "noisecore",
+      "noisegrind",
+      "oi",
+      "pop punk",
+      "post-hardcore",
+      "powerviolence",
+      "progressive metalcore",
+      "psychobilly",
+      "punk / new wave",
+      "punk rock",
+      "queercore",
+      "rapcore",
+      "raw punk",
+      "riot grrrl",
+      "rock",
+      "rock / indie",
+      "sasscore",
+      "screamo",
+      "seishun punk",
+      "ska punk",
+      "skacore",
+      "skate punk",
+      "stenchcore",
+      "street punk",
+      "surf punk",
+      "swancore",
+      "thall",
+      "thrashcore",
+      "uk82",
+      "viking rock"
+    ]
+  },
+  {
+    "genre": "r&b",
+    "translation_key": "r_b",
+    "aliases": [
+      "alternative r&b",
+      "blue-eyed soul",
+      "contemporary r&b",
+      "doo-wop",
+      "funk",
+      "funk & disco",
+      "hip hop soul",
+      "jazz / r&b and soul",
+      "new jack swing",
+      "quiet storm",
+      "r&b / soul",
+      "r&b / soul/funk",
+      "r&b and soul",
+      "soul",
+      "soul & funk",
+      "soul / r&b",
+      "soul/funk",
+      "soul/funk/r&b",
+      "trap soul",
+      "uk street soul"
+    ]
+  },
+  {
+    "genre": "ragtime",
+    "translation_key": "ragtime",
+    "aliases": [
+      "classic ragtime",
+      "ragtime song"
+    ]
+  },
+  {
+    "genre": "raï",
+    "translation_key": "rai",
+    "aliases": [
+      "african",
+      "afro",
+      "arabic",
+      "chaabi",
+      "maghreb"
+    ]
+  },
+  {
+    "genre": "reggae",
+    "translation_key": "reggae",
+    "aliases": [
+      "ambient dub",
+      "dancehall",
+      "dub",
+      "jawaiian",
+      "lovers rock",
+      "novo dub",
+      "pacific reggae",
+      "reggae / dancehall",
+      "reggae and caribbean",
+      "reggae-pop",
+      "skinhead reggae"
+    ]
+  },
+  {
+    "genre": "reggaeton",
+    "translation_key": "reggaeton",
+    "aliases": [
+      "bachatón",
+      "cubatón",
+      "cumbiatón",
+      "doble paso",
+      "latin",
+      "latin music",
+      "latin urban",
+      "neoperreo",
+      "rkt",
+      "romantic flow",
+      "urbano latino"
+    ]
+  },
+  {
+    "genre": "rock",
+    "translation_key": "rock",
+    "aliases": [
+      "acid rock",
+      "acoustic rock",
+      "afro rock",
+      "alternative & indie",
+      "alternative / indie",
+      "alternative / rock",
+      "alternative dance",
+      "alternative rock",
+      "anatolian rock",
+      "aor",
+      "arena rock",
+      "art rock",
+      "avant-prog",
+      "beat music",
+      "blackgaze",
+      "blues rock",
+      "boogie rock",
+      "british folk rock",
+      "britpop",
+      "brutal prog",
+      "burmese stereo",
+      "canterbury scene",
+      "celtic rock",
+      "christian rock",
+      "classic rock",
+      "coldwave",
+      "comedy rock",
+      "cosmic country",
+      "country rock",
+      "crossover prog",
+      "dance-punk",
+      "dance-punk revival",
+      "dance-rock",
+      "desert blues",
+      "desert rock",
+      "dream pop",
+      "dunedin sound",
+      "electronic rock",
+      "eleki",
+      "emo",
+      "emo pop",
+      "emo rap",
+      "emocore",
+      "experimental rock",
+      "folk rock",
+      "frat rock",
+      "freakbeat",
+      "french rock",
+      "funk metal",
+      "funk rock",
+      "garage psych",
+      "garage punk",
+      "garage rock",
+      "garage rock revival",
+      "geek rock",
+      "glam metal",
+      "glam punk",
+      "glam rock",
+      "gothic rock",
+      "grebo",
+      "grunge",
+      "hamburger schule",
+      "hard rock",
+      "heartland rock",
+      "heavy psych",
+      "hot rod music",
+      "indie & alternative",
+      "indie and alternative",
+      "indie rock",
+      "indie surf",
+      "industrial rock",
+      "instrumental rock",
+      "j-rock",
+      "jam band",
+      "jangle pop",
+      "jazz / rock",
+      "jazz fusion & jazz rock",
+      "jazz rock",
+      "krautrock",
+      "latin rock",
+      "livetronica",
+      "lo-fi",
+      "madchester",
+      "mainstream rock",
+      "mangue beat",
+      "manila sound",
+      "math pop",
+      "math rock",
+      "medieval rock",
+      "midwest emo",
+      "miejski folk",
+      "mod",
+      "neo-progressive rock",
+      "neo-rockabilly",
+      "neue deutsche welle",
+      "new rave",
+      "new romantic",
+      "new wave",
+      "no wave",
+      "noise pop",
+      "noise rock",
+      "occult rock",
+      "phleng phuea chiwit",
+      "piano rock",
+      "pop / rock",
+      "pop rock",
+      "pop yeh-yeh",
+      "pop/rock",
+      "post-britpop",
+      "post-grunge",
+      "post-punk",
+      "post-punk revival",
+      "post-rock",
+      "power pop",
+      "progressive rock",
+      "proto-punk",
+      "psychedelic rock",
+      "psychobilly",
+      "pub rock",
+      "punk",
+      "punk / new wave",
+      "punk blues",
+      "raga rock",
+      "rap rock",
+      "rautalanka",
+      "reggae rock",
+      "rock / indie",
+      "rock and roll",
+      "rock andaluz",
+      "rock andino (andean rock)",
+      "rock opera",
+      "rock rural",
+      "rock urbano mexicano",
+      "rockabilly",
+      "roots rock",
+      "shoegaze",
+      "slacker rock",
+      "sleaze rock",
+      "slowcore",
+      "soft rock",
+      "southern rock",
+      "space rock",
+      "space rock revival",
+      "stoner rock",
+      "sufi rock",
+      "surf",
+      "surf rock",
+      "swamp rock",
+      "symphonic prog",
+      "symphonic rock",
+      "tex-mex",
+      "tropical rock",
+      "visual kei",
+      "vocal surf",
+      "xian psych",
+      "yacht rock",
+      "zamrock",
+      "zeuhl",
+      "zolo"
+    ]
+  },
+  {
+    "genre": "salsa",
+    "translation_key": "salsa",
+    "aliases": [
+      "latin",
+      "salsa choke",
+      "salsa dura",
+      "salsa romántica",
+      "timba"
+    ]
+  },
+  {
+    "genre": "singer-songwriter",
+    "translation_key": "singer_songwriter",
+    "aliases": [
+      "avtorskaya pesnya",
+      "euskal kantagintza berria",
+      "kleinkunst",
+      "liedermacher",
+      "música de intervenção",
+      "nova cançó",
+      "nueva canción",
+      "nueva canción chilena",
+      "nueva canción española",
+      "nuevo cancionero"
+    ]
+  },
+  {
+    "genre": "ska",
+    "translation_key": "ska",
+    "aliases": [
+      "2 tone",
+      "crack rock steady",
+      "reggae",
+      "reggae / dancehall",
+      "reggae and caribbean",
+      "rocksteady",
+      "ska & rocksteady",
+      "ska punk",
+      "skacore",
+      "third wave ska"
+    ]
+  },
+  {
+    "genre": "soul",
+    "translation_key": "soul",
+    "aliases": [
+      "acid jazz",
+      "country soul",
+      "funk",
+      "funk & disco",
+      "jazz / r&b and soul",
+      "latin soul",
+      "motown",
+      "neo soul",
+      "northern soul",
+      "pop soul",
+      "progressive soul",
+      "psychedelic soul",
+      "r&b",
+      "r&b / soul",
+      "r&b / soul/funk",
+      "r&b and soul",
+      "soul & funk",
+      "soul / r&b",
+      "soul/funk",
+      "soul/funk/r&b"
+    ]
+  },
+  {
+    "genre": "sound effects",
+    "translation_key": "sound_effects",
+    "aliases": [
+      "binaural beats",
+      "broadband noise"
+    ]
+  },
+  {
+    "genre": "soundtrack",
+    "translation_key": "soundtrack",
+    "aliases": [
+      "cinema music",
+      "film score",
+      "film soundtracks",
+      "film, tv & stage",
+      "movies & series",
+      "soundtracks",
+      "soundtracks and musicals",
+      "tv & films",
+      "tv series"
+    ]
+  },
+  {
+    "genre": "spoken word",
+    "translation_key": "spoken_word",
+    "aliases": [
+      "audio documentary",
+      "educational",
+      "fairy tale",
+      "guided meditation",
+      "historical documents",
+      "interview",
+      "lecture",
+      "literature",
+      "poetry",
+      "sermon",
+      "speech"
+    ]
+  },
+  {
+    "genre": "swing",
+    "translation_key": "swing",
+    "aliases": [
+      "electro swing",
+      "swing revival"
+    ]
+  },
+  {
+    "genre": "tango",
+    "translation_key": "tango",
+    "aliases": [
+      "latin",
+      "latin music"
+    ]
+  },
+  {
+    "genre": "trap",
+    "translation_key": "trap",
+    "aliases": [
+      "ambient plugg",
+      "asian rock (pluggnb subgenre - not rock from Asia)",
+      "dark plugg",
+      "new jazz (trap subgenre)",
+      "plugg",
+      "pluggnb",
+      "rage",
+      "regalia",
+      "sigilkore",
+      "terror plugg",
+      "trap latino (latin trap)",
+      "trap metal",
+      "trap soul",
+      "tread"
+    ]
+  },
+  {
+    "genre": "waltz",
+    "translation_key": "waltz",
+    "aliases": [
+      "slow waltz",
+      "vals venezolano",
+      "valsa brasileira"
+    ]
+  },
+  {
+    "genre": "wellness",
+    "translation_key": "wellness",
+    "aliases": [
+      "healing",
+      "meditation",
+      "relaxation",
+      "sleep",
+      "sleep / sound therapy",
+      "sleep / wellness",
+      "soundscape",
+      "wellness / meditation"
+    ]
+  }
+]
index 83bfa3291b212031bb04114a51a0e6d70ccf7142..51f3009aa03564d53dff4996ed6752bbebc3ba6a 100644 (file)
@@ -120,6 +120,11 @@ class MusicProvider(Provider):
         yield  # type: ignore[misc]
         raise NotImplementedError
 
+    async def get_library_genres(self) -> AsyncGenerator[str, None]:
+        """Retrieve library genres from the provider."""
+        yield  # type: ignore[misc]
+        raise NotImplementedError
+
     async def get_artist(self, prov_artist_id: str) -> Artist:
         """Get full artist details by id."""
         raise NotImplementedError
@@ -187,6 +192,10 @@ class MusicProvider(Provider):
         """
         raise NotImplementedError
 
+    async def get_item_genre_names(self, media_type: MediaType, item_id: str) -> set[str]:
+        """Return genre names for a single item."""
+        raise NotImplementedError
+
     async def get_album_tracks(
         self,
         prov_album_id: str,
@@ -742,6 +751,24 @@ class MusicProvider(Provider):
             category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
         )
 
+    async def _sync_item_genres(
+        self,
+        media_type: MediaType,
+        provider_item_id: str,
+        library_item_id: int,
+        fallback_genres: set[str] | None = None,
+    ) -> None:
+        try:
+            genre_names = await self.get_item_genre_names(media_type, provider_item_id)
+        except NotImplementedError:
+            if fallback_genres is None:
+                return
+            genre_names = fallback_genres
+
+        await self.mass.music.genres.sync_media_item_genres(
+            media_type, library_item_id, set(genre_names)
+        )
+
     async def _sync_library_artists(self) -> set[int]:
         """Sync Library Artists to Music Assistant library."""
         self.logger.debug("Start sync of Artists to Music Assistant library.")
@@ -769,6 +796,17 @@ class MusicProvider(Provider):
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.artists.set_favorite(library_item.item_id, True)
+                fallback_genres = (
+                    set(prov_item.metadata.genres)
+                    if prov_item.metadata and prov_item.metadata.genres
+                    else None
+                )
+                await self._sync_item_genres(
+                    MediaType.ARTIST,
+                    prov_item.item_id,
+                    int(library_item.item_id),
+                    fallback_genres,
+                )
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
             except MusicAssistantError as err:
@@ -811,6 +849,17 @@ class MusicProvider(Provider):
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.albums.set_favorite(library_item.item_id, True)
+                fallback_genres = (
+                    set(prov_item.metadata.genres)
+                    if prov_item.metadata and prov_item.metadata.genres
+                    else None
+                )
+                await self._sync_item_genres(
+                    MediaType.ALBUM,
+                    prov_item.item_id,
+                    int(library_item.item_id),
+                    fallback_genres,
+                )
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
                 # optionally add album tracks to library
@@ -845,6 +894,17 @@ class MusicProvider(Provider):
                     library_track = await self.mass.music.tracks.update_item_in_library(
                         library_track.item_id, prov_track
                     )
+                fallback_genres = (
+                    set(prov_track.metadata.genres)
+                    if prov_track.metadata and prov_track.metadata.genres
+                    else None
+                )
+                await self._sync_item_genres(
+                    MediaType.TRACK,
+                    prov_track.item_id,
+                    int(library_track.item_id),
+                    fallback_genres,
+                )
                 await asyncio.sleep(0)  # yield to eventloop
             except MusicAssistantError as err:
                 self.logger.warning(
@@ -893,6 +953,18 @@ class MusicProvider(Provider):
                         library_item.item_id, prov_item
                     )
 
+                fallback_genres = (
+                    set(prov_item.metadata.genres)
+                    if prov_item.metadata and prov_item.metadata.genres
+                    else None
+                )
+                await self._sync_item_genres(
+                    MediaType.AUDIOBOOK,
+                    prov_item.item_id,
+                    int(library_item.item_id),
+                    fallback_genres,
+                )
+
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
             except MusicAssistantError as err:
@@ -972,6 +1044,17 @@ class MusicProvider(Provider):
                     library_track = await self.mass.music.tracks.update_item_in_library(
                         library_track.item_id, prov_track
                     )
+                fallback_genres = (
+                    set(prov_track.metadata.genres)
+                    if prov_track.metadata and prov_track.metadata.genres
+                    else None
+                )
+                await self._sync_item_genres(
+                    MediaType.TRACK,
+                    prov_track.item_id,
+                    int(library_track.item_id),
+                    fallback_genres,
+                )
                 await asyncio.sleep(0)  # yield to eventloop
             except MusicAssistantError as err:
                 self.logger.warning(
@@ -1015,6 +1098,17 @@ class MusicProvider(Provider):
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.tracks.set_favorite(library_item.item_id, True)
+                fallback_genres = (
+                    set(prov_item.metadata.genres)
+                    if prov_item.metadata and prov_item.metadata.genres
+                    else None
+                )
+                await self._sync_item_genres(
+                    MediaType.TRACK,
+                    prov_item.item_id,
+                    int(library_item.item_id),
+                    fallback_genres,
+                )
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
             except MusicAssistantError as err:
@@ -1052,6 +1146,17 @@ class MusicProvider(Provider):
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.podcasts.set_favorite(library_item.item_id, True)
+                fallback_genres = (
+                    set(prov_item.metadata.genres)
+                    if prov_item.metadata and prov_item.metadata.genres
+                    else None
+                )
+                await self._sync_item_genres(
+                    MediaType.PODCAST,
+                    prov_item.item_id,
+                    int(library_item.item_id),
+                    fallback_genres,
+                )
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
 
index 8703589d6fe9126084620103b7b7e4015530e1c2..b8befc20747149570211845a63f7fc6a3a96a1d1 100644 (file)
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import random
 from collections.abc import AsyncGenerator
 from typing import TYPE_CHECKING
 
@@ -31,7 +32,12 @@ from music_assistant_models.media_items import (
 )
 from music_assistant_models.streamdetails import StreamDetails
 
-from music_assistant.constants import MASS_LOGO, SILENCE_FILE_LONG, VARIOUS_ARTISTS_FANART
+from music_assistant.constants import (
+    DEFAULT_GENRES,
+    MASS_LOGO,
+    SILENCE_FILE_LONG,
+    VARIOUS_ARTISTS_FANART,
+)
 from music_assistant.models.music_provider import MusicProvider
 
 if TYPE_CHECKING:
@@ -144,13 +150,37 @@ class TestProvider(MusicProvider):
         """Return True if the provider is a streaming provider."""
         return False
 
+    async def get_library_genres(self) -> AsyncGenerator[str, None]:
+        """Retrieve library genres from the provider."""
+        for genre in DEFAULT_GENRES:
+            yield genre
+
+    async def get_item_genre_names(self, media_type: MediaType, item_id: str) -> set[str]:
+        """Return genre names for a single item."""
+        if media_type == MediaType.ARTIST:
+            seed = item_id
+        elif media_type == MediaType.ALBUM:
+            seed = item_id.split("_", 2)[0]
+        elif media_type == MediaType.TRACK:
+            seed = item_id.split("_", 3)[0]
+        elif media_type == MediaType.PODCAST:
+            seed = item_id
+        elif media_type == MediaType.PODCAST_EPISODE:
+            seed = item_id.split("_", 2)[0]
+        elif media_type == MediaType.AUDIOBOOK:
+            seed = item_id
+        else:
+            return set()
+        return {random.Random(seed).choice(DEFAULT_GENRES)}
+
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
         artist_idx, album_idx, track_idx = prov_track_id.split("_", 3)
+        genre = random.Random(artist_idx).choice(DEFAULT_GENRES)
         return Track(
             item_id=prov_track_id,
             provider=self.instance_id,
-            name=f"Test Track {artist_idx} - {album_idx} - {track_idx}",
+            name=f"{genre} Test Track {artist_idx} - {album_idx} - {track_idx}",
             duration=60,
             artists=UniqueList([await self.get_artist(artist_idx)]),
             album=await self.get_album(f"{artist_idx}_{album_idx}"),
@@ -161,18 +191,22 @@ class TestProvider(MusicProvider):
                     provider_instance=self.instance_id,
                 ),
             },
-            metadata=MediaItemMetadata(images=UniqueList([DEFAULT_THUMB])),
+            metadata=MediaItemMetadata(images=UniqueList([DEFAULT_THUMB]), genres={genre}),
             disc_number=1,
             track_number=int(track_idx),
         )
 
     async def get_artist(self, prov_artist_id: str) -> Artist:
         """Get full artist details by id."""
+        genre = random.Random(prov_artist_id).choice(DEFAULT_GENRES)
         return Artist(
             item_id=prov_artist_id,
             provider=self.instance_id,
-            name=f"Test Artist {prov_artist_id}",
-            metadata=MediaItemMetadata(images=UniqueList([DEFAULT_THUMB, DEFAULT_FANART])),
+            name=f"{genre} Test Artist {prov_artist_id}",
+            metadata=MediaItemMetadata(
+                images=UniqueList([DEFAULT_THUMB, DEFAULT_FANART]),
+                genres={genre},
+            ),
             provider_mappings={
                 ProviderMapping(
                     item_id=prov_artist_id,
@@ -185,10 +219,11 @@ class TestProvider(MusicProvider):
     async def get_album(self, prov_album_id: str) -> Album:
         """Get full artist details by id."""
         artist_idx, album_idx = prov_album_id.split("_", 2)
+        genre = random.Random(artist_idx).choice(DEFAULT_GENRES)
         return Album(
             item_id=prov_album_id,
             provider=self.instance_id,
-            name=f"Test Album {album_idx}",
+            name=f"{genre} Test Album {album_idx}",
             artists=UniqueList([await self.get_artist(artist_idx)]),
             provider_mappings={
                 ProviderMapping(
@@ -197,16 +232,17 @@ class TestProvider(MusicProvider):
                     provider_instance=self.instance_id,
                 )
             },
-            metadata=MediaItemMetadata(images=UniqueList([DEFAULT_THUMB])),
+            metadata=MediaItemMetadata(images=UniqueList([DEFAULT_THUMB]), genres={genre}),
         )
 
     async def get_podcast(self, prov_podcast_id: str) -> Podcast:
         """Get full podcast details by id."""
+        genre = random.Random(prov_podcast_id).choice(DEFAULT_GENRES)
         return Podcast(
             item_id=prov_podcast_id,
             provider=self.instance_id,
-            name=f"Test Podcast {prov_podcast_id}",
-            metadata=MediaItemMetadata(images=UniqueList([DEFAULT_THUMB])),
+            name=f"{genre} Test Podcast {prov_podcast_id}",
+            metadata=MediaItemMetadata(images=UniqueList([DEFAULT_THUMB]), genres={genre}),
             provider_mappings={
                 ProviderMapping(
                     item_id=prov_podcast_id,
@@ -219,10 +255,11 @@ class TestProvider(MusicProvider):
 
     async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
         """Get full audiobook details by id."""
+        genre = random.Random(prov_audiobook_id).choice(DEFAULT_GENRES)
         return Audiobook(
             item_id=prov_audiobook_id,
             provider=self.instance_id,
-            name=f"Test Audiobook {prov_audiobook_id}",
+            name=f"{genre} Test Audiobook {prov_audiobook_id}",
             metadata=MediaItemMetadata(
                 images=UniqueList([DEFAULT_THUMB]),
                 description="This is a description for Test Audiobook",
@@ -231,6 +268,7 @@ class TestProvider(MusicProvider):
                     MediaItemChapter(position=2, name="Chapter 2", start=20, end=40),
                     MediaItemChapter(position=2, name="Chapter 3", start=40),
                 ],
+                genres={genre},
             ),
             provider_mappings={
                 ProviderMapping(
@@ -303,10 +341,11 @@ class TestProvider(MusicProvider):
     async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
         """Get (full) podcast episode details by id."""
         podcast_id, episode_idx = prov_episode_id.split("_", 2)
+        genre = random.Random(podcast_id).choice(DEFAULT_GENRES)
         return PodcastEpisode(
             item_id=prov_episode_id,
             provider=self.instance_id,
-            name=f"Test PodcastEpisode {podcast_id}-{episode_idx}",
+            name=f"{genre} Test PodcastEpisode {podcast_id}-{episode_idx}",
             duration=60,
             podcast=ItemMapping(
                 item_id=podcast_id,
@@ -324,7 +363,8 @@ class TestProvider(MusicProvider):
             },
             metadata=MediaItemMetadata(
                 description="This is a description for "
-                f"Test PodcastEpisode {episode_idx} of Test Podcast {podcast_id}"
+                f"Test PodcastEpisode {episode_idx} of Test Podcast {podcast_id}",
+                genres={genre},
             ),
             position=int(episode_idx),
         )
index a01f1293e3d89c42d9b1c8bb84807b025a71bc7b..d02ac30d868a20b398ad154121ed6ae1c1ebf6e3 100644 (file)
@@ -75,6 +75,7 @@ mass = "music_assistant.__main__:main"
 ignore-words-list = "provid,hass,followings,childs,explict,additionals,commitish,nam,"
 skip = """*.js,*.svg,\
 music_assistant/providers/itunes_podcasts/itunes_country_codes.json,\
+music_assistant/helpers/resources/genre_mapping.json,\
 """
 
 [tool.setuptools]
diff --git a/tests/core/test_genre_helpers.py b/tests/core/test_genre_helpers.py
new file mode 100644 (file)
index 0000000..6ee637b
--- /dev/null
@@ -0,0 +1,90 @@
+"""Unit tests for genre helper functions (_normalize_genre_name)."""
+
+from music_assistant.controllers.media.genres import GenreController
+
+_normalize = GenreController._normalize_genre_name
+
+
+def test_normalize_basic() -> None:
+    """Test basic genre name normalization."""
+    result = _normalize("Rock")
+    assert result == ("Rock", "Rock", "rock", "rock")
+
+
+def test_normalize_with_spaces() -> None:
+    """Test normalization of multi-word genre name."""
+    result = _normalize("Classic Rock")
+    assert result is not None
+    name, sort_name, search_name, search_sort_name = result
+    assert name == "Classic Rock"
+    assert sort_name == "Classic Rock"
+    # replace_space=True strips spaces from search names
+    assert search_name == "classicrock"
+    assert search_sort_name == "classicrock"
+
+
+def test_normalize_strips_whitespace() -> None:
+    """Test that leading/trailing whitespace is stripped."""
+    result = _normalize("  Jazz  ")
+    assert result is not None
+    name, sort_name, search_name, search_sort_name = result
+    assert name == "Jazz"
+    assert sort_name == "Jazz"
+    assert search_name == "jazz"
+    assert search_sort_name == "jazz"
+
+
+def test_normalize_empty_string() -> None:
+    """Test that empty string returns None."""
+    assert _normalize("") is None
+
+
+def test_normalize_whitespace_only() -> None:
+    """Test that whitespace-only string returns None."""
+    assert _normalize("   ") is None
+
+
+def test_normalize_special_characters() -> None:
+    """Test diacritics are handled via create_safe_string (unidecode)."""
+    result = _normalize("Café")
+    assert result is not None
+    name, _sort_name, search_name, _search_sort_name = result
+    assert name == "Café"
+    assert search_name == "cafe"
+
+
+def test_normalize_unicode() -> None:
+    """Test unicode transliteration."""
+    result = _normalize("Electrónica")
+    assert result is not None
+    name, _sort_name, search_name, _search_sort_name = result
+    assert name == "Electrónica"
+    assert search_name == "electronica"
+
+
+def test_normalize_ampersand() -> None:
+    """Test R&B search_name normalization (& is stripped)."""
+    result = _normalize("R&B")
+    assert result is not None
+    name, _sort_name, search_name, _search_sort_name = result
+    assert name == "R&B"
+    assert search_name == "rb"
+
+
+def test_normalize_slash() -> None:
+    """Test compound genre name with special chars."""
+    result = _normalize("Drum & Bass / Jungle")
+    assert result is not None
+    name, _sort_name, search_name, _search_sort_name = result
+    assert name == "Drum & Bass / Jungle"
+    # All non-alphanumeric chars (including spaces, &, /) are stripped
+    assert search_name == "drumbassjungle"
+
+
+def test_normalize_returns_four_tuple() -> None:
+    """Test that a valid input always returns a tuple of exactly 4 strings."""
+    result = _normalize("Pop")
+    assert result is not None
+    assert isinstance(result, tuple)
+    assert len(result) == 4
+    assert all(isinstance(s, str) for s in result)
diff --git a/tests/core/test_genres.py b/tests/core/test_genres.py
new file mode 100644 (file)
index 0000000..dab6068
--- /dev/null
@@ -0,0 +1,887 @@
+"""Integration tests for the GenreController (V3 schema).
+
+Uses the ``mass`` fixture from ``tests/conftest.py`` which creates a full
+MusicAssistant instance with a real SQLite database in a temporary directory.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from collections.abc import AsyncGenerator
+
+import pytest
+from music_assistant_models.enums import MediaType
+from music_assistant_models.errors import MediaNotFoundError
+from music_assistant_models.media_items import (
+    Artist,
+    Genre,
+    Track,
+)
+from music_assistant_models.unique_list import UniqueList
+
+from music_assistant.constants import (
+    DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
+    DB_TABLE_GENRES,
+    DEFAULT_GENRE_MAPPING,
+)
+from music_assistant.controllers.media.genres import GenreController
+from music_assistant.mass import MusicAssistant
+
+# ---------------------------------------------------------------------------
+# Fixtures & helpers
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="class")
+async def mass(tmp_path_factory: pytest.TempPathFactory) -> AsyncGenerator[MusicAssistant, None]:
+    """Class-scoped MusicAssistant instance (one per test class)."""
+    tmp_path = tmp_path_factory.mktemp("genre_tests")
+    storage_path = tmp_path / "data"
+    cache_path = tmp_path / "cache"
+    storage_path.mkdir(parents=True)
+    cache_path.mkdir(parents=True)
+    logging.getLogger("aiosqlite").level = logging.INFO
+    mass_instance = MusicAssistant(str(storage_path), str(cache_path))
+    await mass_instance.start()
+    try:
+        yield mass_instance
+    finally:
+        await mass_instance.stop()
+
+
+@pytest.fixture(scope="class")
+async def genre_ctrl(mass: MusicAssistant) -> GenreController:
+    """Get the genre controller from a running MusicAssistant instance."""
+    return mass.music.genres
+
+
+def _make_genre(name: str, favorite: bool = False) -> Genre:
+    """Create a Genre object for adding to the library."""
+    return Genre(
+        item_id="0",
+        provider="library",
+        name=name,
+        provider_mappings=set(),
+        favorite=favorite,
+    )
+
+
+async def _add_test_artist(mass: MusicAssistant, name: str) -> Artist:
+    """Add a minimal artist to the library."""
+    artist = Artist(
+        item_id="0",
+        provider="library",
+        name=name,
+        provider_mappings=set(),
+    )
+    return await mass.music.artists.add_item_to_library(artist)
+
+
+async def _add_test_track(mass: MusicAssistant, name: str) -> Track:
+    """Add a minimal track to the library (creates an artist first)."""
+    artist = await _add_test_artist(mass, f"Artist for {name}")
+    track = Track(
+        item_id="0",
+        provider="library",
+        name=name,
+        provider_mappings=set(),
+        artists=UniqueList([artist]),
+    )
+    return await mass.music.tracks.add_item_to_library(track)
+
+
+# ===================================================================
+# Group B: Genre CRUD (14 tests)
+# ===================================================================
+
+
+class TestGenreCRUD:
+    """Tests for adding, reading, updating, and removing genres."""
+
+    async def test_add_genre(self, genre_ctrl: GenreController) -> None:
+        """add_item_to_library returns Genre with numeric id and correct name."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Rock"))
+        assert int(genre.item_id) > 0
+        assert genre.name == "Rock"
+
+    async def test_add_genre_creates_self_alias(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Genre has its own name in genre_aliases JSON column."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Blues"))
+        # Check genre_aliases JSON column directly
+        row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": int(genre.item_id)})
+        assert row is not None
+        aliases = json.loads(row["genre_aliases"])
+        assert "Blues" in aliases
+
+    async def test_add_genre_duplicate_updates(self, genre_ctrl: GenreController) -> None:
+        """Adding the same genre with library id returns the same item_id (update, no duplicate)."""
+        genre1 = await genre_ctrl.add_item_to_library(_make_genre("Jazz"))
+        # Second add using the real library id (simulates re-adding same item)
+        dup = Genre(
+            item_id=genre1.item_id,
+            provider="library",
+            name="Jazz",
+            provider_mappings=set(),
+        )
+        genre2 = await genre_ctrl.add_item_to_library(dup)
+        assert genre1.item_id == genre2.item_id
+
+    async def test_get_library_item(self, genre_ctrl: GenreController) -> None:
+        """get_library_item returns Genre with genre_aliases populated."""
+        created = await genre_ctrl.add_item_to_library(_make_genre("Funk"))
+        fetched = await genre_ctrl.get_library_item(int(created.item_id))
+        assert fetched.name == "Funk"
+        assert fetched.genre_aliases is not None
+        assert "Funk" in fetched.genre_aliases
+
+    async def test_get_library_item_not_found(self, genre_ctrl: GenreController) -> None:
+        """Raises MediaNotFoundError for nonexistent id."""
+        with pytest.raises(MediaNotFoundError):
+            await genre_ctrl.get_library_item(999999)
+
+    async def test_update_smart_merge(self, genre_ctrl: GenreController) -> None:
+        """Update with metadata merges without overwrite flag."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Reggae"))
+        update = _make_genre("Reggae")
+        update.favorite = True
+        updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=False)
+        assert updated.favorite is True
+        assert updated.name == "Reggae"
+
+    async def test_update_overwrite(self, genre_ctrl: GenreController) -> None:
+        """Update with overwrite=True replaces name."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("OldName"))
+        update = _make_genre("NewName")
+        updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=True)
+        assert updated.name == "NewName"
+
+    async def test_update_ensures_self_alias(self, genre_ctrl: GenreController) -> None:
+        """After name update, self-alias exists for new name."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("OldGenre"))
+        update = _make_genre("RenamedGenre")
+        updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=True)
+        assert updated.genre_aliases is not None
+        assert "RenamedGenre" in updated.genre_aliases
+
+    async def test_remove_genre(self, genre_ctrl: GenreController) -> None:
+        """After remove, get_library_item raises MediaNotFoundError."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Ska"))
+        await genre_ctrl.remove_item_from_library(genre.item_id)
+        with pytest.raises(MediaNotFoundError):
+            await genre_ctrl.get_library_item(int(genre.item_id))
+
+    async def test_remove_cleans_mappings(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """After remove, genre_media_item_mapping entries for that genre are gone."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Dubstep"))
+        genre_id = int(genre.item_id)
+        # Add a media mapping first
+        track = await _add_test_track(mass, "Dubstep Track")
+        await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track.item_id, "Dubstep")
+        # Now remove the genre
+        await genre_ctrl.remove_item_from_library(genre.item_id)
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} WHERE genre_id = :genre_id",
+            {"genre_id": genre_id},
+            limit=0,
+        )
+        assert len(rows) == 0
+
+    async def test_library_items(self, genre_ctrl: GenreController) -> None:
+        """Add 3 genres, returns all 3."""
+        for name in ("Alpha", "Beta", "Gamma"):
+            await genre_ctrl.add_item_to_library(_make_genre(name))
+        items = await genre_ctrl.library_items()
+        names = {g.name for g in items}
+        assert {"Alpha", "Beta", "Gamma"}.issubset(names)
+
+    async def test_library_items_search(self, genre_ctrl: GenreController) -> None:
+        """Search 'country' returns only matching genres."""
+        await genre_ctrl.add_item_to_library(_make_genre("Country"))
+        await genre_ctrl.add_item_to_library(_make_genre("Metal"))
+        items = await genre_ctrl.library_items(search="country")
+        assert all("country" in g.name.lower() for g in items)
+
+    async def test_library_items_rejects_genre_param(self, genre_ctrl: GenreController) -> None:
+        """library_items(genre=1) raises ValueError."""
+        with pytest.raises(ValueError, match="genre parameter is not supported"):
+            await genre_ctrl.library_items(genre=1)
+
+    async def test_library_count(self, genre_ctrl: GenreController) -> None:
+        """Returns correct count; favorite_only=True filters."""
+        await genre_ctrl.add_item_to_library(_make_genre("CountA"))
+        await genre_ctrl.add_item_to_library(_make_genre("CountB", favorite=True))
+        total = await genre_ctrl.library_count()
+        assert total >= 2
+        fav = await genre_ctrl.library_count(favorite_only=True)
+        assert fav >= 1
+        assert fav <= total
+
+
+# ===================================================================
+# Group C: Alias Operations (8 tests)
+# ===================================================================
+
+
+class TestAliasOperations:
+    """Tests for add_alias, remove_alias string operations on genres."""
+
+    async def test_add_alias(self, genre_ctrl: GenreController) -> None:
+        """add_alias adds a string to genre_aliases."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Electronic"))
+        updated = await genre_ctrl.add_alias(genre.item_id, "EDM")
+        assert updated.genre_aliases is not None
+        assert "EDM" in updated.genre_aliases
+        assert "Electronic" in updated.genre_aliases
+
+    async def test_add_alias_idempotent(self, genre_ctrl: GenreController) -> None:
+        """Adding the same alias twice doesn't duplicate."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("House"))
+        await genre_ctrl.add_alias(genre.item_id, "Deep House")
+        updated = await genre_ctrl.add_alias(genre.item_id, "Deep House")
+        assert updated.genre_aliases is not None
+        assert list(updated.genre_aliases).count("Deep House") == 1
+
+    async def test_add_alias_multiple(self, genre_ctrl: GenreController) -> None:
+        """Multiple aliases can be added to a single genre."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Ambient"))
+        await genre_ctrl.add_alias(genre.item_id, "Ambient Music")
+        updated = await genre_ctrl.add_alias(genre.item_id, "Chill Ambient")
+        assert updated.genre_aliases is not None
+        assert "Ambient" in updated.genre_aliases
+        assert "Ambient Music" in updated.genre_aliases
+        assert "Chill Ambient" in updated.genre_aliases
+
+    async def test_remove_alias(self, genre_ctrl: GenreController) -> None:
+        """remove_alias removes a string from genre_aliases."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Techno"))
+        await genre_ctrl.add_alias(genre.item_id, "Detroit Techno")
+        updated = await genre_ctrl.remove_alias(genre.item_id, "Detroit Techno")
+        assert updated.genre_aliases is not None
+        assert "Detroit Techno" not in updated.genre_aliases
+        assert "Techno" in updated.genre_aliases
+
+    async def test_remove_self_alias_raises(self, genre_ctrl: GenreController) -> None:
+        """Removing the genre's own name raises ValueError."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Soul"))
+        with pytest.raises(ValueError, match="Cannot remove self-alias"):
+            await genre_ctrl.remove_alias(genre.item_id, "Soul")
+
+    async def test_remove_alias_cleans_media_mappings(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Removing an alias also removes media mappings that used that alias."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Latin"))
+        await genre_ctrl.add_alias(genre.item_id, "Latin Pop")
+        track = await _add_test_track(mass, "Latin Track")
+        await genre_ctrl.add_media_mapping(
+            genre.item_id, MediaType.TRACK, track.item_id, "Latin Pop"
+        )
+        # Remove the alias
+        await genre_ctrl.remove_alias(genre.item_id, "Latin Pop")
+        # Check mapping is gone
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE genre_id = :gid AND alias = :alias",
+            {"gid": int(genre.item_id), "alias": "Latin Pop"},
+            limit=0,
+        )
+        assert len(rows) == 0
+
+    async def test_add_alias_not_found(self, genre_ctrl: GenreController) -> None:
+        """add_alias for nonexistent genre raises MediaNotFoundError."""
+        with pytest.raises(MediaNotFoundError):
+            await genre_ctrl.add_alias(999999, "NoGenre")
+
+    async def test_remove_alias_not_found(self, genre_ctrl: GenreController) -> None:
+        """remove_alias for nonexistent genre raises MediaNotFoundError."""
+        with pytest.raises(MediaNotFoundError):
+            await genre_ctrl.remove_alias(999999, "NoGenre")
+
+
+# ===================================================================
+# Group D: Media Mapping Operations (8 tests)
+# ===================================================================
+
+
+class TestMediaMappingOperations:
+    """Tests for add_media_mapping and remove_media_mapping."""
+
+    async def test_add_media_mapping_track(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Mapping exists in genre_media_item_mapping table."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Pop"))
+        track = await _add_test_track(mass, "Pop Track")
+        await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Pop")
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
+            {
+                "gid": int(genre.item_id),
+                "mt": MediaType.TRACK.value,
+                "mid": int(track.item_id),
+            },
+            limit=1,
+        )
+        assert len(rows) == 1
+        assert rows[0]["alias"] == "Pop"
+
+    async def test_add_media_mapping_idempotent(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Calling add_media_mapping twice doesn't raise (uses allow_replace)."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Grunge"))
+        track = await _add_test_track(mass, "Grunge Song")
+        await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Grunge")
+        await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Grunge")
+
+    async def test_remove_media_mapping_track(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Mapping removed from DB."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Disco"))
+        track = await _add_test_track(mass, "Disco Track")
+        await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Disco")
+        await genre_ctrl.remove_media_mapping(genre.item_id, MediaType.TRACK, track.item_id)
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
+            {
+                "gid": int(genre.item_id),
+                "mt": MediaType.TRACK.value,
+                "mid": int(track.item_id),
+            },
+            limit=1,
+        )
+        assert len(rows) == 0
+
+    async def test_add_media_mapping_artist(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Artist mapping works correctly."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Funk2"))
+        artist = await _add_test_artist(mass, "Funk Artist")
+        await genre_ctrl.add_media_mapping(genre.item_id, MediaType.ARTIST, artist.item_id, "Funk2")
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
+            {
+                "gid": int(genre.item_id),
+                "mt": MediaType.ARTIST.value,
+                "mid": int(artist.item_id),
+            },
+            limit=1,
+        )
+        assert len(rows) == 1
+
+    async def test_mapping_preserves_alias_string(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """The alias column records which alias caused the mapping."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Afrobeat"))
+        await genre_ctrl.add_alias(genre.item_id, "Highlife")
+        track = await _add_test_track(mass, "Afrobeat Track")
+        await genre_ctrl.add_media_mapping(
+            genre.item_id, MediaType.TRACK, track.item_id, "Highlife"
+        )
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT alias FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE genre_id = :gid AND media_id = :mid",
+            {"gid": int(genre.item_id), "mid": int(track.item_id)},
+            limit=1,
+        )
+        assert len(rows) == 1
+        assert rows[0]["alias"] == "Highlife"
+
+    async def test_multiple_genres_same_track(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """A track can be mapped to multiple genres."""
+        genre1 = await genre_ctrl.add_item_to_library(_make_genre("Genre1"))
+        genre2 = await genre_ctrl.add_item_to_library(_make_genre("Genre2"))
+        track = await _add_test_track(mass, "Multi Genre Track")
+        await genre_ctrl.add_media_mapping(genre1.item_id, MediaType.TRACK, track.item_id, "Genre1")
+        await genre_ctrl.add_media_mapping(genre2.item_id, MediaType.TRACK, track.item_id, "Genre2")
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE media_id = :mid AND media_type = 'track'",
+            {"mid": int(track.item_id)},
+            limit=0,
+        )
+        assert len(rows) == 2
+
+    async def test_multiple_tracks_same_genre(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Multiple tracks can be mapped to the same genre."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("SharedGenre"))
+        track1 = await _add_test_track(mass, "Shared Track 1")
+        track2 = await _add_test_track(mass, "Shared Track 2")
+        await genre_ctrl.add_media_mapping(
+            genre.item_id, MediaType.TRACK, track1.item_id, "SharedGenre"
+        )
+        await genre_ctrl.add_media_mapping(
+            genre.item_id, MediaType.TRACK, track2.item_id, "SharedGenre"
+        )
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE genre_id = :gid AND media_type = 'track'",
+            {"gid": int(genre.item_id)},
+            limit=0,
+        )
+        assert len(rows) == 2
+
+    async def test_remove_nonexistent_mapping(self, genre_ctrl: GenreController) -> None:
+        """Removing a mapping that doesn't exist doesn't raise."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("NoMapping"))
+        await genre_ctrl.remove_media_mapping(genre.item_id, MediaType.TRACK, 999999)
+
+
+# ===================================================================
+# Group E: sync_media_item_genres (8 tests)
+# ===================================================================
+
+
+class TestSyncMediaItemGenres:
+    """Tests for sync_media_item_genres."""
+
+    async def test_sync_creates_genre(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """New genre created, mapping exists."""
+        track = await _add_test_track(mass, "Sync Track 1")
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"Psytrance"})
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRES} WHERE name = :name",
+            {"name": "Psytrance"},
+            limit=1,
+        )
+        assert len(rows) == 1
+
+    async def test_sync_uses_existing_genre(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """No duplicate genre created."""
+        await genre_ctrl.add_item_to_library(_make_genre("Punk"))
+        track = await _add_test_track(mass, "Sync Track 2")
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"Punk"})
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRES} WHERE name = :name",
+            {"name": "Punk"},
+            limit=0,
+        )
+        assert len(rows) == 1
+
+    async def test_sync_adds_new_mappings(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Multiple genres creates both mappings."""
+        track = await _add_test_track(mass, "Sync Track 3")
+        await genre_ctrl.sync_media_item_genres(
+            MediaType.TRACK, track.item_id, {"SyncRock", "SyncJazz"}
+        )
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE media_id = :mid AND media_type = 'track'",
+            {"mid": int(track.item_id)},
+            limit=0,
+        )
+        assert len(rows) == 2
+
+    async def test_sync_removes_stale_mappings(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Re-sync with subset removes stale mapping."""
+        track = await _add_test_track(mass, "Sync Track 4")
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncA", "SyncB"})
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncA"})
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE media_id = :mid AND media_type = 'track'",
+            {"mid": int(track.item_id)},
+            limit=0,
+        )
+        assert len(rows) == 1
+
+    async def test_sync_empty_set_removes_all(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Empty set removes all mappings."""
+        track = await _add_test_track(mass, "Sync Track 5")
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncX"})
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, set())
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE media_id = :mid AND media_type = 'track'",
+            {"mid": int(track.item_id)},
+            limit=0,
+        )
+        assert len(rows) == 0
+
+    async def test_sync_idempotent(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
+        """Second call with same set is a no-op."""
+        track = await _add_test_track(mass, "Sync Track 6")
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncIdem"})
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncIdem"})
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE media_id = :mid AND media_type = 'track'",
+            {"mid": int(track.item_id)},
+            limit=0,
+        )
+        assert len(rows) == 1
+
+    async def test_sync_skips_empty_names(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Empty and whitespace-only names are skipped."""
+        track = await _add_test_track(mass, "Sync Track 7")
+        await genre_ctrl.sync_media_item_genres(
+            MediaType.TRACK, track.item_id, {"SyncValid", "", "  "}
+        )
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE media_id = :mid AND media_type = 'track'",
+            {"mid": int(track.item_id)},
+            limit=0,
+        )
+        assert len(rows) == 1
+
+    async def test_sync_concurrent(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
+        """asyncio.gather with different sets doesn't crash."""
+        track1 = await _add_test_track(mass, "Conc Track 1")
+        track2 = await _add_test_track(mass, "Conc Track 2")
+        await asyncio.gather(
+            genre_ctrl.sync_media_item_genres(MediaType.TRACK, track1.item_id, {"ConcA"}),
+            genre_ctrl.sync_media_item_genres(MediaType.TRACK, track2.item_id, {"ConcB"}),
+        )
+
+    async def test_sync_one_alias_maps_to_multiple_genres(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """One alias shared by two genres creates mappings to both (n:n)."""
+        genre_a = await genre_ctrl.add_item_to_library(_make_genre("GenreA"))
+        genre_b = await genre_ctrl.add_item_to_library(_make_genre("GenreB"))
+        # Both genres claim "shared-alias"
+        await genre_ctrl.add_alias(genre_a.item_id, "shared-alias")
+        await genre_ctrl.add_alias(genre_b.item_id, "shared-alias")
+        track = await _add_test_track(mass, "SharedAlias Track")
+        await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"shared-alias"})
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE media_id = :mid AND media_type = 'track'",
+            {"mid": int(track.item_id)},
+            limit=0,
+        )
+        mapped_genre_ids = {int(r["genre_id"]) for r in rows}
+        assert int(genre_a.item_id) in mapped_genre_ids
+        assert int(genre_b.item_id) in mapped_genre_ids
+
+
+# ===================================================================
+# Group F: promote_alias_to_genre (4 tests)
+# ===================================================================
+
+
+class TestPromoteAlias:
+    """Tests for promote_alias_to_genre."""
+
+    async def test_promote_alias(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
+        """New genre created, media mappings moved to new genre."""
+        parent = await genre_ctrl.add_item_to_library(_make_genre("ParentGenre"))
+        await genre_ctrl.add_alias(parent.item_id, "SubGenre")
+        # Add a media mapping via the alias
+        track = await _add_test_track(mass, "Promote Track")
+        await genre_ctrl.add_media_mapping(
+            parent.item_id, MediaType.TRACK, track.item_id, "SubGenre"
+        )
+
+        new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "SubGenre")
+        assert new_genre.name == "SubGenre"
+        assert int(new_genre.item_id) != int(parent.item_id)
+
+        # Media mapping should have moved to new genre
+        rows = await mass.music.database.get_rows_from_query(
+            f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
+            "WHERE media_id = :mid AND media_type = 'track' AND alias = 'SubGenre'",
+            {"mid": int(track.item_id)},
+            limit=1,
+        )
+        assert len(rows) == 1
+        assert int(rows[0]["genre_id"]) == int(new_genre.item_id)
+
+    async def test_promote_creates_self_alias(self, genre_ctrl: GenreController) -> None:
+        """New genre has its own name as alias."""
+        parent = await genre_ctrl.add_item_to_library(_make_genre("PromParent"))
+        await genre_ctrl.add_alias(parent.item_id, "PromChild")
+
+        new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromChild")
+        assert new_genre.genre_aliases is not None
+        assert "PromChild" in new_genre.genre_aliases
+
+    async def test_promote_self_alias_raises(self, genre_ctrl: GenreController) -> None:
+        """Raises ValueError for self-alias."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("PromSelf"))
+        with pytest.raises(ValueError, match="Cannot promote self-alias"):
+            await genre_ctrl.promote_alias_to_genre(genre.item_id, "PromSelf")
+
+    async def test_promote_removes_alias_from_source(self, genre_ctrl: GenreController) -> None:
+        """Alias is removed from source genre after promotion."""
+        parent = await genre_ctrl.add_item_to_library(_make_genre("PromComplete"))
+        await genre_ctrl.add_alias(parent.item_id, "PromAlias")
+
+        await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromAlias")
+        updated_parent = await genre_ctrl.get_library_item(int(parent.item_id))
+        assert updated_parent.genre_aliases is not None
+        assert "PromAlias" not in updated_parent.genre_aliases
+        assert "PromComplete" in updated_parent.genre_aliases
+
+
+# ===================================================================
+# Group G: restore_default_genres (5 tests)
+# ===================================================================
+
+
+class TestRestoreDefaultGenres:
+    """Tests for restore_default_genres."""
+
+    async def test_restore_partial_on_empty(self, genre_ctrl: GenreController) -> None:
+        """Creates genres from DEFAULT_GENRE_MAPPING with self-aliases."""
+        created = await genre_ctrl.restore_default_genres(full_restore=False)
+        assert len(created) > 0
+        for genre in created[:3]:
+            assert genre.genre_aliases is not None
+            assert genre.name in genre.genre_aliases
+
+    async def test_restore_partial_idempotent(self, genre_ctrl: GenreController) -> None:
+        """Second call returns empty list (no duplicates)."""
+        await genre_ctrl.restore_default_genres(full_restore=False)
+        second = await genre_ctrl.restore_default_genres(full_restore=False)
+        assert len(second) == 0
+
+    async def test_restore_partial_adds_missing(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Pre-existing genres not duplicated, missing ones added."""
+        first_default = DEFAULT_GENRE_MAPPING[0]["genre"]
+        await genre_ctrl.add_item_to_library(_make_genre(first_default))
+        before = await genre_ctrl.library_count()
+        created = await genre_ctrl.restore_default_genres(full_restore=False)
+        after = await genre_ctrl.library_count()
+        assert len(created) == after - before
+
+    async def test_restore_full_clears_all(self, genre_ctrl: GenreController) -> None:
+        """Full restore: custom genres gone, only defaults remain."""
+        await genre_ctrl.add_item_to_library(_make_genre("MyCustomGenre"))
+        await genre_ctrl.restore_default_genres(full_restore=True)
+        items = await genre_ctrl.library_items(limit=0)
+        names = {g.name for g in items}
+        assert "MyCustomGenre" not in names
+        assert len(items) == len(DEFAULT_GENRE_MAPPING)
+
+    async def test_restore_creates_configured_aliases(self, genre_ctrl: GenreController) -> None:
+        """Genres have aliases from genre_mapping.json."""
+        await genre_ctrl.restore_default_genres(full_restore=True)
+        entries_with_aliases = [e for e in DEFAULT_GENRE_MAPPING if e.get("aliases")]
+        if not entries_with_aliases:
+            pytest.skip("No default genres with aliases configured")
+        entry = entries_with_aliases[0]
+        items = await genre_ctrl.library_items(search=entry["genre"])
+        assert len(items) > 0
+        genre = items[0]
+        assert genre.genre_aliases is not None
+        # Self-alias should be present
+        assert entry["genre"] in genre.genre_aliases
+        # Configured aliases should be present
+        for alias in entry["aliases"]:
+            assert alias in genre.genre_aliases
+
+
+# ===================================================================
+# Group H: Query Methods (7 tests)
+# ===================================================================
+
+
+class TestQueryMethods:
+    """Tests for radio_mode, mapped_media, and overview endpoints."""
+
+    async def test_radio_mode_empty(self, genre_ctrl: GenreController) -> None:
+        """No mapped tracks returns empty list."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyRadio"))
+        tracks = await genre_ctrl.radio_mode_base_tracks(genre)
+        assert tracks == []
+
+    async def test_radio_mode_returns_tracks(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Mapped tracks are returned."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("RadioGenre"))
+        track = await _add_test_track(mass, "Radio Track")
+        await genre_ctrl.add_media_mapping(
+            genre.item_id, MediaType.TRACK, track.item_id, "RadioGenre"
+        )
+        tracks = await genre_ctrl.radio_mode_base_tracks(genre)
+        assert len(tracks) >= 1
+        assert any(t.name == "Radio Track" for t in tracks)
+
+    async def test_radio_mode_limit_50(self, genre_ctrl: GenreController) -> None:
+        """At most 50 tracks returned (hardcoded limit in radio_mode_base_tracks)."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("RadioLimit"))
+        tracks = await genre_ctrl.radio_mode_base_tracks(genre)
+        assert len(tracks) <= 50
+
+    async def test_mapped_media_returns_all_types(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Returns (tracks, albums, artists) tuple."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("MappedMedia"))
+        result = await genre_ctrl.mapped_media(genre)
+        assert isinstance(result, tuple)
+        assert len(result) == 3
+        tracks, albums, artists = result
+        assert isinstance(tracks, list)
+        assert isinstance(albums, list)
+        assert isinstance(artists, list)
+
+    async def test_mapped_media_empty(self, genre_ctrl: GenreController) -> None:
+        """No mappings returns ([], [], [])."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyMapped"))
+        tracks, albums, artists = await genre_ctrl.mapped_media(genre)
+        assert tracks == []
+        assert albums == []
+        assert artists == []
+
+    async def test_overview_returns_folders(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Returns RecommendationFolder items when mappings exist."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("OverviewGenre"))
+        track = await _add_test_track(mass, "Overview Track")
+        await genre_ctrl.add_media_mapping(
+            genre.item_id, MediaType.TRACK, track.item_id, "OverviewGenre"
+        )
+        folders = await genre_ctrl.get_overview(genre.item_id)
+        assert len(folders) >= 1
+        assert folders[0].name == "Tracks"
+
+    async def test_overview_empty(self, genre_ctrl: GenreController) -> None:
+        """No mappings returns empty list."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyOverview"))
+        folders = await genre_ctrl.get_overview(genre.item_id)
+        assert folders == []
+
+    async def test_get_genres_for_media_item(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Returns genres mapped to a specific media item."""
+        genre1 = await genre_ctrl.add_item_to_library(_make_genre("GenreForItem1"))
+        genre2 = await genre_ctrl.add_item_to_library(_make_genre("GenreForItem2"))
+        track = await _add_test_track(mass, "Track With Genres")
+        await genre_ctrl.add_media_mapping(
+            genre1.item_id, MediaType.TRACK, track.item_id, "GenreForItem1"
+        )
+        await genre_ctrl.add_media_mapping(
+            genre2.item_id, MediaType.TRACK, track.item_id, "GenreForItem2"
+        )
+        genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id)
+        genre_names = {g.name for g in genres}
+        assert "GenreForItem1" in genre_names
+        assert "GenreForItem2" in genre_names
+
+    async def test_get_genres_for_media_item_empty(
+        self, mass: MusicAssistant, genre_ctrl: GenreController
+    ) -> None:
+        """Returns empty list for unmapped media item."""
+        track = await _add_test_track(mass, "Track Without Genres")
+        genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id)
+        assert genres == []
+
+
+# ===================================================================
+# Group I: Genre Lookup & Scanner (5 tests)
+# ===================================================================
+
+
+class TestGenreLookupAndScanner:
+    """Tests for genre/alias lookup and scanner status."""
+
+    async def test_find_genres_for_alias_existing(self, genre_ctrl: GenreController) -> None:
+        """Finds existing genre by name."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Garage"))
+        found = await genre_ctrl._find_genres_for_alias("Garage")
+        assert isinstance(found, list)
+        assert int(genre.item_id) in found
+
+    async def test_find_genres_for_alias_by_alias(self, genre_ctrl: GenreController) -> None:
+        """Finds existing genre by alias string in genre_aliases JSON."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("Breakbeat"))
+        await genre_ctrl.add_alias(genre.item_id, "Big Beat")
+        found = await genre_ctrl._find_genres_for_alias("Big Beat")
+        assert isinstance(found, list)
+        assert int(genre.item_id) in found
+
+    async def test_find_genres_for_alias_creates_new(self, genre_ctrl: GenreController) -> None:
+        """Creates new genre when no match found."""
+        found = await genre_ctrl._find_genres_for_alias("BrandNewGenre12345")
+        assert isinstance(found, list)
+        assert len(found) == 1
+        genre = await genre_ctrl.get_library_item(found[0])
+        assert genre.name == "BrandNewGenre12345"
+
+    async def test_scanner_status(self, genre_ctrl: GenreController) -> None:
+        """Returns dict with expected keys."""
+        status = await genre_ctrl.get_scanner_status()
+        assert "running" in status
+        assert "last_scan_time" in status
+
+    async def test_scan_mappings_trigger(self, genre_ctrl: GenreController) -> None:
+        """Returns 'triggered' status."""
+        result = await genre_ctrl.scan_mappings()
+        assert result["status"] == "triggered"
+
+
+# ===================================================================
+# Group J: Base Class Integration (3 tests)
+# ===================================================================
+
+
+class TestBaseClassIntegration:
+    """Tests for base class query patterns (genre_aliases column, pagination, favorites)."""
+
+    async def test_genre_aliases_inline(self, genre_ctrl: GenreController) -> None:
+        """genre_aliases column populates genre_aliases on fetched Genre."""
+        genre = await genre_ctrl.add_item_to_library(_make_genre("InlineTest"))
+        await genre_ctrl.add_alias(genre.item_id, "Inline Alias")
+        # Fetch via library_items (uses base_query)
+        items = await genre_ctrl.library_items(search="InlineTest")
+        assert len(items) >= 1
+        fetched = items[0]
+        assert fetched.genre_aliases is not None
+        assert "InlineTest" in fetched.genre_aliases
+        assert "Inline Alias" in fetched.genre_aliases
+
+    async def test_pagination(self, genre_ctrl: GenreController) -> None:
+        """limit/offset work correctly."""
+        for i in range(5):
+            await genre_ctrl.add_item_to_library(_make_genre(f"Page{i}"))
+        page1 = await genre_ctrl.library_items(limit=2, offset=0, order_by="name")
+        page2 = await genre_ctrl.library_items(limit=2, offset=2, order_by="name")
+        assert len(page1) == 2
+        assert len(page2) == 2
+        ids1 = {g.item_id for g in page1}
+        ids2 = {g.item_id for g in page2}
+        assert ids1.isdisjoint(ids2)
+
+    async def test_favorite_filter(self, genre_ctrl: GenreController) -> None:
+        """favorite=True filters correctly."""
+        await genre_ctrl.add_item_to_library(_make_genre("FavYes", favorite=True))
+        await genre_ctrl.add_item_to_library(_make_genre("FavNo", favorite=False))
+        favs = await genre_ctrl.library_items(favorite=True)
+        assert all(g.favorite for g in favs)
+        assert any(g.name == "FavYes" for g in favs)
index cdd1d871370aa77660fa154889d2016f4946d987..a59ef395d32ba7cfb2820e6cbc8cc843856d0f05 100644 (file)
@@ -542,6 +542,7 @@ async def test_apply_filters_in_library_only_without_provider_filter() -> None:
         join_parts=join_parts,
         favorite=None,
         search=None,
+        genre_ids=None,
         provider_filter=None,
         in_library_only=True,
     )
@@ -568,6 +569,7 @@ async def test_apply_filters_in_library_only_with_provider_filter() -> None:
         join_parts=join_parts,
         favorite=None,
         search=None,
+        genre_ids=None,
         provider_filter=["spotify_1"],
         in_library_only=True,
     )
@@ -595,6 +597,7 @@ async def test_apply_filters_no_in_library_filter_by_default() -> None:
         join_parts=join_parts,
         favorite=None,
         search=None,
+        genre_ids=None,
         provider_filter=None,
         in_library_only=False,
     )
@@ -619,6 +622,7 @@ async def test_apply_filters_provider_filter_without_in_library() -> None:
         join_parts=join_parts,
         favorite=None,
         search=None,
+        genre_ids=None,
         provider_filter=["spotify_1"],
         in_library_only=False,
     )