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