"""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,
)
-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"
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"))
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
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]:
: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] = []
result = await self.get_library_items_by_query(
favorite=favorite,
search=search,
+ genre_ids=genre,
limit=limit,
offset=offset,
order_by=order_by,
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]:
: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] = []
return await self.get_library_items_by_query(
favorite=favorite,
search=search,
+ genre_ids=genre,
limit=limit,
offset=offset,
order_by=order_by,
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.
: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,
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),
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
"external_ids",
"narrators",
"authors",
+ "genre_aliases",
)
SORT_KEYS = {
offset: int = 0,
order_by: str = "sort_name",
provider: str | list[str] | None = None,
+ genre: int | list[int] | None = None,
**kwargs: Any,
) -> list[ItemCls]:
"""
: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,
offset=offset,
order_by=order_by,
provider_filter=self._ensure_provider_filter(provider),
+ genre_ids=genre,
in_library_only=True,
)
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."""
next_items = await self.get_library_items_by_query(
favorite=favorite,
search=search,
+ genre_ids=genre,
limit=limit,
offset=offset,
order_by=order_by,
"""
@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,
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."""
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(
join_parts=join_parts,
favorite=favorite,
search=search,
+ genre_ids=genre_ids,
provider_filter=provider_filter,
limit=limit,
in_library_only=in_library_only,
join_parts=join_parts,
favorite=favorite,
search=search,
+ genre_ids=genre_ids,
provider_filter=provider_filter,
in_library_only=in_library_only,
)
)
]
+ @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."""
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]:
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,
join_parts=sub_join_parts,
favorite=favorite,
search=search,
+ genre_ids=genre_ids,
provider_filter=provider_filter,
in_library_only=in_library_only,
)
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 = []
-"""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,
+ }
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.
: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,
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),
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.
: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,
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),
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,
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
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
"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()
[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}(
DB_TABLE_RADIOS,
DB_TABLE_AUDIOBOOKS,
DB_TABLE_PODCASTS,
+ DB_TABLE_GENRES,
):
# index on favorite column
await self.database.execute(
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 "
"radios",
"audiobooks",
"podcasts",
+ "genres",
):
await self.database.execute(
f"""
Artist,
Audiobook,
BrowseFolder,
+ Genre,
ItemMapping,
MediaItemType,
PlayableMediaItemType,
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"
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,
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]:
)
)
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
--- /dev/null
+[
+ {
+ "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"
+ ]
+ }
+]
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
"""
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,
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.")
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:
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
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(
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:
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(
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:
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
from __future__ import annotations
+import random
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
)
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:
"""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}"),
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,
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(
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,
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",
MediaItemChapter(position=2, name="Chapter 2", start=20, end=40),
MediaItemChapter(position=2, name="Chapter 3", start=40),
],
+ genres={genre},
),
provider_mappings={
ProviderMapping(
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,
},
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),
)
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]
--- /dev/null
+"""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)
--- /dev/null
+"""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)
join_parts=join_parts,
favorite=None,
search=None,
+ genre_ids=None,
provider_filter=None,
in_library_only=True,
)
join_parts=join_parts,
favorite=None,
search=None,
+ genre_ids=None,
provider_filter=["spotify_1"],
in_library_only=True,
)
join_parts=join_parts,
favorite=None,
search=None,
+ genre_ids=None,
provider_filter=None,
in_library_only=False,
)
join_parts=join_parts,
favorite=None,
search=None,
+ genre_ids=None,
provider_filter=["spotify_1"],
in_library_only=False,
)