From: Jozef Kruszynski <60214390+jozefKruszynski@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:04:09 +0000 (+0100) Subject: feat(genres): add merge genres functionality (#3236) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=7bbad860f943ce2442310f4411b6b9f055a9b18c;p=music-assistant-server.git feat(genres): add merge genres functionality (#3236) * feat(genres): add merge genres functionality * feat(genres): add empty genre filtering * fix(genres): review comments --- diff --git a/music_assistant/controllers/media/genres.py b/music_assistant/controllers/media/genres.py index 1c601012..fbd234d4 100644 --- a/music_assistant/controllers/media/genres.py +++ b/music_assistant/controllers/media/genres.py @@ -130,6 +130,11 @@ class GenreController(MediaControllerBase[Genre]): "music/genres/genres_for_media_item", self.get_genres_for_media_item, ) + self.mass.register_api_command( + "music/genres/merge", + self.merge_genres, + required_role="admin", + ) # Run genre mapping scanner after library sync completes self.mass.subscribe(self._on_sync_tasks_updated, EventType.SYNC_TASKS_UPDATED) @@ -266,11 +271,14 @@ class GenreController(MediaControllerBase[Genre]): order_by: str = "sort_name", provider: str | list[str] | None = None, genre: int | list[int] | None = None, + hide_empty: bool = True, **kwargs: Any, ) -> list[Genre]: """Get genres in the library. :param genre: NOT SUPPORTED - Filtering genres by genres doesn't make sense. + :param hide_empty: If True (default), only return genres that have media mappings. + Set to False to return all genres including unmapped ones. """ if genre is not None: msg = "genre parameter is not supported for Genre.library_items()" @@ -280,8 +288,14 @@ class GenreController(MediaControllerBase[Genre]): # 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 + extra_parts: list[str] | None = None if search: extra_params = {"search_raw": f"%{search.strip().lower()}%"} + if hide_empty: + gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING + extra_parts = [ + f"EXISTS(SELECT 1 FROM {gm} gm WHERE gm.genre_id = {self.db_table}.item_id)" + ] return await self.get_library_items_by_query( favorite=favorite, search=search, @@ -289,6 +303,7 @@ class GenreController(MediaControllerBase[Genre]): offset=offset, order_by=order_by, extra_query_params=extra_params, + extra_query_parts=extra_parts, ) async def radio_mode_base_tracks( @@ -939,6 +954,62 @@ class GenreController(MediaControllerBase[Genre]): return await self.get_library_item(new_genre_id) + async def merge_genres(self, genre_ids: list[str | int], target_genre_id: str | int) -> Genre: + """Merge one or more genres into a target genre. + + Transfers all aliases and media mappings from the source genres to the + target, then deletes the source genres. Aliases and mappings are + deduplicated so no duplicates are created on the target. + + :param genre_ids: List of genre IDs to merge into the target. + :param target_genre_id: Database ID of the genre to merge into. + """ + target_id = int(target_genre_id) + source_ids = [int(gid) for gid in genre_ids] + + if target_id in source_ids: + msg = "Target genre cannot be in the list of genres to merge" + raise ValueError(msg) + if not source_ids: + msg = "No genre IDs provided to merge" + raise ValueError(msg) + + target_genre = await self.get_library_item(target_id) + db = self.mass.music.database + gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING + + # Collect and merge aliases from all source genres into the target + all_new_aliases: list[str] = [] + for source_id in source_ids: + source_genre = await self.get_library_item(source_id) + if source_genre.genre_aliases: + all_new_aliases.extend(source_genre.genre_aliases) + + existing_aliases = list(target_genre.genre_aliases) if target_genre.genre_aliases else [] + merged_aliases = self._dedup_aliases(existing_aliases, all_new_aliases) + await db.update( + self.db_table, + {"item_id": target_id}, + {"genre_aliases": serialize_to_json(merged_aliases)}, + ) + + # Transfer media mappings from source genres to target (deduplicated) + placeholders = ", ".join(str(sid) for sid in source_ids) + await db.execute( + f"INSERT OR IGNORE INTO {gm} (genre_id, media_id, media_type, alias) " + f"SELECT :target_id, media_id, media_type, alias FROM {gm} " + f"WHERE genre_id IN ({placeholders})", + {"target_id": target_id}, + ) + + # Delete source genres (remove_item_from_library cleans up remaining mappings) + for source_id in source_ids: + await self.remove_item_from_library(source_id) + + updated = await self.get_library_item(target_id) + self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, updated.uri, updated) + return updated + async def sync_media_item_genres( self, media_type: MediaType, media_id: str | int, genre_names: set[str] ) -> None: diff --git a/tests/core/test_genres.py b/tests/core/test_genres.py index dab60686..f970625c 100644 --- a/tests/core/test_genres.py +++ b/tests/core/test_genres.py @@ -196,7 +196,7 @@ class TestGenreCRUD: """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() + items = await genre_ctrl.library_items(hide_empty=False) names = {g.name for g in items} assert {"Alpha", "Beta", "Gamma"}.issubset(names) @@ -204,7 +204,7 @@ class TestGenreCRUD: """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") + items = await genre_ctrl.library_items(search="country", hide_empty=False) assert all("country" in g.name.lower() for g in items) async def test_library_items_rejects_genre_param(self, genre_ctrl: GenreController) -> None: @@ -643,6 +643,146 @@ class TestPromoteAlias: assert "PromComplete" in updated_parent.genre_aliases +# =================================================================== +# Group F2: merge_genres (7 tests) +# =================================================================== + + +class TestMergeGenres: + """Tests for merge_genres.""" + + async def test_merge_transfers_aliases( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """Aliases from source genres are added to the target.""" + target = await genre_ctrl.add_item_to_library(_make_genre("MergeTarget")) + source = await genre_ctrl.add_item_to_library(_make_genre("MergeSource")) + await genre_ctrl.add_alias(source.item_id, "SourceAlias") + + result = await genre_ctrl.merge_genres([source.item_id], target.item_id) + assert result.genre_aliases is not None + assert "MergeTarget" in result.genre_aliases + assert "MergeSource" in result.genre_aliases + assert "SourceAlias" in result.genre_aliases + + async def test_merge_transfers_media_mappings( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """Media mappings from source genres are moved to the target.""" + target = await genre_ctrl.add_item_to_library(_make_genre("MergeMapTarget")) + source = await genre_ctrl.add_item_to_library(_make_genre("MergeMapSource")) + track = await _add_test_track(mass, "Merge Track") + await genre_ctrl.add_media_mapping( + source.item_id, MediaType.TRACK, track.item_id, "MergeMapSource" + ) + + await genre_ctrl.merge_genres([source.item_id], target.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 = 'track' AND media_id = :mid", + {"gid": int(target.item_id), "mid": int(track.item_id)}, + limit=1, + ) + assert len(rows) == 1 + + async def test_merge_deletes_source_genres( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """Source genres are deleted after merge.""" + target = await genre_ctrl.add_item_to_library(_make_genre("MergeDelTarget")) + source = await genre_ctrl.add_item_to_library(_make_genre("MergeDelSource")) + + await genre_ctrl.merge_genres([source.item_id], target.item_id) + with pytest.raises(MediaNotFoundError): + await genre_ctrl.get_library_item(int(source.item_id)) + + async def test_merge_deduplicates_aliases( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """Overlapping aliases are not duplicated on the target.""" + target = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupTarget")) + await genre_ctrl.add_alias(target.item_id, "SharedAlias") + source = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupSource")) + await genre_ctrl.add_alias(source.item_id, "SharedAlias") + + result = await genre_ctrl.merge_genres([source.item_id], target.item_id) + assert result.genre_aliases is not None + alias_list = list(result.genre_aliases) + norm_aliases = [a for a in alias_list if a.lower().replace(" ", "") == "sharedalias"] + assert len(norm_aliases) == 1 + + async def test_merge_deduplicates_media_mappings( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """Overlapping media mappings do not create duplicates.""" + target = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupMapTarget")) + source = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupMapSource")) + track = await _add_test_track(mass, "Merge Dedup Track") + # Both genres map the same track + await genre_ctrl.add_media_mapping( + target.item_id, MediaType.TRACK, track.item_id, "MergeDedupMapTarget" + ) + await genre_ctrl.add_media_mapping( + source.item_id, MediaType.TRACK, track.item_id, "MergeDedupMapSource" + ) + + await genre_ctrl.merge_genres([source.item_id], target.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 = 'track' AND media_id = :mid", + {"gid": int(target.item_id), "mid": int(track.item_id)}, + limit=0, + ) + assert len(rows) == 1 + + async def test_merge_multiple_sources( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """Multiple source genres can be merged at once.""" + target = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiTarget")) + source1 = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiSrc1")) + source2 = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiSrc2")) + track1 = await _add_test_track(mass, "Multi Merge Track 1") + track2 = await _add_test_track(mass, "Multi Merge Track 2") + await genre_ctrl.add_media_mapping( + source1.item_id, MediaType.TRACK, track1.item_id, "MergeMultiSrc1" + ) + await genre_ctrl.add_media_mapping( + source2.item_id, MediaType.TRACK, track2.item_id, "MergeMultiSrc2" + ) + + result = await genre_ctrl.merge_genres([source1.item_id, source2.item_id], target.item_id) + assert result.genre_aliases is not None + assert "MergeMultiSrc1" in result.genre_aliases + assert "MergeMultiSrc2" in result.genre_aliases + + # Both tracks mapped to target + 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(target.item_id)}, + limit=0, + ) + assert len(rows) == 2 + + # Both sources deleted + for src in (source1, source2): + with pytest.raises(MediaNotFoundError): + await genre_ctrl.get_library_item(int(src.item_id)) + + async def test_merge_target_in_source_raises(self, genre_ctrl: GenreController) -> None: + """Raises ValueError when target is in the source list.""" + genre = await genre_ctrl.add_item_to_library(_make_genre("MergeSelfTarget")) + with pytest.raises(ValueError, match="Target genre cannot be in the list"): + await genre_ctrl.merge_genres([genre.item_id], genre.item_id) + + async def test_merge_empty_source_raises(self, genre_ctrl: GenreController) -> None: + """Raises ValueError when source list is empty.""" + target = await genre_ctrl.add_item_to_library(_make_genre("MergeEmptyTarget")) + with pytest.raises(ValueError, match="No genre IDs provided"): + await genre_ctrl.merge_genres([], target.item_id) + + # =================================================================== # Group G: restore_default_genres (5 tests) # =================================================================== @@ -680,7 +820,7 @@ class TestRestoreDefaultGenres: """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) + items = await genre_ctrl.library_items(limit=0, hide_empty=False) names = {g.name for g in items} assert "MyCustomGenre" not in names assert len(items) == len(DEFAULT_GENRE_MAPPING) @@ -692,7 +832,7 @@ class TestRestoreDefaultGenres: 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"]) + items = await genre_ctrl.library_items(search=entry["genre"], hide_empty=False) assert len(items) > 0 genre = items[0] assert genre.genre_aliases is not None @@ -802,6 +942,51 @@ class TestQueryMethods: genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id) assert genres == [] + async def test_library_items_hide_empty_true( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """hide_empty=True returns only genres with mappings.""" + mapped = await genre_ctrl.add_item_to_library(_make_genre("HasMappingGenre")) + unmapped = await genre_ctrl.add_item_to_library(_make_genre("NoMappingGenre")) + track = await _add_test_track(mass, "HasMapping Track") + await genre_ctrl.add_media_mapping( + mapped.item_id, MediaType.TRACK, track.item_id, "HasMappingGenre" + ) + results = await genre_ctrl.library_items(hide_empty=True) + result_ids = {int(g.item_id) for g in results} + assert int(mapped.item_id) in result_ids + assert int(unmapped.item_id) not in result_ids + + async def test_library_items_hide_empty_default( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """Default (hide_empty=True) excludes unmapped genres.""" + mapped = await genre_ctrl.add_item_to_library(_make_genre("DefaultFilterMapped")) + unmapped = await genre_ctrl.add_item_to_library(_make_genre("DefaultFilterUnmapped")) + track = await _add_test_track(mass, "DefaultFilter Track") + await genre_ctrl.add_media_mapping( + mapped.item_id, MediaType.TRACK, track.item_id, "DefaultFilterMapped" + ) + results = await genre_ctrl.library_items() + result_ids = {int(g.item_id) for g in results} + assert int(mapped.item_id) in result_ids + assert int(unmapped.item_id) not in result_ids + + async def test_library_items_show_all( + self, mass: MusicAssistant, genre_ctrl: GenreController + ) -> None: + """hide_empty=False returns all genres including unmapped.""" + mapped = await genre_ctrl.add_item_to_library(_make_genre("ShowAllMapped")) + unmapped = await genre_ctrl.add_item_to_library(_make_genre("ShowAllUnmapped")) + track = await _add_test_track(mass, "ShowAll Track") + await genre_ctrl.add_media_mapping( + mapped.item_id, MediaType.TRACK, track.item_id, "ShowAllMapped" + ) + results = await genre_ctrl.library_items(hide_empty=False) + result_ids = {int(g.item_id) for g in results} + assert int(mapped.item_id) in result_ids + assert int(unmapped.item_id) in result_ids + # =================================================================== # Group I: Genre Lookup & Scanner (5 tests) @@ -859,7 +1044,7 @@ class TestBaseClassIntegration: 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") + items = await genre_ctrl.library_items(search="InlineTest", hide_empty=False) assert len(items) >= 1 fetched = items[0] assert fetched.genre_aliases is not None @@ -870,8 +1055,8 @@ class TestBaseClassIntegration: """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") + page1 = await genre_ctrl.library_items(limit=2, offset=0, order_by="name", hide_empty=False) + page2 = await genre_ctrl.library_items(limit=2, offset=2, order_by="name", hide_empty=False) assert len(page1) == 2 assert len(page2) == 2 ids1 = {g.item_id for g in page1} @@ -882,6 +1067,6 @@ class TestBaseClassIntegration: """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) + favs = await genre_ctrl.library_items(favorite=True, hide_empty=False) assert all(g.favorite for g in favs) assert any(g.name == "FavYes" for g in favs)