/
/
1"""
2Integration tests for the GenreController (V3 schema).
3
4Uses a database-only MusicAssistant instance with a real SQLite database in a
5temporary directory.
6"""
7
8from __future__ import annotations
9
10import asyncio
11import json
12from pathlib import Path
13from typing import Any
14from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
15from uuid import uuid4
16
17import pytest
18from music_assistant_models.enums import AlbumType, MediaType
19from music_assistant_models.errors import MediaNotFoundError
20from music_assistant_models.helpers import create_safe_string
21from music_assistant_models.media_items import (
22 Album,
23 Artist,
24 Genre,
25 Podcast,
26 ProviderMapping,
27 Track,
28)
29from music_assistant_models.unique_list import UniqueList
30
31from music_assistant.constants import (
32 DB_TABLE_ALBUM_TRACKS,
33 DB_TABLE_ALBUMS,
34 DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION,
35 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
36 DB_TABLE_GENRES,
37 DB_TABLE_PLAYLOG,
38 DB_TABLE_PODCASTS,
39 DB_TABLE_PROVIDER_MAPPINGS,
40 DB_TABLE_TRACKS,
41 DEFAULT_AUDIOBOOK_GENRE_MAPPING,
42 DEFAULT_GENRE_MAPPING,
43 DEFAULT_PODCAST_GENRE_MAPPING,
44)
45from music_assistant.controllers.music.media.genres import GenreController
46from music_assistant.mass import MusicAssistant
47
48# ---------------------------------------------------------------------------
49# Fixtures & helpers
50# ---------------------------------------------------------------------------
51
52
53@pytest.fixture(scope="class", name="mass")
54def mass_fixture(music_mass_class: MusicAssistant) -> MusicAssistant:
55 """Return the class-scoped database-only Music Assistant fixture."""
56 return music_mass_class
57
58
59@pytest.fixture(scope="class")
60async def genre_ctrl(mass: MusicAssistant) -> GenreController:
61 """Get the genre controller from a running MusicAssistant instance."""
62 return mass.music.genres
63
64
65def _make_genre(name: str, favorite: bool = False) -> Genre:
66 """Create a Genre object for adding to the library."""
67 return Genre(
68 item_id="0",
69 provider="library",
70 name=name,
71 provider_mappings=set(),
72 favorite=favorite,
73 )
74
75
76def _library_provider_mapping() -> set[ProviderMapping]:
77 """Create a provider mapping set with in_library=True and a unique provider_item_id."""
78 return {
79 ProviderMapping(
80 item_id=uuid4().hex,
81 provider_domain="library",
82 provider_instance="library",
83 in_library=True,
84 )
85 }
86
87
88async def _add_test_artist(mass: MusicAssistant, name: str) -> Artist:
89 """Add a minimal artist to the library."""
90 artist = Artist(
91 item_id="0",
92 provider="library",
93 name=name,
94 provider_mappings=_library_provider_mapping(),
95 )
96 return await mass.music.artists.add_item_to_library(artist)
97
98
99async def _add_test_track(mass: MusicAssistant, name: str) -> Track:
100 """Add a minimal track to the library (creates an artist first)."""
101 artist = await _add_test_artist(mass, f"Artist for {name}")
102 track = Track(
103 item_id="0",
104 provider="library",
105 name=name,
106 provider_mappings=_library_provider_mapping(),
107 artists=UniqueList([artist]),
108 )
109 return await mass.music.tracks.add_item_to_library(track)
110
111
112async def _add_test_podcast(mass: MusicAssistant, name: str) -> Podcast:
113 """Add a minimal podcast to the library."""
114 podcast = Podcast(
115 item_id="0",
116 provider="library",
117 name=name,
118 provider_mappings=_library_provider_mapping(),
119 )
120 return await mass.music.podcasts.add_item_to_library(podcast)
121
122
123async def _set_podcast_genres(mass: MusicAssistant, podcast_id: int, genres: list[str]) -> None:
124 """Set metadata.genres on a podcast row directly in the DB."""
125 await mass.music.database.execute(
126 f"UPDATE {DB_TABLE_PODCASTS} "
127 "SET metadata = json_set(metadata, '$.genres', json(:genres)) "
128 "WHERE item_id = :id",
129 {"genres": json.dumps(genres), "id": podcast_id},
130 )
131 await mass.music.database.commit()
132
133
134async def _add_test_album(mass: MusicAssistant, name: str) -> Album:
135 """Add a minimal album to the library."""
136 album = Album(
137 item_id="0",
138 provider="library",
139 name=name,
140 provider_mappings=_library_provider_mapping(),
141 album_type=AlbumType.ALBUM,
142 )
143 return await mass.music.albums.add_item_to_library(album)
144
145
146# ===================================================================
147# Group B: Genre CRUD (14 tests)
148# ===================================================================
149
150
151class TestGenreCRUD:
152 """Tests for adding, reading, updating, and removing genres."""
153
154 async def test_add_genre(self, genre_ctrl: GenreController) -> None:
155 """add_item_to_library returns Genre with numeric id and correct name."""
156 genre = await genre_ctrl.add_item_to_library(_make_genre("Rock"))
157 assert int(genre.item_id) > 0
158 assert genre.name == "Rock"
159
160 async def test_add_genre_creates_self_alias(
161 self, mass: MusicAssistant, genre_ctrl: GenreController
162 ) -> None:
163 """Genre has its own name in genre_aliases JSON column."""
164 genre = await genre_ctrl.add_item_to_library(_make_genre("Blues"))
165 # Check genre_aliases JSON column directly
166 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": int(genre.item_id)})
167 assert row is not None
168 aliases = json.loads(row["genre_aliases"])
169 assert "Blues" in aliases
170
171 async def test_add_genre_duplicate_updates(self, genre_ctrl: GenreController) -> None:
172 """Adding the same genre with library id returns the same item_id (update, no duplicate)."""
173 genre1 = await genre_ctrl.add_item_to_library(_make_genre("Jazz"))
174 # Second add using the real library id (simulates re-adding same item)
175 dup = Genre(
176 item_id=genre1.item_id,
177 provider="library",
178 name="Jazz",
179 provider_mappings=set(),
180 )
181 genre2 = await genre_ctrl.add_item_to_library(dup)
182 assert genre1.item_id == genre2.item_id
183
184 async def test_get_library_item(self, genre_ctrl: GenreController) -> None:
185 """get_library_item returns Genre with genre_aliases populated."""
186 created = await genre_ctrl.add_item_to_library(_make_genre("Funk"))
187 fetched = await genre_ctrl.get_library_item(int(created.item_id))
188 assert fetched.name == "Funk"
189 assert fetched.genre_aliases is not None
190 assert "Funk" in fetched.genre_aliases
191
192 async def test_content_type_defaults_to_none(self, genre_ctrl: GenreController) -> None:
193 """A genre added without a content_type round-trips as None (music/general)."""
194 created = await genre_ctrl.add_item_to_library(_make_genre("Soul"))
195 fetched = await genre_ctrl.get_library_item(int(created.item_id))
196 assert fetched.content_type is None
197
198 async def test_content_type_persists_and_round_trips(self, genre_ctrl: GenreController) -> None:
199 """A genre's content_type is persisted to the DB column and read back as the enum."""
200 genre = Genre(
201 item_id="0",
202 provider="library",
203 name="True Crime",
204 provider_mappings=set(),
205 content_type=MediaType.PODCAST,
206 )
207 created = await genre_ctrl.add_item_to_library(genre)
208 fetched = await genre_ctrl.get_library_item(int(created.item_id))
209 assert fetched.content_type is MediaType.PODCAST
210
211 async def test_content_type_immutable_on_overwrite_update(
212 self, genre_ctrl: GenreController
213 ) -> None:
214 """The taxonomy is set at creation and is not changed by an update, even with overwrite."""
215 genre = Genre(
216 item_id="0",
217 provider="library",
218 name="Documentary",
219 provider_mappings=set(),
220 content_type=MediaType.PODCAST,
221 )
222 created = await genre_ctrl.add_item_to_library(genre)
223 update = Genre(
224 item_id="0",
225 provider="library",
226 name="Documentary",
227 provider_mappings=set(),
228 content_type=MediaType.AUDIOBOOK,
229 )
230 updated = await genre_ctrl.update_item_in_library(created.item_id, update, overwrite=True)
231 assert updated.content_type is MediaType.PODCAST
232 fetched = await genre_ctrl.get_library_item(int(created.item_id))
233 assert fetched.content_type is MediaType.PODCAST
234
235 async def test_get_library_item_not_found(self, genre_ctrl: GenreController) -> None:
236 """Raises MediaNotFoundError for nonexistent id."""
237 with pytest.raises(MediaNotFoundError):
238 await genre_ctrl.get_library_item(999999)
239
240 async def test_update_smart_merge(self, genre_ctrl: GenreController) -> None:
241 """Update with metadata merges without overwrite flag."""
242 genre = await genre_ctrl.add_item_to_library(_make_genre("Reggae"))
243 update = _make_genre("Reggae")
244 update.favorite = True
245 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=False)
246 assert updated.favorite is True
247 assert updated.name == "Reggae"
248
249 async def test_update_overwrite(self, genre_ctrl: GenreController) -> None:
250 """Update with overwrite=True replaces name."""
251 genre = await genre_ctrl.add_item_to_library(_make_genre("OldName"))
252 update = _make_genre("NewName")
253 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=True)
254 assert updated.name == "NewName"
255
256 async def test_update_ensures_self_alias(self, genre_ctrl: GenreController) -> None:
257 """After name update, self-alias exists for new name."""
258 genre = await genre_ctrl.add_item_to_library(_make_genre("OldGenre"))
259 update = _make_genre("RenamedGenre")
260 updated = await genre_ctrl.update_item_in_library(genre.item_id, update, overwrite=True)
261 assert updated.genre_aliases is not None
262 assert "RenamedGenre" in updated.genre_aliases
263
264 async def test_remove_genre(self, genre_ctrl: GenreController) -> None:
265 """After remove, get_library_item raises MediaNotFoundError."""
266 genre = await genre_ctrl.add_item_to_library(_make_genre("Ska"))
267 await genre_ctrl.remove_item_from_library(genre.item_id)
268 with pytest.raises(MediaNotFoundError):
269 await genre_ctrl.get_library_item(int(genre.item_id))
270
271 async def test_remove_cleans_mappings(
272 self, mass: MusicAssistant, genre_ctrl: GenreController
273 ) -> None:
274 """After remove, genre_media_item_mapping entries for that genre are gone."""
275 genre = await genre_ctrl.add_item_to_library(_make_genre("Dubstep"))
276 genre_id = int(genre.item_id)
277 # Add a media mapping first
278 track = await _add_test_track(mass, "Dubstep Track")
279 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track.item_id, "Dubstep")
280 # Now remove the genre
281 await genre_ctrl.remove_item_from_library(genre.item_id)
282 rows = await mass.music.database.get_rows_from_query(
283 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} WHERE genre_id = :genre_id",
284 {"genre_id": genre_id},
285 limit=0,
286 )
287 assert len(rows) == 0
288
289 async def test_library_items(self, genre_ctrl: GenreController) -> None:
290 """Add 3 genres, returns all 3."""
291 for name in ("Alpha", "Beta", "Gamma"):
292 await genre_ctrl.add_item_to_library(_make_genre(name))
293 items = await genre_ctrl.library_items(hide_empty=False)
294 names = {g.name for g in items}
295 assert {"Alpha", "Beta", "Gamma"}.issubset(names)
296
297 async def test_library_items_search(self, genre_ctrl: GenreController) -> None:
298 """Search 'country' returns Country genre but not unrelated ones like Metal."""
299 await genre_ctrl.add_item_to_library(_make_genre("Metal"))
300 items = await genre_ctrl.library_items(search="country", hide_empty=False)
301 names = {g.name for g in items}
302 assert "country" in names
303 assert "Metal" not in names
304
305 async def test_library_items_hide_empty_true(
306 self, mass: MusicAssistant, genre_ctrl: GenreController
307 ) -> None:
308 """hide_empty=True returns only genres with media mappings."""
309 mapped = await genre_ctrl.add_item_to_library(_make_genre("HideEmptyMapped"))
310 await genre_ctrl.add_item_to_library(_make_genre("HideEmptyUnmapped"))
311 track = await _add_test_track(mass, "HideEmpty Track")
312 await genre_ctrl.add_media_mapping(
313 int(mapped.item_id), MediaType.TRACK, track.item_id, "HideEmptyMapped"
314 )
315 items = await genre_ctrl.library_items(hide_empty=True)
316 names = {g.name for g in items}
317 assert "HideEmptyMapped" in names
318 assert "HideEmptyUnmapped" not in names
319
320 async def test_library_items_hide_empty_false(self, genre_ctrl: GenreController) -> None:
321 """hide_empty=False returns all genres regardless of mappings."""
322 await genre_ctrl.add_item_to_library(_make_genre("HideEmptyFalseGenre"))
323 items = await genre_ctrl.library_items(hide_empty=False)
324 names = {g.name for g in items}
325 assert "HideEmptyFalseGenre" in names
326
327 async def test_library_items_hide_empty_none_returns_default_genres(
328 self, genre_ctrl: GenreController
329 ) -> None:
330 """
331 hide_empty=None (default) returns only default genres (translation_key IS NOT NULL).
332
333 Default genres are seeded via restore_default_genres (translation_key IS NOT NULL).
334 Non-default genres created via _find_genres_for_alias mirror the library scan path
335 and store translation_key=NULL in the DB.
336 """
337 await genre_ctrl.restore_default_genres()
338 scanned_name = "ScannedNonDefaultGenreXyz"
339 await genre_ctrl._find_genres_for_alias(scanned_name, None)
340
341 default_genre_name = DEFAULT_GENRE_MAPPING[0]["genre"]
342 items = await genre_ctrl.library_items(hide_empty=None)
343 names = {g.name for g in items}
344 assert default_genre_name in names
345 assert scanned_name not in names
346
347 async def test_library_items_default_is_hide_empty_none(
348 self, genre_ctrl: GenreController
349 ) -> None:
350 """Calling library_items() with no hide_empty arg behaves like hide_empty=None."""
351 await genre_ctrl.restore_default_genres()
352 await genre_ctrl._find_genres_for_alias("DefaultArgScannedGenreXyz", None)
353 default_genre_name = DEFAULT_GENRE_MAPPING[0]["genre"]
354 items_default = await genre_ctrl.library_items()
355 items_none = await genre_ctrl.library_items(hide_empty=None)
356 assert {g.item_id for g in items_default} == {g.item_id for g in items_none}
357 names = {g.name for g in items_default}
358 assert default_genre_name in names
359 assert "DefaultArgScannedGenreXyz" not in names
360
361 async def test_library_items_rejects_genre_param(self, genre_ctrl: GenreController) -> None:
362 """library_items(genre=1) raises ValueError."""
363 with pytest.raises(ValueError, match="genre parameter is not supported"):
364 await genre_ctrl.library_items(genre=1)
365
366 async def test_library_items_media_type_filter(
367 self, mass: MusicAssistant, genre_ctrl: GenreController
368 ) -> None:
369 """
370 media_type filter returns all non-empty genres for that type, including non-defaults.
371
372 Verifies:
373 - Non-default genres (no translation_key) with mappings ARE returned — the default
374 translation_key IS NOT NULL filter is bypassed when media_type is set.
375 - Genres mapped only to another type are excluded.
376 - Default genres (with translation_key) that have no mapping for the type are excluded.
377 - A genre mapped to multiple types appears in results for each of those types.
378 - search composing with media_type works correctly.
379 - No mappings for the requested type returns an empty list.
380 """
381 track_genre = await genre_ctrl.add_item_to_library(_make_genre("MT_FilterTrackOnlyGenre"))
382 album_genre = await genre_ctrl.add_item_to_library(_make_genre("MT_FilterAlbumOnlyGenre"))
383 shared_genre = await genre_ctrl.add_item_to_library(_make_genre("MT_FilterSharedGenre"))
384
385 track = await _add_test_track(mass, "MT Filter Track")
386 album = await _add_test_album(mass, "MT Filter Album")
387 await genre_ctrl.add_media_mapping(
388 int(track_genre.item_id), MediaType.TRACK, track.item_id, "MT_FilterTrackOnlyGenre"
389 )
390 await genre_ctrl.add_media_mapping(
391 int(album_genre.item_id), MediaType.ALBUM, album.item_id, "MT_FilterAlbumOnlyGenre"
392 )
393 # shared_genre is mapped to both tracks and albums
394 await genre_ctrl.add_media_mapping(
395 int(shared_genre.item_id), MediaType.TRACK, track.item_id, "MT_FilterSharedGenre"
396 )
397 await genre_ctrl.add_media_mapping(
398 int(shared_genre.item_id), MediaType.ALBUM, album.item_id, "MT_FilterSharedGenre"
399 )
400
401 # Add a default genre (has translation_key) that has NO mappings for any type —
402 # it must not appear in media_type results even though hide_empty=None would normally
403 # include all defaults.
404 await genre_ctrl.restore_default_genres()
405
406 track_results = await genre_ctrl.library_items(media_type=MediaType.TRACK)
407 track_names = {g.name for g in track_results}
408 assert "MT_FilterTrackOnlyGenre" in track_names, (
409 "track-mapped genre missing from TRACK results"
410 )
411 assert "MT_FilterSharedGenre" in track_names, "shared genre missing from TRACK results"
412 assert "MT_FilterAlbumOnlyGenre" not in track_names, (
413 "album-only genre appeared in TRACK results"
414 )
415 # Unmapped default genres must not bleed through —
416 # media_type overrides the translation_key filter
417 default_genre_name = DEFAULT_GENRE_MAPPING[0]["genre"]
418 assert default_genre_name not in track_names, (
419 "unmapped default genre appeared in TRACK results"
420 )
421
422 album_results = await genre_ctrl.library_items(media_type=MediaType.ALBUM)
423 album_names = {g.name for g in album_results}
424 assert "MT_FilterAlbumOnlyGenre" in album_names, (
425 "album-mapped genre missing from ALBUM results"
426 )
427 assert "MT_FilterSharedGenre" in album_names, "shared genre missing from ALBUM results"
428 assert "MT_FilterTrackOnlyGenre" not in album_names, (
429 "track-only genre appeared in ALBUM results"
430 )
431
432 # search composes correctly with media_type
433 search_results = await genre_ctrl.library_items(
434 media_type=MediaType.TRACK, search="MT_FilterShared"
435 )
436 search_names = {g.name for g in search_results}
437 assert "MT_FilterSharedGenre" in search_names
438 assert "MT_FilterTrackOnlyGenre" not in search_names
439
440 # No mappings for the requested type returns an empty list
441 playlist_results = await genre_ctrl.library_items(media_type=MediaType.PLAYLIST)
442 playlist_names = {g.name for g in playlist_results}
443 assert "MT_FilterTrackOnlyGenre" not in playlist_names
444 assert "MT_FilterAlbumOnlyGenre" not in playlist_names
445 assert "MT_FilterSharedGenre" not in playlist_names
446
447 async def test_library_count(self, genre_ctrl: GenreController) -> None:
448 """Returns correct count; favorite_only=True filters."""
449 await genre_ctrl.add_item_to_library(_make_genre("CountA"))
450 await genre_ctrl.add_item_to_library(_make_genre("CountB", favorite=True))
451 total = await genre_ctrl.library_count()
452 assert total >= 2
453 fav = await genre_ctrl.library_count(favorite_only=True)
454 assert fav >= 1
455 assert fav <= total
456
457
458# ===================================================================
459# Group C: Alias Operations (8 tests)
460# ===================================================================
461
462
463class TestAliasOperations:
464 """Tests for add_alias, remove_alias string operations on genres."""
465
466 async def test_add_alias(self, genre_ctrl: GenreController) -> None:
467 """add_alias adds a string to genre_aliases."""
468 genre = await genre_ctrl.add_item_to_library(_make_genre("Electronic"))
469 updated = await genre_ctrl.add_alias(genre.item_id, "EDM")
470 assert updated.genre_aliases is not None
471 assert "EDM" in updated.genre_aliases
472 assert "Electronic" in updated.genre_aliases
473
474 async def test_add_alias_idempotent(self, genre_ctrl: GenreController) -> None:
475 """Adding the same alias twice doesn't duplicate."""
476 genre = await genre_ctrl.add_item_to_library(_make_genre("House"))
477 await genre_ctrl.add_alias(genre.item_id, "Deep House")
478 updated = await genre_ctrl.add_alias(genre.item_id, "Deep House")
479 assert updated.genre_aliases is not None
480 assert list(updated.genre_aliases).count("Deep House") == 1
481
482 async def test_add_alias_multiple(self, genre_ctrl: GenreController) -> None:
483 """Multiple aliases can be added to a single genre."""
484 genre = await genre_ctrl.add_item_to_library(_make_genre("Ambient"))
485 await genre_ctrl.add_alias(genre.item_id, "Ambient Music")
486 updated = await genre_ctrl.add_alias(genre.item_id, "Chill Ambient")
487 assert updated.genre_aliases is not None
488 assert "Ambient" in updated.genre_aliases
489 assert "Ambient Music" in updated.genre_aliases
490 assert "Chill Ambient" in updated.genre_aliases
491
492 async def test_remove_alias(self, genre_ctrl: GenreController) -> None:
493 """remove_alias removes a string from genre_aliases."""
494 genre = await genre_ctrl.add_item_to_library(_make_genre("Techno"))
495 await genre_ctrl.add_alias(genre.item_id, "Detroit Techno")
496 updated = await genre_ctrl.remove_alias(genre.item_id, "Detroit Techno")
497 assert updated.genre_aliases is not None
498 assert "Detroit Techno" not in updated.genre_aliases
499 assert "Techno" in updated.genre_aliases
500
501 async def test_remove_self_alias_raises(self, genre_ctrl: GenreController) -> None:
502 """Removing the genre's own name raises ValueError."""
503 genre = await genre_ctrl.add_item_to_library(_make_genre("Soul"))
504 with pytest.raises(ValueError, match="Cannot remove self-alias"):
505 await genre_ctrl.remove_alias(genre.item_id, "Soul")
506
507 async def test_remove_alias_cleans_media_mappings(
508 self, mass: MusicAssistant, genre_ctrl: GenreController
509 ) -> None:
510 """Removing an alias also removes media mappings that used that alias."""
511 genre = await genre_ctrl.add_item_to_library(_make_genre("Latin"))
512 await genre_ctrl.add_alias(genre.item_id, "Latin Pop")
513 track = await _add_test_track(mass, "Latin Track")
514 await genre_ctrl.add_media_mapping(
515 genre.item_id, MediaType.TRACK, track.item_id, "Latin Pop"
516 )
517 # Remove the alias
518 await genre_ctrl.remove_alias(genre.item_id, "Latin Pop")
519 # Check mapping is gone
520 rows = await mass.music.database.get_rows_from_query(
521 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
522 "WHERE genre_id = :gid AND alias = :alias",
523 {"gid": int(genre.item_id), "alias": "Latin Pop"},
524 limit=0,
525 )
526 assert len(rows) == 0
527
528 async def test_add_alias_not_found(self, genre_ctrl: GenreController) -> None:
529 """add_alias for nonexistent genre raises MediaNotFoundError."""
530 with pytest.raises(MediaNotFoundError):
531 await genre_ctrl.add_alias(999999, "NoGenre")
532
533 async def test_remove_alias_not_found(self, genre_ctrl: GenreController) -> None:
534 """remove_alias for nonexistent genre raises MediaNotFoundError."""
535 with pytest.raises(MediaNotFoundError):
536 await genre_ctrl.remove_alias(999999, "NoGenre")
537
538
539# ===================================================================
540# Group D: Media Mapping Operations (8 tests)
541# ===================================================================
542
543
544class TestMediaMappingOperations:
545 """Tests for add_media_mapping and remove_media_mapping."""
546
547 async def test_add_media_mapping_track(
548 self, mass: MusicAssistant, genre_ctrl: GenreController
549 ) -> None:
550 """Mapping exists in genre_media_item_mapping table."""
551 genre = await genre_ctrl.add_item_to_library(_make_genre("Pop"))
552 track = await _add_test_track(mass, "Pop Track")
553 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Pop")
554 rows = await mass.music.database.get_rows_from_query(
555 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
556 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
557 {
558 "gid": int(genre.item_id),
559 "mt": MediaType.TRACK.value,
560 "mid": int(track.item_id),
561 },
562 limit=1,
563 )
564 assert len(rows) == 1
565 assert rows[0]["alias"] == "Pop"
566
567 async def test_add_media_mapping_sets_is_manual(
568 self, mass: MusicAssistant, genre_ctrl: GenreController
569 ) -> None:
570 """Mappings created via add_media_mapping have is_manual = 1."""
571 genre = await genre_ctrl.add_item_to_library(_make_genre("IsManualPop"))
572 track = await _add_test_track(mass, "IsManualPop Track")
573 await genre_ctrl.add_media_mapping(
574 genre.item_id, MediaType.TRACK, track.item_id, "IsManualPop"
575 )
576 row = await mass.music.database.get_row(
577 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
578 {
579 "genre_id": int(genre.item_id),
580 "media_id": int(track.item_id),
581 "media_type": MediaType.TRACK.value,
582 },
583 )
584 assert row is not None
585 assert row["is_manual"] == 1
586
587 async def test_add_media_mapping_upgrades_existing_auto_row(
588 self, mass: MusicAssistant, genre_ctrl: GenreController
589 ) -> None:
590 """Calling add_media_mapping on a pre-existing non-manual row upgrades is_manual to 1."""
591 genre = await genre_ctrl.add_item_to_library(_make_genre("UpgradeGenre"))
592 track = await _add_test_track(mass, "Upgrade Track")
593 # insert row directly without is_manual (simulates scanner-created row)
594 await mass.music.database.insert(
595 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
596 {
597 "genre_id": int(genre.item_id),
598 "media_id": int(track.item_id),
599 "media_type": MediaType.TRACK.value,
600 "alias": "UpgradeGenre",
601 },
602 )
603 row_before = await mass.music.database.get_row(
604 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
605 {
606 "genre_id": int(genre.item_id),
607 "media_id": int(track.item_id),
608 "media_type": MediaType.TRACK.value,
609 },
610 )
611 assert row_before is not None
612 assert row_before["is_manual"] == 0
613
614 await genre_ctrl.add_media_mapping(
615 genre.item_id, MediaType.TRACK, track.item_id, "UpgradeGenre"
616 )
617
618 row_after = await mass.music.database.get_row(
619 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
620 {
621 "genre_id": int(genre.item_id),
622 "media_id": int(track.item_id),
623 "media_type": MediaType.TRACK.value,
624 },
625 )
626 assert row_after is not None
627 assert row_after["is_manual"] == 1
628
629 async def test_add_media_mapping_idempotent(
630 self, mass: MusicAssistant, genre_ctrl: GenreController
631 ) -> None:
632 """Calling add_media_mapping twice doesn't raise (uses allow_replace)."""
633 genre = await genre_ctrl.add_item_to_library(_make_genre("Grunge"))
634 track = await _add_test_track(mass, "Grunge Song")
635 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Grunge")
636 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Grunge")
637
638 async def test_remove_media_mapping_track(
639 self, mass: MusicAssistant, genre_ctrl: GenreController
640 ) -> None:
641 """Mapping removed from DB."""
642 genre = await genre_ctrl.add_item_to_library(_make_genre("Disco"))
643 track = await _add_test_track(mass, "Disco Track")
644 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.TRACK, track.item_id, "Disco")
645 await genre_ctrl.remove_media_mapping(genre.item_id, MediaType.TRACK, track.item_id)
646 rows = await mass.music.database.get_rows_from_query(
647 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
648 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
649 {
650 "gid": int(genre.item_id),
651 "mt": MediaType.TRACK.value,
652 "mid": int(track.item_id),
653 },
654 limit=1,
655 )
656 assert len(rows) == 0
657
658 async def test_add_media_mapping_artist(
659 self, mass: MusicAssistant, genre_ctrl: GenreController
660 ) -> None:
661 """Artist mapping works correctly."""
662 genre = await genre_ctrl.add_item_to_library(_make_genre("Funk2"))
663 artist = await _add_test_artist(mass, "Funk Artist")
664 await genre_ctrl.add_media_mapping(genre.item_id, MediaType.ARTIST, artist.item_id, "Funk2")
665 rows = await mass.music.database.get_rows_from_query(
666 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
667 "WHERE genre_id = :gid AND media_type = :mt AND media_id = :mid",
668 {
669 "gid": int(genre.item_id),
670 "mt": MediaType.ARTIST.value,
671 "mid": int(artist.item_id),
672 },
673 limit=1,
674 )
675 assert len(rows) == 1
676
677 async def test_mapping_preserves_alias_string(
678 self, mass: MusicAssistant, genre_ctrl: GenreController
679 ) -> None:
680 """The alias column records which alias caused the mapping."""
681 genre = await genre_ctrl.add_item_to_library(_make_genre("Afrobeat"))
682 await genre_ctrl.add_alias(genre.item_id, "Highlife")
683 track = await _add_test_track(mass, "Afrobeat Track")
684 await genre_ctrl.add_media_mapping(
685 genre.item_id, MediaType.TRACK, track.item_id, "Highlife"
686 )
687 rows = await mass.music.database.get_rows_from_query(
688 f"SELECT alias FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
689 "WHERE genre_id = :gid AND media_id = :mid",
690 {"gid": int(genre.item_id), "mid": int(track.item_id)},
691 limit=1,
692 )
693 assert len(rows) == 1
694 assert rows[0]["alias"] == "Highlife"
695
696 async def test_multiple_genres_same_track(
697 self, mass: MusicAssistant, genre_ctrl: GenreController
698 ) -> None:
699 """A track can be mapped to multiple genres."""
700 genre1 = await genre_ctrl.add_item_to_library(_make_genre("Genre1"))
701 genre2 = await genre_ctrl.add_item_to_library(_make_genre("Genre2"))
702 track = await _add_test_track(mass, "Multi Genre Track")
703 await genre_ctrl.add_media_mapping(genre1.item_id, MediaType.TRACK, track.item_id, "Genre1")
704 await genre_ctrl.add_media_mapping(genre2.item_id, MediaType.TRACK, track.item_id, "Genre2")
705 rows = await mass.music.database.get_rows_from_query(
706 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
707 "WHERE media_id = :mid AND media_type = 'track'",
708 {"mid": int(track.item_id)},
709 limit=0,
710 )
711 assert len(rows) == 2
712
713 async def test_multiple_tracks_same_genre(
714 self, mass: MusicAssistant, genre_ctrl: GenreController
715 ) -> None:
716 """Multiple tracks can be mapped to the same genre."""
717 genre = await genre_ctrl.add_item_to_library(_make_genre("SharedGenre"))
718 track1 = await _add_test_track(mass, "Shared Track 1")
719 track2 = await _add_test_track(mass, "Shared Track 2")
720 await genre_ctrl.add_media_mapping(
721 genre.item_id, MediaType.TRACK, track1.item_id, "SharedGenre"
722 )
723 await genre_ctrl.add_media_mapping(
724 genre.item_id, MediaType.TRACK, track2.item_id, "SharedGenre"
725 )
726 rows = await mass.music.database.get_rows_from_query(
727 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
728 "WHERE genre_id = :gid AND media_type = 'track'",
729 {"gid": int(genre.item_id)},
730 limit=0,
731 )
732 assert len(rows) == 2
733
734 async def test_remove_nonexistent_mapping(self, genre_ctrl: GenreController) -> None:
735 """Removing a mapping that doesn't exist doesn't raise."""
736 genre = await genre_ctrl.add_item_to_library(_make_genre("NoMapping"))
737 await genre_ctrl.remove_media_mapping(genre.item_id, MediaType.TRACK, 999999)
738
739
740# ===================================================================
741# Group E: sync_media_item_genres (8 tests)
742# ===================================================================
743
744
745class TestSyncMediaItemGenres:
746 """Tests for sync_media_item_genres."""
747
748 async def test_sync_creates_genre(
749 self, mass: MusicAssistant, genre_ctrl: GenreController
750 ) -> None:
751 """New genre created, mapping exists."""
752 track = await _add_test_track(mass, "Sync Track 1")
753 unique_genre = "SzTestSyncGenreXYZ"
754 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {unique_genre})
755 rows = await mass.music.database.get_rows_from_query(
756 f"SELECT * FROM {DB_TABLE_GENRES} WHERE name = :name",
757 {"name": unique_genre},
758 limit=1,
759 )
760 assert len(rows) == 1
761
762 async def test_sync_uses_existing_genre(
763 self, mass: MusicAssistant, genre_ctrl: GenreController
764 ) -> None:
765 """No duplicate genre created."""
766 await genre_ctrl.add_item_to_library(_make_genre("Punk"))
767 track = await _add_test_track(mass, "Sync Track 2")
768 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"Punk"})
769 rows = await mass.music.database.get_rows_from_query(
770 f"SELECT * FROM {DB_TABLE_GENRES} WHERE name = :name",
771 {"name": "Punk"},
772 limit=0,
773 )
774 assert len(rows) == 1
775
776 async def test_sync_adds_new_mappings(
777 self, mass: MusicAssistant, genre_ctrl: GenreController
778 ) -> None:
779 """Multiple genres creates both mappings."""
780 track = await _add_test_track(mass, "Sync Track 3")
781 await genre_ctrl.sync_media_item_genres(
782 MediaType.TRACK, track.item_id, {"SyncRock", "SyncJazz"}
783 )
784 rows = await mass.music.database.get_rows_from_query(
785 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
786 "WHERE media_id = :mid AND media_type = 'track'",
787 {"mid": int(track.item_id)},
788 limit=0,
789 )
790 assert len(rows) == 2
791
792 async def test_sync_removes_stale_mappings(
793 self, mass: MusicAssistant, genre_ctrl: GenreController
794 ) -> None:
795 """Re-sync with subset removes stale mapping."""
796 track = await _add_test_track(mass, "Sync Track 4")
797 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncA", "SyncB"})
798 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncA"})
799 rows = await mass.music.database.get_rows_from_query(
800 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
801 "WHERE media_id = :mid AND media_type = 'track'",
802 {"mid": int(track.item_id)},
803 limit=0,
804 )
805 assert len(rows) == 1
806
807 async def test_sync_empty_set_removes_all(
808 self, mass: MusicAssistant, genre_ctrl: GenreController
809 ) -> None:
810 """Empty set removes all mappings."""
811 track = await _add_test_track(mass, "Sync Track 5")
812 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncX"})
813 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, set())
814 rows = await mass.music.database.get_rows_from_query(
815 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
816 "WHERE media_id = :mid AND media_type = 'track'",
817 {"mid": int(track.item_id)},
818 limit=0,
819 )
820 assert len(rows) == 0
821
822 async def test_sync_idempotent(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
823 """Second call with same set is a no-op."""
824 track = await _add_test_track(mass, "Sync Track 6")
825 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncIdem"})
826 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncIdem"})
827 rows = await mass.music.database.get_rows_from_query(
828 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
829 "WHERE media_id = :mid AND media_type = 'track'",
830 {"mid": int(track.item_id)},
831 limit=0,
832 )
833 assert len(rows) == 1
834
835 async def test_sync_skips_empty_names(
836 self, mass: MusicAssistant, genre_ctrl: GenreController
837 ) -> None:
838 """Empty and whitespace-only names are skipped."""
839 track = await _add_test_track(mass, "Sync Track 7")
840 await genre_ctrl.sync_media_item_genres(
841 MediaType.TRACK, track.item_id, {"SyncValid", "", " "}
842 )
843 rows = await mass.music.database.get_rows_from_query(
844 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
845 "WHERE media_id = :mid AND media_type = 'track'",
846 {"mid": int(track.item_id)},
847 limit=0,
848 )
849 assert len(rows) == 1
850
851 async def test_sync_concurrent(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
852 """asyncio.gather with different sets doesn't crash."""
853 track1 = await _add_test_track(mass, "Conc Track 1")
854 track2 = await _add_test_track(mass, "Conc Track 2")
855 await asyncio.gather(
856 genre_ctrl.sync_media_item_genres(MediaType.TRACK, track1.item_id, {"ConcA"}),
857 genre_ctrl.sync_media_item_genres(MediaType.TRACK, track2.item_id, {"ConcB"}),
858 )
859
860 async def test_sync_one_alias_maps_to_multiple_genres(
861 self, mass: MusicAssistant, genre_ctrl: GenreController
862 ) -> None:
863 """One alias shared by two genres creates mappings to both (n:n)."""
864 genre_a = await genre_ctrl.add_item_to_library(_make_genre("GenreA"))
865 genre_b = await genre_ctrl.add_item_to_library(_make_genre("GenreB"))
866 # Both genres claim "shared-alias"
867 await genre_ctrl.add_alias(genre_a.item_id, "shared-alias")
868 await genre_ctrl.add_alias(genre_b.item_id, "shared-alias")
869 track = await _add_test_track(mass, "SharedAlias Track")
870 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"shared-alias"})
871 rows = await mass.music.database.get_rows_from_query(
872 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
873 "WHERE media_id = :mid AND media_type = 'track'",
874 {"mid": int(track.item_id)},
875 limit=0,
876 )
877 mapped_genre_ids = {int(r["genre_id"]) for r in rows}
878 assert int(genre_a.item_id) in mapped_genre_ids
879 assert int(genre_b.item_id) in mapped_genre_ids
880
881 async def test_sync_picks_up_genre_created_between_syncs(
882 self, mass: MusicAssistant, genre_ctrl: GenreController
883 ) -> None:
884 """A genre created between syncs re-routes an already-stored alias mapping."""
885 electro = await genre_ctrl.add_item_to_library(_make_genre("SyncElectro"))
886 await genre_ctrl.add_alias(electro.item_id, "SyncWaveAlias")
887 track = await _add_test_track(mass, "Sync Track NewGenre")
888 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncWaveAlias"})
889 # user now creates a genre whose primary name matches the stored alias;
890 # primary-name resolution takes priority, so a re-sync must remap the item
891 new_genre = await genre_ctrl.add_item_to_library(_make_genre("SyncWaveAlias"))
892 genre_ctrl._sync_lookup_cache.clear() # simulate the cached lookup expiring
893 await genre_ctrl.sync_media_item_genres(MediaType.TRACK, track.item_id, {"SyncWaveAlias"})
894 rows = await mass.music.database.get_rows_from_query(
895 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
896 "WHERE media_id = :mid AND media_type = 'track'",
897 {"mid": int(track.item_id)},
898 limit=0,
899 )
900 assert {int(r["genre_id"]) for r in rows} == {int(new_genre.item_id)}
901
902
903# ===================================================================
904# Group F: promote_alias_to_genre (4 tests)
905# ===================================================================
906
907
908class TestPromoteAlias:
909 """Tests for promote_alias_to_genre."""
910
911 async def test_promote_alias(self, mass: MusicAssistant, genre_ctrl: GenreController) -> None:
912 """New genre created, media mappings moved to new genre."""
913 parent = await genre_ctrl.add_item_to_library(_make_genre("ParentGenre"))
914 await genre_ctrl.add_alias(parent.item_id, "SubGenre")
915 # Add a media mapping via the alias
916 track = await _add_test_track(mass, "Promote Track")
917 await genre_ctrl.add_media_mapping(
918 parent.item_id, MediaType.TRACK, track.item_id, "SubGenre"
919 )
920
921 new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "SubGenre")
922 assert new_genre.name == "SubGenre"
923 assert int(new_genre.item_id) != int(parent.item_id)
924
925 # Media mapping should have moved to new genre
926 rows = await mass.music.database.get_rows_from_query(
927 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
928 "WHERE media_id = :mid AND media_type = 'track' AND alias = 'SubGenre'",
929 {"mid": int(track.item_id)},
930 limit=1,
931 )
932 assert len(rows) == 1
933 assert int(rows[0]["genre_id"]) == int(new_genre.item_id)
934
935 async def test_promote_creates_self_alias(self, genre_ctrl: GenreController) -> None:
936 """New genre has its own name as alias."""
937 parent = await genre_ctrl.add_item_to_library(_make_genre("PromParent"))
938 await genre_ctrl.add_alias(parent.item_id, "PromChild")
939
940 new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromChild")
941 assert new_genre.genre_aliases is not None
942 assert "PromChild" in new_genre.genre_aliases
943
944 async def test_promote_self_alias_raises(self, genre_ctrl: GenreController) -> None:
945 """Raises ValueError for self-alias."""
946 genre = await genre_ctrl.add_item_to_library(_make_genre("PromSelf"))
947 with pytest.raises(ValueError, match="Cannot promote self-alias"):
948 await genre_ctrl.promote_alias_to_genre(genre.item_id, "PromSelf")
949
950 async def test_promote_removes_alias_from_source(self, genre_ctrl: GenreController) -> None:
951 """Alias is removed from source genre after promotion."""
952 parent = await genre_ctrl.add_item_to_library(_make_genre("PromComplete"))
953 await genre_ctrl.add_alias(parent.item_id, "PromAlias")
954
955 await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromAlias")
956 updated_parent = await genre_ctrl.get_library_item(int(parent.item_id))
957 assert updated_parent.genre_aliases is not None
958 assert "PromAlias" not in updated_parent.genre_aliases
959 assert "PromComplete" in updated_parent.genre_aliases
960
961 async def test_promote_alias_shared_across_genres(
962 self, mass: MusicAssistant, genre_ctrl: GenreController
963 ) -> None:
964 """
965 Promotion handles aliases shared across multiple genres.
966
967 Every genre that claimed the alias loses it and all mappings made via
968 the alias are moved to the new genre, regardless of which source the
969 caller invoked the promotion from.
970 """
971 folk = await genre_ctrl.add_item_to_library(_make_genre("PromFolk"))
972 pop = await genre_ctrl.add_item_to_library(_make_genre("PromPop"))
973 await genre_ctrl.add_alias(folk.item_id, "PromManele")
974 await genre_ctrl.add_alias(pop.item_id, "PromManele")
975 track = await _add_test_track(mass, "Manele Track")
976 # Track is mapped to both genres via the same alias (n:n).
977 await genre_ctrl.add_media_mapping(
978 folk.item_id, MediaType.TRACK, track.item_id, "PromManele"
979 )
980 await genre_ctrl.add_media_mapping(
981 pop.item_id, MediaType.TRACK, track.item_id, "PromManele"
982 )
983
984 # Invoke from one of the owning genres; both should be cleared.
985 new_genre = await genre_ctrl.promote_alias_to_genre(folk.item_id, "PromManele")
986
987 rows = await mass.music.database.get_rows_from_query(
988 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
989 "WHERE media_id = :mid AND media_type = 'track'",
990 {"mid": int(track.item_id)},
991 limit=0,
992 )
993 genre_ids = {int(r["genre_id"]) for r in rows}
994 assert genre_ids == {int(new_genre.item_id)}
995
996 updated_folk = await genre_ctrl.get_library_item(int(folk.item_id))
997 updated_pop = await genre_ctrl.get_library_item(int(pop.item_id))
998 assert "PromManele" not in (updated_folk.genre_aliases or [])
999 assert "PromManele" not in (updated_pop.genre_aliases or [])
1000
1001 async def test_promote_rebuilds_derived_album_mappings(
1002 self, mass: MusicAssistant, genre_ctrl: GenreController
1003 ) -> None:
1004 """
1005 Derived album rows are cleared from the source genre after promotion.
1006
1007 Without this, propagation-derived (alias=NULL, is_derived=1) rows would
1008 still link the album to the source genre even though the underlying
1009 tracks have moved to the new one.
1010 """
1011 parent = await genre_ctrl.add_item_to_library(_make_genre("PromHipHop"))
1012 await genre_ctrl.add_alias(parent.item_id, "PromRap")
1013 track = await _add_test_track(mass, "Rap Track")
1014 album = await _add_test_album(mass, "Rap Album")
1015 await genre_ctrl.add_media_mapping(
1016 parent.item_id, MediaType.TRACK, track.item_id, "PromRap"
1017 )
1018 # Seed a propagation-derived album row (alias=NULL, is_derived=1) as if
1019 # it had been written by _propagate_genre_mappings_to_parents.
1020 await mass.music.database.execute(
1021 f"INSERT INTO {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1022 "(genre_id, media_id, media_type, alias, is_derived) "
1023 "VALUES (:gid, :mid, 'album', NULL, 1)",
1024 {"gid": int(parent.item_id), "mid": int(album.item_id)},
1025 )
1026
1027 await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromRap")
1028
1029 rows = await mass.music.database.get_rows_from_query(
1030 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1031 "WHERE media_id = :mid AND media_type = 'album'",
1032 {"mid": int(album.item_id)},
1033 limit=0,
1034 )
1035 assert all(int(r["genre_id"]) != int(parent.item_id) for r in rows)
1036
1037 async def test_promote_alias_inherits_content_type(self, genre_ctrl: GenreController) -> None:
1038 """A genre promoted from an alias stays in its source's taxonomy."""
1039 parent = await genre_ctrl.add_item_to_library(
1040 Genre(
1041 item_id="0",
1042 provider="library",
1043 name="PromTrueCrime",
1044 provider_mappings=set(),
1045 content_type=MediaType.PODCAST,
1046 )
1047 )
1048 await genre_ctrl.add_alias(parent.item_id, "PromSerialKillers")
1049
1050 new_genre = await genre_ctrl.promote_alias_to_genre(parent.item_id, "PromSerialKillers")
1051 assert new_genre.content_type is MediaType.PODCAST
1052 fetched = await genre_ctrl.get_library_item(int(new_genre.item_id))
1053 assert fetched.content_type is MediaType.PODCAST
1054
1055
1056# ===================================================================
1057# Group F2: merge_genres (7 tests)
1058# ===================================================================
1059
1060
1061class TestMergeGenres:
1062 """Tests for merge_genres."""
1063
1064 async def test_merge_transfers_aliases(
1065 self, mass: MusicAssistant, genre_ctrl: GenreController
1066 ) -> None:
1067 """Aliases from source genres are added to the target."""
1068 target = await genre_ctrl.add_item_to_library(_make_genre("MergeTarget"))
1069 source = await genre_ctrl.add_item_to_library(_make_genre("MergeSource"))
1070 await genre_ctrl.add_alias(source.item_id, "SourceAlias")
1071
1072 result = await genre_ctrl.merge_genres([source.item_id], target.item_id)
1073 assert result.genre_aliases is not None
1074 assert "MergeTarget" in result.genre_aliases
1075 assert "MergeSource" in result.genre_aliases
1076 assert "SourceAlias" in result.genre_aliases
1077
1078 async def test_merge_transfers_media_mappings(
1079 self, mass: MusicAssistant, genre_ctrl: GenreController
1080 ) -> None:
1081 """Media mappings from source genres are moved to the target."""
1082 target = await genre_ctrl.add_item_to_library(_make_genre("MergeMapTarget"))
1083 source = await genre_ctrl.add_item_to_library(_make_genre("MergeMapSource"))
1084 track = await _add_test_track(mass, "Merge Track")
1085 await genre_ctrl.add_media_mapping(
1086 source.item_id, MediaType.TRACK, track.item_id, "MergeMapSource"
1087 )
1088
1089 await genre_ctrl.merge_genres([source.item_id], target.item_id)
1090 rows = await mass.music.database.get_rows_from_query(
1091 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1092 "WHERE genre_id = :gid AND media_type = 'track' AND media_id = :mid",
1093 {"gid": int(target.item_id), "mid": int(track.item_id)},
1094 limit=1,
1095 )
1096 assert len(rows) == 1
1097
1098 async def test_merge_deletes_source_genres(
1099 self, mass: MusicAssistant, genre_ctrl: GenreController
1100 ) -> None:
1101 """Source genres are deleted after merge."""
1102 target = await genre_ctrl.add_item_to_library(_make_genre("MergeDelTarget"))
1103 source = await genre_ctrl.add_item_to_library(_make_genre("MergeDelSource"))
1104
1105 await genre_ctrl.merge_genres([source.item_id], target.item_id)
1106 with pytest.raises(MediaNotFoundError):
1107 await genre_ctrl.get_library_item(int(source.item_id))
1108
1109 async def test_merge_deduplicates_aliases(
1110 self, mass: MusicAssistant, genre_ctrl: GenreController
1111 ) -> None:
1112 """Overlapping aliases are not duplicated on the target."""
1113 target = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupTarget"))
1114 await genre_ctrl.add_alias(target.item_id, "SharedAlias")
1115 source = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupSource"))
1116 await genre_ctrl.add_alias(source.item_id, "SharedAlias")
1117
1118 result = await genre_ctrl.merge_genres([source.item_id], target.item_id)
1119 assert result.genre_aliases is not None
1120 alias_list = list(result.genre_aliases)
1121 norm_aliases = [a for a in alias_list if a.lower().replace(" ", "") == "sharedalias"]
1122 assert len(norm_aliases) == 1
1123
1124 async def test_merge_deduplicates_media_mappings(
1125 self, mass: MusicAssistant, genre_ctrl: GenreController
1126 ) -> None:
1127 """Overlapping media mappings do not create duplicates."""
1128 target = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupMapTarget"))
1129 source = await genre_ctrl.add_item_to_library(_make_genre("MergeDedupMapSource"))
1130 track = await _add_test_track(mass, "Merge Dedup Track")
1131 # Both genres map the same track
1132 await genre_ctrl.add_media_mapping(
1133 target.item_id, MediaType.TRACK, track.item_id, "MergeDedupMapTarget"
1134 )
1135 await genre_ctrl.add_media_mapping(
1136 source.item_id, MediaType.TRACK, track.item_id, "MergeDedupMapSource"
1137 )
1138
1139 await genre_ctrl.merge_genres([source.item_id], target.item_id)
1140 rows = await mass.music.database.get_rows_from_query(
1141 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1142 "WHERE genre_id = :gid AND media_type = 'track' AND media_id = :mid",
1143 {"gid": int(target.item_id), "mid": int(track.item_id)},
1144 limit=0,
1145 )
1146 assert len(rows) == 1
1147
1148 async def test_merge_multiple_sources(
1149 self, mass: MusicAssistant, genre_ctrl: GenreController
1150 ) -> None:
1151 """Multiple source genres can be merged at once."""
1152 target = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiTarget"))
1153 source1 = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiSrc1"))
1154 source2 = await genre_ctrl.add_item_to_library(_make_genre("MergeMultiSrc2"))
1155 track1 = await _add_test_track(mass, "Multi Merge Track 1")
1156 track2 = await _add_test_track(mass, "Multi Merge Track 2")
1157 await genre_ctrl.add_media_mapping(
1158 source1.item_id, MediaType.TRACK, track1.item_id, "MergeMultiSrc1"
1159 )
1160 await genre_ctrl.add_media_mapping(
1161 source2.item_id, MediaType.TRACK, track2.item_id, "MergeMultiSrc2"
1162 )
1163
1164 result = await genre_ctrl.merge_genres([source1.item_id, source2.item_id], target.item_id)
1165 assert result.genre_aliases is not None
1166 assert "MergeMultiSrc1" in result.genre_aliases
1167 assert "MergeMultiSrc2" in result.genre_aliases
1168
1169 # Both tracks mapped to target
1170 rows = await mass.music.database.get_rows_from_query(
1171 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1172 "WHERE genre_id = :gid AND media_type = 'track'",
1173 {"gid": int(target.item_id)},
1174 limit=0,
1175 )
1176 assert len(rows) == 2
1177
1178 # Both sources deleted
1179 for src in (source1, source2):
1180 with pytest.raises(MediaNotFoundError):
1181 await genre_ctrl.get_library_item(int(src.item_id))
1182
1183 async def test_merge_target_in_source_raises(self, genre_ctrl: GenreController) -> None:
1184 """Raises ValueError when target is in the source list."""
1185 genre = await genre_ctrl.add_item_to_library(_make_genre("MergeSelfTarget"))
1186 with pytest.raises(ValueError, match="Target genre cannot be in the list"):
1187 await genre_ctrl.merge_genres([genre.item_id], genre.item_id)
1188
1189 async def test_merge_empty_source_raises(self, genre_ctrl: GenreController) -> None:
1190 """Raises ValueError when source list is empty."""
1191 target = await genre_ctrl.add_item_to_library(_make_genre("MergeEmptyTarget"))
1192 with pytest.raises(ValueError, match="No genre IDs provided"):
1193 await genre_ctrl.merge_genres([], target.item_id)
1194
1195 async def test_merge_cross_taxonomy_raises(self, genre_ctrl: GenreController) -> None:
1196 """Raises ValueError when source and target belong to different taxonomies."""
1197 target = await genre_ctrl.add_item_to_library(_make_genre("MergeMusicTarget"))
1198 source = await genre_ctrl.add_item_to_library(
1199 Genre(
1200 item_id="0",
1201 provider="library",
1202 name="MergePodcastSource",
1203 provider_mappings=set(),
1204 content_type=MediaType.PODCAST,
1205 )
1206 )
1207 with pytest.raises(ValueError, match="same taxonomy"):
1208 await genre_ctrl.merge_genres([source.item_id], target.item_id)
1209 # the source genre must survive a rejected merge
1210 assert await genre_ctrl.get_library_item(int(source.item_id)) is not None
1211
1212
1213# ===================================================================
1214# Group G: restore_default_genres (5 tests)
1215# ===================================================================
1216
1217
1218class TestRestoreDefaultGenres:
1219 """Tests for restore_default_genres."""
1220
1221 async def test_restore_partial_on_empty(self, genre_ctrl: GenreController) -> None:
1222 """Partial restore on pre-seeded DB returns empty (nothing to add)."""
1223 # Genres are already seeded during startup (_setup_database), so a partial
1224 # restore is idempotent and returns no new genres.
1225 created = await genre_ctrl.restore_default_genres(full_restore=False)
1226 assert len(created) == 0
1227 # Verify the default genres are actually present
1228 count = await genre_ctrl.library_count()
1229 assert count >= len(DEFAULT_GENRE_MAPPING)
1230
1231 async def test_restore_partial_idempotent(self, genre_ctrl: GenreController) -> None:
1232 """Second call returns empty list (no duplicates)."""
1233 await genre_ctrl.restore_default_genres(full_restore=False)
1234 second = await genre_ctrl.restore_default_genres(full_restore=False)
1235 assert len(second) == 0
1236
1237 async def test_restore_partial_adds_missing(
1238 self, mass: MusicAssistant, genre_ctrl: GenreController
1239 ) -> None:
1240 """Pre-existing genres not duplicated, missing ones added."""
1241 first_default = DEFAULT_GENRE_MAPPING[0]["genre"]
1242 await genre_ctrl.add_item_to_library(_make_genre(first_default))
1243 before = await genre_ctrl.library_count()
1244 created = await genre_ctrl.restore_default_genres(full_restore=False)
1245 after = await genre_ctrl.library_count()
1246 assert len(created) == after - before
1247
1248 async def test_restore_full_clears_all(self, genre_ctrl: GenreController) -> None:
1249 """Full restore: custom genres gone, only defaults remain."""
1250 await genre_ctrl.add_item_to_library(_make_genre("MyCustomGenre"))
1251 await genre_ctrl.restore_default_genres(full_restore=True)
1252 items = await genre_ctrl.library_items(limit=0, hide_empty=False)
1253 names = {g.name for g in items}
1254 assert "MyCustomGenre" not in names
1255 # full restore seeds every taxonomy (music + podcast + audiobook)
1256 assert len(items) == (
1257 len(DEFAULT_GENRE_MAPPING)
1258 + len(DEFAULT_PODCAST_GENRE_MAPPING)
1259 + len(DEFAULT_AUDIOBOOK_GENRE_MAPPING)
1260 )
1261
1262 async def test_restore_creates_configured_aliases(self, genre_ctrl: GenreController) -> None:
1263 """Genres have aliases from genre_mapping.json."""
1264 await genre_ctrl.restore_default_genres(full_restore=True)
1265 entries_with_aliases = [e for e in DEFAULT_GENRE_MAPPING if e.get("aliases")]
1266 if not entries_with_aliases:
1267 pytest.skip("No default genres with aliases configured")
1268 entry = entries_with_aliases[0]
1269 items = await genre_ctrl.library_items(
1270 search=entry["genre"], hide_empty=False, summary=False
1271 )
1272 assert len(items) > 0
1273 genre = items[0]
1274 assert genre.genre_aliases is not None
1275 # Self-alias should be present
1276 assert entry["genre"] in genre.genre_aliases
1277 # Configured aliases should be present
1278 for alias in entry["aliases"]:
1279 assert alias in genre.genre_aliases
1280
1281
1282# ===================================================================
1283# Group H: Query Methods (7 tests)
1284# ===================================================================
1285
1286
1287class TestQueryMethods:
1288 """Tests for the tracks, albums, mapped_media, and overview endpoints."""
1289
1290 async def test_genre_tracks_empty(self, genre_ctrl: GenreController) -> None:
1291 """A genre with no mapped tracks returns an empty list."""
1292 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyGenre"))
1293 assert await genre_ctrl.tracks(genre.item_id) == []
1294
1295 async def test_genre_tracks_returns_mapped(
1296 self, mass: MusicAssistant, genre_ctrl: GenreController
1297 ) -> None:
1298 """Tracks mapped to a genre are returned."""
1299 genre = await genre_ctrl.add_item_to_library(_make_genre("TracksGenre"))
1300 track = await _add_test_track(mass, "Genre Track")
1301 await genre_ctrl.add_media_mapping(
1302 genre.item_id, MediaType.TRACK, track.item_id, "TracksGenre"
1303 )
1304 tracks = await genre_ctrl.tracks(genre.item_id)
1305 assert any(t.name == "Genre Track" for t in tracks)
1306
1307 async def test_genre_tracks_respects_limit(
1308 self, mass: MusicAssistant, genre_ctrl: GenreController
1309 ) -> None:
1310 """The limit parameter caps the number of tracks returned."""
1311 genre = await genre_ctrl.add_item_to_library(_make_genre("LimitGenre"))
1312 for i in range(3):
1313 track = await _add_test_track(mass, f"Limit Track {i}")
1314 await genre_ctrl.add_media_mapping(
1315 genre.item_id, MediaType.TRACK, track.item_id, "LimitGenre"
1316 )
1317 assert len(await genre_ctrl.tracks(genre.item_id, limit=2)) == 2
1318
1319 async def test_genre_albums_returns_mapped(
1320 self, mass: MusicAssistant, genre_ctrl: GenreController
1321 ) -> None:
1322 """Albums mapped to a genre are returned."""
1323 genre = await genre_ctrl.add_item_to_library(_make_genre("AlbumsGenre"))
1324 album = await _add_test_album(mass, "Genre Album")
1325 await genre_ctrl.add_media_mapping(
1326 genre.item_id, MediaType.ALBUM, album.item_id, "AlbumsGenre"
1327 )
1328 albums = await genre_ctrl.albums(genre.item_id)
1329 assert any(a.name == "Genre Album" for a in albums)
1330
1331 async def test_mapped_media_returns_all_types(
1332 self, mass: MusicAssistant, genre_ctrl: GenreController
1333 ) -> None:
1334 """Returns (tracks, albums, artists) tuple."""
1335 genre = await genre_ctrl.add_item_to_library(_make_genre("MappedMedia"))
1336 result = await genre_ctrl.mapped_media(genre)
1337 assert isinstance(result, tuple)
1338 assert len(result) == 3
1339 tracks, albums, artists = result
1340 assert isinstance(tracks, list)
1341 assert isinstance(albums, list)
1342 assert isinstance(artists, list)
1343
1344 async def test_mapped_media_empty(self, genre_ctrl: GenreController) -> None:
1345 """No mappings returns ([], [], [])."""
1346 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyMapped"))
1347 tracks, albums, artists = await genre_ctrl.mapped_media(genre)
1348 assert tracks == []
1349 assert albums == []
1350 assert artists == []
1351
1352 async def test_overview_returns_folders(
1353 self, mass: MusicAssistant, genre_ctrl: GenreController
1354 ) -> None:
1355 """Returns RecommendationFolder items when mappings exist."""
1356 genre = await genre_ctrl.add_item_to_library(_make_genre("OverviewGenre"))
1357 track = await _add_test_track(mass, "Overview Track")
1358 await genre_ctrl.add_media_mapping(
1359 genre.item_id, MediaType.TRACK, track.item_id, "OverviewGenre"
1360 )
1361 folders = await genre_ctrl.get_overview(genre.item_id)
1362 assert len(folders) >= 1
1363 assert folders[0].name == "Tracks"
1364
1365 async def test_overview_empty(self, genre_ctrl: GenreController) -> None:
1366 """No mappings returns empty list."""
1367 genre = await genre_ctrl.add_item_to_library(_make_genre("EmptyOverview"))
1368 folders = await genre_ctrl.get_overview(genre.item_id)
1369 assert folders == []
1370
1371 async def test_get_genres_for_media_item(
1372 self, mass: MusicAssistant, genre_ctrl: GenreController
1373 ) -> None:
1374 """Returns genres mapped to a specific media item."""
1375 genre1 = await genre_ctrl.add_item_to_library(_make_genre("GenreForItem1"))
1376 genre2 = await genre_ctrl.add_item_to_library(_make_genre("GenreForItem2"))
1377 track = await _add_test_track(mass, "Track With Genres")
1378 await genre_ctrl.add_media_mapping(
1379 genre1.item_id, MediaType.TRACK, track.item_id, "GenreForItem1"
1380 )
1381 await genre_ctrl.add_media_mapping(
1382 genre2.item_id, MediaType.TRACK, track.item_id, "GenreForItem2"
1383 )
1384 genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id)
1385 genre_names = {g.name for g in genres}
1386 assert "GenreForItem1" in genre_names
1387 assert "GenreForItem2" in genre_names
1388
1389 async def test_get_genres_for_media_item_empty(
1390 self, mass: MusicAssistant, genre_ctrl: GenreController
1391 ) -> None:
1392 """Returns empty list for unmapped media item."""
1393 track = await _add_test_track(mass, "Track Without Genres")
1394 genres = await genre_ctrl.get_genres_for_media_item(MediaType.TRACK, track.item_id)
1395 assert genres == []
1396
1397 async def test_get_genres_for_media_item_non_integer_id(
1398 self, mass: MusicAssistant, genre_ctrl: GenreController
1399 ) -> None:
1400 """Returns empty list for non-integer provider item IDs (e.g. Bandcamp compound IDs)."""
1401 genres = await genre_ctrl.get_genres_for_media_item(MediaType.ALBUM, "3957198221-190478553")
1402 assert genres == []
1403
1404 async def test_library_items_hide_empty_true(
1405 self, mass: MusicAssistant, genre_ctrl: GenreController
1406 ) -> None:
1407 """hide_empty=True returns only genres with mappings."""
1408 mapped = await genre_ctrl.add_item_to_library(_make_genre("HasMappingGenre"))
1409 unmapped = await genre_ctrl.add_item_to_library(_make_genre("NoMappingGenre"))
1410 track = await _add_test_track(mass, "HasMapping Track")
1411 await genre_ctrl.add_media_mapping(
1412 mapped.item_id, MediaType.TRACK, track.item_id, "HasMappingGenre"
1413 )
1414 results = await genre_ctrl.library_items(hide_empty=True)
1415 result_ids = {int(g.item_id) for g in results}
1416 assert int(mapped.item_id) in result_ids
1417 assert int(unmapped.item_id) not in result_ids
1418
1419 async def test_library_items_hide_empty_default(self, genre_ctrl: GenreController) -> None:
1420 """Default (hide_empty=None) returns only default genres (translation_key IS NOT NULL)."""
1421 await genre_ctrl.restore_default_genres()
1422 scanned = await genre_ctrl._find_genres_for_alias("DefaultFilterScannedXyz", None)
1423 assert scanned
1424
1425 default_genre_name = DEFAULT_GENRE_MAPPING[0]["genre"]
1426 results = await genre_ctrl.library_items()
1427 names = {g.name for g in results}
1428 assert default_genre_name in names
1429 assert "DefaultFilterScannedXyz" not in names
1430
1431 async def test_library_items_show_all(
1432 self, mass: MusicAssistant, genre_ctrl: GenreController
1433 ) -> None:
1434 """hide_empty=False returns all genres including unmapped."""
1435 mapped = await genre_ctrl.add_item_to_library(_make_genre("ShowAllMapped"))
1436 unmapped = await genre_ctrl.add_item_to_library(_make_genre("ShowAllUnmapped"))
1437 track = await _add_test_track(mass, "ShowAll Track")
1438 await genre_ctrl.add_media_mapping(
1439 mapped.item_id, MediaType.TRACK, track.item_id, "ShowAllMapped"
1440 )
1441 results = await genre_ctrl.library_items(hide_empty=False)
1442 result_ids = {int(g.item_id) for g in results}
1443 assert int(mapped.item_id) in result_ids
1444 assert int(unmapped.item_id) in result_ids
1445
1446
1447# ===================================================================
1448# Group I: Genre Lookup & Scanner (5 tests)
1449# ===================================================================
1450
1451
1452class TestGenreLookupAndScanner:
1453 """Tests for genre/alias lookup and scanner status."""
1454
1455 async def test_find_genres_for_alias_existing(self, genre_ctrl: GenreController) -> None:
1456 """Finds existing genre by name."""
1457 genre = await genre_ctrl.add_item_to_library(_make_genre("Garage"))
1458 found = await genre_ctrl._find_genres_for_alias("Garage", None)
1459 assert isinstance(found, list)
1460 assert int(genre.item_id) in found
1461
1462 async def test_find_genres_for_alias_by_alias(self, genre_ctrl: GenreController) -> None:
1463 """Finds existing genre by alias string in genre_aliases JSON."""
1464 genre = await genre_ctrl.add_item_to_library(_make_genre("Breakbeat"))
1465 await genre_ctrl.add_alias(genre.item_id, "Big Beat")
1466 found = await genre_ctrl._find_genres_for_alias("Big Beat", None)
1467 assert isinstance(found, list)
1468 assert int(genre.item_id) in found
1469
1470 async def test_find_genres_for_alias_primary_name_takes_priority(
1471 self, genre_ctrl: GenreController
1472 ) -> None:
1473 """
1474 Primary name match returns only that genre, ignoring secondary alias matches.
1475
1476 Regression test: a bare "pop" tag must not fan out to Rock/Punk/etc. that
1477 accumulated "pop" as a side-effect alias, when a dedicated Pop genre exists.
1478 """
1479 # Use the pre-seeded Pop and Rock genres (seeded during startup).
1480 pop_items = await genre_ctrl.library_items(search="Pop", hide_empty=False)
1481 rock_items = await genre_ctrl.library_items(search="Rock", hide_empty=False)
1482 pop_genre = next(g for g in pop_items if g.name == "pop")
1483 rock_genre = next(g for g in rock_items if g.name == "rock")
1484 # Simulate "pop" being written as a secondary alias on Rock (the bug scenario)
1485 await genre_ctrl.add_alias(rock_genre.item_id, "pop")
1486
1487 found = await genre_ctrl._find_genres_for_alias("Pop", None)
1488 assert found == [int(pop_genre.item_id)]
1489
1490 async def test_find_genres_for_alias_creates_new(self, genre_ctrl: GenreController) -> None:
1491 """Creates new genre when no match found."""
1492 found = await genre_ctrl._find_genres_for_alias("BrandNewGenre12345", None)
1493 assert isinstance(found, list)
1494 assert len(found) == 1
1495 genre = await genre_ctrl.get_library_item(found[0])
1496 assert genre.name == "BrandNewGenre12345"
1497
1498 async def test_scanner_status(self, genre_ctrl: GenreController) -> None:
1499 """Returns dict with expected keys."""
1500 status = await genre_ctrl.get_scanner_status()
1501 assert "running" in status
1502 assert "last_scan_time" in status
1503
1504 async def test_scan_mappings_trigger(self, genre_ctrl: GenreController) -> None:
1505 """Returns 'triggered' status."""
1506 result = await genre_ctrl.scan_mappings()
1507 assert result["status"] == "triggered"
1508
1509
1510# ===================================================================
1511# Group J: Base Class Integration (3 tests)
1512# ===================================================================
1513
1514
1515class TestBaseClassIntegration:
1516 """Tests for base class query patterns (genre_aliases column, pagination, favorites)."""
1517
1518 async def test_genre_aliases_inline(self, genre_ctrl: GenreController) -> None:
1519 """genre_aliases column populates genre_aliases on fetched Genre."""
1520 genre = await genre_ctrl.add_item_to_library(_make_genre("InlineTest"))
1521 await genre_ctrl.add_alias(genre.item_id, "Inline Alias")
1522 # Fetch via library_items (uses base_query); request full items so genre_aliases hydrates
1523 items = await genre_ctrl.library_items(search="InlineTest", hide_empty=False, summary=False)
1524 assert len(items) >= 1
1525 fetched = items[0]
1526 assert fetched.genre_aliases is not None
1527 assert "InlineTest" in fetched.genre_aliases
1528 assert "Inline Alias" in fetched.genre_aliases
1529
1530 async def test_pagination(self, genre_ctrl: GenreController) -> None:
1531 """limit/offset work correctly."""
1532 for i in range(5):
1533 await genre_ctrl.add_item_to_library(_make_genre(f"Page{i}"))
1534 page1 = await genre_ctrl.library_items(limit=2, offset=0, order_by="name", hide_empty=False)
1535 page2 = await genre_ctrl.library_items(limit=2, offset=2, order_by="name", hide_empty=False)
1536 assert len(page1) == 2
1537 assert len(page2) == 2
1538 ids1 = {g.item_id for g in page1}
1539 ids2 = {g.item_id for g in page2}
1540 assert ids1.isdisjoint(ids2)
1541
1542 async def test_favorite_filter(self, genre_ctrl: GenreController) -> None:
1543 """favorite=True filters correctly."""
1544 await genre_ctrl.add_item_to_library(_make_genre("FavYes", favorite=True))
1545 await genre_ctrl.add_item_to_library(_make_genre("FavNo", favorite=False))
1546 favs = await genre_ctrl.library_items(favorite=True, hide_empty=False)
1547 assert all(g.favorite for g in favs)
1548 assert any(g.name == "FavYes" for g in favs)
1549
1550
1551# ===================================================================
1552# Group K: _cleanup_stale_genre_mappings (7 tests)
1553# ===================================================================
1554
1555
1556async def _set_track_genres(mass: MusicAssistant, track_id: int, genres: list[str]) -> None:
1557 """Set metadata.genres on a track row directly in the DB."""
1558 await mass.music.database.execute(
1559 f"UPDATE {DB_TABLE_TRACKS} "
1560 "SET metadata = json_set(metadata, '$.genres', json(:genres)) "
1561 "WHERE item_id = :id",
1562 {"genres": json.dumps(genres), "id": track_id},
1563 )
1564 await mass.music.database.commit()
1565
1566
1567async def _set_album_genres(mass: MusicAssistant, album_id: int, genres: list[str]) -> None:
1568 """Set metadata.genres on an album row directly in the DB."""
1569 await mass.music.database.execute(
1570 f"UPDATE {DB_TABLE_ALBUMS} "
1571 "SET metadata = json_set(metadata, '$.genres', json(:genres)) "
1572 "WHERE item_id = :id",
1573 {"genres": json.dumps(genres), "id": album_id},
1574 )
1575 await mass.music.database.commit()
1576
1577
1578class TestCleanupStaleMappings:
1579 """Tests for _cleanup_stale_genre_mappings."""
1580
1581 async def test_stale_mapping_removed_on_call(
1582 self, mass: MusicAssistant, genre_ctrl: GenreController
1583 ) -> None:
1584 """A stale scanner mapping is removed when cleanup is called."""
1585 genre = await genre_ctrl.add_item_to_library(_make_genre("CsCountGenre"))
1586 track = await _add_test_track(mass, "CsCount Track")
1587 genre_id = int(genre.item_id)
1588 track_id = int(track.item_id)
1589 # insert a non-manual (scanner-style) row; track has no metadata.genres
1590 await mass.music.database.insert(
1591 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
1592 {
1593 "genre_id": genre_id,
1594 "media_id": track_id,
1595 "media_type": MediaType.TRACK.value,
1596 "alias": "CsCountGenre",
1597 },
1598 )
1599 await genre_ctrl._cleanup_stale_genre_mappings()
1600 rows = await mass.music.database.get_rows_from_query(
1601 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1602 "WHERE genre_id = :gid AND media_id = :mid",
1603 {"gid": genre_id, "mid": track_id},
1604 limit=0,
1605 )
1606 assert len(rows) == 0
1607
1608 async def test_empty_metadata_genres_removes_mapping(
1609 self, mass: MusicAssistant, genre_ctrl: GenreController
1610 ) -> None:
1611 """Scanner mapping is removed when the track has no genres in metadata."""
1612 genre = await genre_ctrl.add_item_to_library(_make_genre("CsStale1Genre"))
1613 track = await _add_test_track(mass, "CsStale1 Track")
1614 genre_id = int(genre.item_id)
1615 track_id = int(track.item_id)
1616 # insert a non-manual (scanner-style) row
1617 await mass.music.database.insert(
1618 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
1619 {
1620 "genre_id": genre_id,
1621 "media_id": track_id,
1622 "media_type": MediaType.TRACK.value,
1623 "alias": "CsStale1Genre",
1624 },
1625 )
1626
1627 await genre_ctrl._cleanup_stale_genre_mappings()
1628
1629 rows = await mass.music.database.get_rows_from_query(
1630 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1631 "WHERE genre_id = :gid AND media_id = :mid",
1632 {"gid": genre_id, "mid": track_id},
1633 limit=0,
1634 )
1635 assert len(rows) == 0
1636
1637 async def test_live_alias_mapping_preserved(
1638 self, mass: MusicAssistant, genre_ctrl: GenreController
1639 ) -> None:
1640 """Mapping is kept when the alias is still present in track metadata.genres."""
1641 genre = await genre_ctrl.add_item_to_library(_make_genre("CsLive1Genre"))
1642 track = await _add_test_track(mass, "CsLive1 Track")
1643 genre_id = int(genre.item_id)
1644 track_id = int(track.item_id)
1645 await _set_track_genres(mass, track_id, ["CsLive1Genre"])
1646 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track.item_id, "CsLive1Genre")
1647
1648 await genre_ctrl._cleanup_stale_genre_mappings()
1649
1650 rows = await mass.music.database.get_rows_from_query(
1651 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1652 "WHERE genre_id = :gid AND media_id = :mid",
1653 {"gid": genre_id, "mid": track_id},
1654 limit=0,
1655 )
1656 assert len(rows) == 1
1657
1658 async def test_orphaned_mapping_removed(
1659 self, mass: MusicAssistant, genre_ctrl: GenreController
1660 ) -> None:
1661 """Mapping is removed when the media item no longer exists in the DB."""
1662 genre = await genre_ctrl.add_item_to_library(_make_genre("CsOrphan1Genre"))
1663 track = await _add_test_track(mass, "CsOrphan1 Track")
1664 genre_id = int(genre.item_id)
1665 track_id = int(track.item_id)
1666 await _set_track_genres(mass, track_id, ["CsOrphan1Genre"])
1667 await genre_ctrl.add_media_mapping(
1668 genre_id, MediaType.TRACK, track.item_id, "CsOrphan1Genre"
1669 )
1670
1671 # Delete the track directly, leaving behind an orphaned mapping row
1672 await mass.music.database.execute(
1673 f"DELETE FROM {DB_TABLE_TRACKS} WHERE item_id = :id", {"id": track_id}
1674 )
1675 await mass.music.database.commit()
1676
1677 await genre_ctrl._cleanup_stale_genre_mappings()
1678
1679 rows = await mass.music.database.get_rows_from_query(
1680 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1681 "WHERE genre_id = :gid AND media_id = :mid",
1682 {"gid": genre_id, "mid": track_id},
1683 limit=0,
1684 )
1685 assert len(rows) == 0
1686
1687 async def test_empty_nondefault_genre_deleted(
1688 self, mass: MusicAssistant, genre_ctrl: GenreController
1689 ) -> None:
1690 """Non-default genre (is_default = 0) with no mappings is deleted."""
1691 # _find_genres_for_alias creates genres with is_default = 0
1692 found = await genre_ctrl._find_genres_for_alias("CsNonDefault1XYZ99", None)
1693 assert len(found) == 1
1694 genre_id = found[0]
1695 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1696 assert row is not None
1697 assert row["translation_key"] is None
1698
1699 await genre_ctrl._cleanup_stale_genre_mappings()
1700
1701 row_after = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1702 assert row_after is None
1703
1704 async def test_default_genre_without_mappings_preserved(
1705 self, genre_ctrl: GenreController, mass: MusicAssistant
1706 ) -> None:
1707 """Default genre (translation_key IS NOT NULL) is never deleted by cleanup."""
1708 await genre_ctrl.restore_default_genres(full_restore=False)
1709 default_entry = next(e for e in DEFAULT_GENRE_MAPPING if e.get("translation_key"))
1710 rows = await mass.music.database.get_rows_from_query(
1711 f"SELECT item_id FROM {DB_TABLE_GENRES} WHERE translation_key = :tk",
1712 {"tk": default_entry["translation_key"]},
1713 limit=1,
1714 )
1715 assert len(rows) == 1
1716 genre_id = int(rows[0]["item_id"])
1717 # Confirm no active mappings for this genre
1718 mapping_rows = await mass.music.database.get_rows_from_query(
1719 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} WHERE genre_id = :gid",
1720 {"gid": genre_id},
1721 limit=0,
1722 )
1723 if mapping_rows:
1724 pytest.skip("Default genre already has mappings")
1725
1726 await genre_ctrl._cleanup_stale_genre_mappings()
1727
1728 row_after = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1729 assert row_after is not None
1730
1731 async def test_nondefault_genre_with_active_mappings_preserved(
1732 self, mass: MusicAssistant, genre_ctrl: GenreController
1733 ) -> None:
1734 """Non-default genre with at least one active mapping is NOT deleted."""
1735 track = await _add_test_track(mass, "CsKeep Track")
1736 track_id = int(track.item_id)
1737 await _set_track_genres(mass, track_id, ["CsKeepGenreXYZ"])
1738 found = await genre_ctrl._find_genres_for_alias("CsKeepGenreXYZ", None)
1739 genre_id = found[0]
1740 await genre_ctrl.add_media_mapping(
1741 genre_id, MediaType.TRACK, track.item_id, "CsKeepGenreXYZ"
1742 )
1743
1744 await genre_ctrl._cleanup_stale_genre_mappings()
1745
1746 row_after = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1747 assert row_after is not None
1748
1749 async def test_manual_mapping_preserved_when_alias_not_in_metadata(
1750 self, mass: MusicAssistant, genre_ctrl: GenreController
1751 ) -> None:
1752 """
1753 Manually-added mappings survive cleanup when alias is not in metadata.
1754
1755 Regression for music-assistant/support#5310: a user creates a custom
1756 ("music type") genre via the UI and links an album to it. The album's
1757 metadata.genres reflects only the source-file tags and never contains
1758 the custom genre name, so the previous cleanup query wiped the mapping
1759 — and, transitively, the empty-non-default genre row.
1760 """
1761 genre = await genre_ctrl.add_item_to_library(_make_genre("MyCustomTypeXYZ"))
1762 genre_id = int(genre.item_id)
1763 album = await _add_test_album(mass, "Custom Type Album XYZ")
1764 album_id = int(album.item_id)
1765 # album source tags are unrelated to the custom genre
1766 await _set_album_genres(mass, album_id, ["Rock"])
1767 # user links album -> custom genre (no explicit alias, matches UI flow)
1768 await genre_ctrl.add_media_mapping(genre_id, MediaType.ALBUM, album_id)
1769
1770 await genre_ctrl._cleanup_stale_genre_mappings()
1771
1772 mapping_rows = await mass.music.database.get_rows_from_query(
1773 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1774 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album'",
1775 {"gid": genre_id, "mid": album_id},
1776 limit=0,
1777 )
1778 assert len(mapping_rows) == 1, "manual mapping was deleted by cleanup"
1779 genre_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1780 assert genre_row is not None, "custom genre was deleted after its mapping disappeared"
1781
1782 async def test_playlog_entries_cleaned_for_deleted_genre(
1783 self, mass: MusicAssistant, genre_ctrl: GenreController
1784 ) -> None:
1785 """Playlog entries for a deleted empty non-default genre are removed."""
1786 found = await genre_ctrl._find_genres_for_alias("CsPlaylog1XYZ99", None)
1787 genre_id = found[0]
1788 # Insert a fake playlog entry for this genre
1789 cols = (
1790 "(item_id, provider, media_type, name, fully_played, seconds_played, timestamp, userid)"
1791 )
1792 await mass.music.database.execute(
1793 f"INSERT OR IGNORE INTO {DB_TABLE_PLAYLOG} {cols} "
1794 "VALUES (:item_id, 'library', :media_type, 'CsPlaylog1XYZ99', 0, 0, 0, 'testuser')",
1795 {"item_id": str(genre_id), "media_type": "genre"},
1796 )
1797 await mass.music.database.commit()
1798
1799 await genre_ctrl._cleanup_stale_genre_mappings()
1800
1801 # Both the genre and its playlog entry should be gone
1802 genre_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1803 assert genre_row is None
1804 playlog_rows = await mass.music.database.get_rows_from_query(
1805 f"SELECT * FROM {DB_TABLE_PLAYLOG} WHERE media_type = 'genre' AND item_id = :id",
1806 {"id": str(genre_id)},
1807 limit=0,
1808 )
1809 assert len(playlog_rows) == 0
1810
1811
1812# ===================================================================
1813# Group L: Genre Exclusion (5 tests)
1814# ===================================================================
1815
1816
1817class TestGenreExclusion:
1818 """Tests for exclude_genre_from_media_item and remove_genre_exclusion."""
1819
1820 async def test_exclude_inserts_row(
1821 self, mass: MusicAssistant, genre_ctrl: GenreController
1822 ) -> None:
1823 """exclude_genre_from_media_item inserts a row into the exclusion table."""
1824 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclGenre1"))
1825 track = await _add_test_track(mass, "ExclTrack1")
1826 genre_id = int(genre.item_id)
1827 track_id = int(track.item_id)
1828
1829 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1830
1831 rows = await mass.music.database.get_rows_from_query(
1832 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION} "
1833 "WHERE genre_id = :gid AND media_id = :mid AND media_type = :mt",
1834 {"gid": genre_id, "mid": track_id, "mt": "track"},
1835 limit=0,
1836 )
1837 assert len(rows) == 1
1838
1839 async def test_exclude_removes_existing_mapping(
1840 self, mass: MusicAssistant, genre_ctrl: GenreController
1841 ) -> None:
1842 """exclude_genre_from_media_item immediately deletes any existing mapping."""
1843 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclGenre2"))
1844 track = await _add_test_track(mass, "ExclTrack2")
1845 genre_id = int(genre.item_id)
1846 track_id = int(track.item_id)
1847
1848 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "ExclGenre2")
1849 pre_rows = await mass.music.database.get_rows_from_query(
1850 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1851 "WHERE genre_id = :gid AND media_id = :mid",
1852 {"gid": genre_id, "mid": track_id},
1853 limit=0,
1854 )
1855 assert len(pre_rows) == 1
1856
1857 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1858
1859 post_rows = await mass.music.database.get_rows_from_query(
1860 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1861 "WHERE genre_id = :gid AND media_id = :mid",
1862 {"gid": genre_id, "mid": track_id},
1863 limit=0,
1864 )
1865 assert len(post_rows) == 0
1866
1867 async def test_exclude_idempotent(
1868 self, mass: MusicAssistant, genre_ctrl: GenreController
1869 ) -> None:
1870 """Calling exclude_genre_from_media_item twice is idempotent."""
1871 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclGenre3"))
1872 track = await _add_test_track(mass, "ExclTrack3")
1873 genre_id = int(genre.item_id)
1874 track_id = int(track.item_id)
1875
1876 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1877 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1878
1879 rows = await mass.music.database.get_rows_from_query(
1880 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION} "
1881 "WHERE genre_id = :gid AND media_id = :mid AND media_type = :mt",
1882 {"gid": genre_id, "mid": track_id, "mt": "track"},
1883 limit=0,
1884 )
1885 assert len(rows) == 1
1886
1887 async def test_remove_exclusion_deletes_row(
1888 self, mass: MusicAssistant, genre_ctrl: GenreController
1889 ) -> None:
1890 """remove_genre_exclusion deletes the exclusion row."""
1891 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclGenre4"))
1892 track = await _add_test_track(mass, "ExclTrack4")
1893 genre_id = int(genre.item_id)
1894 track_id = int(track.item_id)
1895
1896 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1897 await genre_ctrl.remove_genre_exclusion(genre_id, MediaType.TRACK, track_id)
1898
1899 rows = await mass.music.database.get_rows_from_query(
1900 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION} "
1901 "WHERE genre_id = :gid AND media_id = :mid",
1902 {"gid": genre_id, "mid": track_id},
1903 limit=0,
1904 )
1905 assert len(rows) == 0
1906
1907 async def test_scanner_respects_exclusion(
1908 self, mass: MusicAssistant, genre_ctrl: GenreController
1909 ) -> None:
1910 """_bulk_scan_unmapped_genres does not create a mapping for an excluded pair."""
1911 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclScanGenre"))
1912 track = await _add_test_track(mass, "ExclScan Track")
1913 genre_id = int(genre.item_id)
1914 track_id = int(track.item_id)
1915
1916 await _set_track_genres(mass, track_id, ["ExclScanGenre"])
1917 await genre_ctrl.add_alias(genre_id, "ExclScanGenre")
1918 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1919 await genre_ctrl._bulk_scan_unmapped_genres()
1920
1921 mapping_rows = await mass.music.database.get_rows_from_query(
1922 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1923 "WHERE genre_id = :gid AND media_id = :mid",
1924 {"gid": genre_id, "mid": track_id},
1925 limit=0,
1926 )
1927 assert len(mapping_rows) == 0
1928
1929 async def test_full_scanner_respects_exclusion(
1930 self, mass: MusicAssistant, genre_ctrl: GenreController
1931 ) -> None:
1932 """_bulk_scan_media_genres does not create a mapping for an excluded pair."""
1933 genre = await genre_ctrl.add_item_to_library(_make_genre("ExclFullScanGenre"))
1934 track = await _add_test_track(mass, "ExclFullScan Track")
1935 genre_id = int(genre.item_id)
1936 track_id = int(track.item_id)
1937
1938 await _set_track_genres(mass, track_id, ["ExclFullScanGenre"])
1939 await genre_ctrl.add_alias(genre_id, "ExclFullScanGenre")
1940 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1941 await genre_ctrl._bulk_scan_media_genres()
1942
1943 mapping_rows = await mass.music.database.get_rows_from_query(
1944 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
1945 "WHERE genre_id = :gid AND media_id = :mid",
1946 {"gid": genre_id, "mid": track_id},
1947 limit=0,
1948 )
1949 assert len(mapping_rows) == 0
1950
1951 async def test_cleanup_preserves_genre_with_exclusion(
1952 self, mass: MusicAssistant, genre_ctrl: GenreController
1953 ) -> None:
1954 """
1955 _cleanup_stale_genre_mappings keeps a genre that has an exclusion but no mappings.
1956
1957 Verifies both that the genre row survives and that a playlog entry for it is
1958 also preserved (the playlog DELETE uses the same exclusion guard).
1959 """
1960 genre = await genre_ctrl.add_item_to_library(_make_genre("CleanupPreservedGenre"))
1961 track = await _add_test_track(mass, "CleanupPreserved Track")
1962 genre_id = int(genre.item_id)
1963 track_id = int(track.item_id)
1964
1965 # Exclude the genre from the track (also removes any mapping that might exist)
1966 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.TRACK, track_id)
1967
1968 # Insert a playlog entry for the genre so we can confirm it is also kept
1969 cols = (
1970 "(item_id, provider, media_type, name, fully_played, seconds_played, timestamp, userid)"
1971 )
1972 await mass.music.database.execute(
1973 f"INSERT OR IGNORE INTO {DB_TABLE_PLAYLOG} {cols} "
1974 "VALUES (:item_id, 'library', 'genre', :name, 0, 0, 0, 'testuser')",
1975 {"item_id": str(genre_id), "name": "CleanupPreservedGenre"},
1976 )
1977 await mass.music.database.commit()
1978
1979 await genre_ctrl._cleanup_stale_genre_mappings()
1980
1981 genre_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
1982 assert genre_row is not None, "genre with an exclusion must not be deleted by cleanup"
1983
1984 playlog_rows = await mass.music.database.get_rows_from_query(
1985 f"SELECT * FROM {DB_TABLE_PLAYLOG} WHERE media_type = 'genre' AND item_id = :id",
1986 {"id": str(genre_id)},
1987 limit=0,
1988 )
1989 assert len(playlog_rows) == 1, "playlog entry for an excluded genre must not be deleted"
1990
1991 async def test_get_genre_exclusions_for_media_item(
1992 self, mass: MusicAssistant, genre_ctrl: GenreController
1993 ) -> None:
1994 """Returns genres excluded from a specific media item."""
1995 genre1 = await genre_ctrl.add_item_to_library(_make_genre("ExclQueryGenre1"))
1996 genre2 = await genre_ctrl.add_item_to_library(_make_genre("ExclQueryGenre2"))
1997 track = await _add_test_track(mass, "ExclQuery Track")
1998 await genre_ctrl.exclude_genre_from_media_item(
1999 genre1.item_id, MediaType.TRACK, track.item_id
2000 )
2001 await genre_ctrl.exclude_genre_from_media_item(
2002 genre2.item_id, MediaType.TRACK, track.item_id
2003 )
2004 result = await genre_ctrl.get_genre_exclusions_for_media_item(
2005 MediaType.TRACK, track.item_id
2006 )
2007 names = {g.name for g in result}
2008 assert "ExclQueryGenre1" in names
2009 assert "ExclQueryGenre2" in names
2010
2011 async def test_get_genre_exclusions_for_media_item_empty(
2012 self, mass: MusicAssistant, genre_ctrl: GenreController
2013 ) -> None:
2014 """Returns empty list when no genres are excluded from the media item."""
2015 track = await _add_test_track(mass, "ExclQuery No Exclusions Track")
2016 result = await genre_ctrl.get_genre_exclusions_for_media_item(
2017 MediaType.TRACK, track.item_id
2018 )
2019 assert result == []
2020
2021 async def test_get_genre_exclusions_for_media_item_non_integer_id(
2022 self, mass: MusicAssistant, genre_ctrl: GenreController
2023 ) -> None:
2024 """Returns empty list for non-integer media IDs."""
2025 result = await genre_ctrl.get_genre_exclusions_for_media_item(
2026 MediaType.ALBUM, "3957198221-190478553"
2027 )
2028 assert result == []
2029
2030
2031class TestPropagateGenreMappings:
2032 """Tests for _propagate_genre_mappings_to_parents."""
2033
2034 async def test_propagation_derives_album_genre(
2035 self, mass: MusicAssistant, genre_ctrl: GenreController
2036 ) -> None:
2037 """Track genre mappings are propagated to the track's album."""
2038 instance_id = "fs_local_instance_album"
2039 genre = await genre_ctrl.add_item_to_library(_make_genre("PropAlbumGenre"))
2040 artist = await _add_test_artist(mass, "PropAlbum Artist")
2041 album = await _add_test_album(mass, "PropAlbum Album")
2042 track = await mass.music.tracks.add_item_to_library(
2043 Track(
2044 item_id="0",
2045 provider="library",
2046 name="PropAlbum Track",
2047 provider_mappings=set(),
2048 artists=UniqueList([artist]),
2049 )
2050 )
2051 track_id = int(track.item_id)
2052 album_id = int(album.item_id)
2053 genre_id = int(genre.item_id)
2054
2055 await mass.music.database.insert(
2056 DB_TABLE_ALBUM_TRACKS,
2057 {"track_id": track_id, "album_id": album_id, "disc_number": 1, "track_number": 1},
2058 )
2059 await mass.music.database.insert(
2060 DB_TABLE_PROVIDER_MAPPINGS,
2061 {
2062 "media_type": "track",
2063 "item_id": track_id,
2064 "provider_domain": "filesystem_local",
2065 "provider_instance": instance_id,
2066 "provider_item_id": f"track_{track_id}",
2067 },
2068 )
2069 await mass.music.database.commit()
2070 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "PropAlbumGenre")
2071
2072 mock_provider = MagicMock()
2073 mock_provider.domain = "filesystem_local"
2074 mock_provider.instance_id = instance_id
2075 with (
2076 patch.object(
2077 type(mass.music),
2078 "providers",
2079 new_callable=PropertyMock,
2080 return_value=[mock_provider],
2081 ),
2082 patch.object(
2083 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2084 ),
2085 ):
2086 await genre_ctrl._propagate_genre_mappings_to_parents()
2087
2088 rows = await mass.music.database.get_rows_from_query(
2089 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2090 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album' AND is_derived = 1",
2091 {"gid": genre_id, "mid": album_id},
2092 limit=0,
2093 )
2094 assert len(rows) == 1
2095
2096 async def test_propagation_derives_artist_genre(
2097 self, mass: MusicAssistant, genre_ctrl: GenreController
2098 ) -> None:
2099 """Track genre mappings are propagated to the track's artist."""
2100 instance_id = "fs_local_instance_artist"
2101 genre = await genre_ctrl.add_item_to_library(_make_genre("PropArtistGenre"))
2102 track = await _add_test_track(mass, "PropArtist Track")
2103 track_id = int(track.item_id)
2104 artist_id = int(track.artists[0].item_id)
2105 genre_id = int(genre.item_id)
2106
2107 await mass.music.database.insert(
2108 DB_TABLE_PROVIDER_MAPPINGS,
2109 {
2110 "media_type": "track",
2111 "item_id": track_id,
2112 "provider_domain": "filesystem_local",
2113 "provider_instance": instance_id,
2114 "provider_item_id": f"track_{track_id}",
2115 },
2116 )
2117 await mass.music.database.commit()
2118 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "PropArtistGenre")
2119
2120 mock_provider = MagicMock()
2121 mock_provider.domain = "filesystem_local"
2122 mock_provider.instance_id = instance_id
2123 with (
2124 patch.object(
2125 type(mass.music),
2126 "providers",
2127 new_callable=PropertyMock,
2128 return_value=[mock_provider],
2129 ),
2130 patch.object(
2131 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2132 ),
2133 ):
2134 await genre_ctrl._propagate_genre_mappings_to_parents()
2135
2136 rows = await mass.music.database.get_rows_from_query(
2137 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2138 "WHERE genre_id = :gid AND media_id = :mid "
2139 "AND media_type = 'artist' AND is_derived = 1",
2140 {"gid": genre_id, "mid": artist_id},
2141 limit=0,
2142 )
2143 assert len(rows) == 1
2144
2145 async def test_propagation_respects_exclusion(
2146 self, mass: MusicAssistant, genre_ctrl: GenreController
2147 ) -> None:
2148 """Excluded genre-album pairs are not derived even when propagation is enabled."""
2149 instance_id = "fs_local_instance_excl"
2150 genre = await genre_ctrl.add_item_to_library(_make_genre("PropExclGenre"))
2151 artist = await _add_test_artist(mass, "PropExcl Artist")
2152 album = await _add_test_album(mass, "PropExcl Album")
2153 track = await mass.music.tracks.add_item_to_library(
2154 Track(
2155 item_id="0",
2156 provider="library",
2157 name="PropExcl Track",
2158 provider_mappings=set(),
2159 artists=UniqueList([artist]),
2160 )
2161 )
2162 track_id = int(track.item_id)
2163 album_id = int(album.item_id)
2164 genre_id = int(genre.item_id)
2165
2166 await mass.music.database.insert(
2167 DB_TABLE_ALBUM_TRACKS,
2168 {"track_id": track_id, "album_id": album_id, "disc_number": 1, "track_number": 1},
2169 )
2170 await mass.music.database.insert(
2171 DB_TABLE_PROVIDER_MAPPINGS,
2172 {
2173 "media_type": "track",
2174 "item_id": track_id,
2175 "provider_domain": "filesystem_local",
2176 "provider_instance": instance_id,
2177 "provider_item_id": f"track_{track_id}",
2178 },
2179 )
2180 await mass.music.database.commit()
2181 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "PropExclGenre")
2182 await genre_ctrl.exclude_genre_from_media_item(genre_id, MediaType.ALBUM, album_id)
2183
2184 mock_provider = MagicMock()
2185 mock_provider.domain = "filesystem_local"
2186 mock_provider.instance_id = instance_id
2187 with (
2188 patch.object(
2189 type(mass.music),
2190 "providers",
2191 new_callable=PropertyMock,
2192 return_value=[mock_provider],
2193 ),
2194 patch.object(
2195 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2196 ),
2197 ):
2198 await genre_ctrl._propagate_genre_mappings_to_parents()
2199
2200 rows = await mass.music.database.get_rows_from_query(
2201 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2202 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album'",
2203 {"gid": genre_id, "mid": album_id},
2204 limit=0,
2205 )
2206 assert len(rows) == 0
2207
2208 async def test_propagation_disabled_removes_derived(
2209 self, mass: MusicAssistant, genre_ctrl: GenreController
2210 ) -> None:
2211 """Disabling propagation on all providers removes previously derived mappings."""
2212 instance_id = "fs_local_instance_disable"
2213 genre = await genre_ctrl.add_item_to_library(_make_genre("PropDisableGenre"))
2214 artist = await _add_test_artist(mass, "PropDisable Artist")
2215 album = await _add_test_album(mass, "PropDisable Album")
2216 track = await mass.music.tracks.add_item_to_library(
2217 Track(
2218 item_id="0",
2219 provider="library",
2220 name="PropDisable Track",
2221 provider_mappings=set(),
2222 artists=UniqueList([artist]),
2223 )
2224 )
2225 track_id = int(track.item_id)
2226 album_id = int(album.item_id)
2227 genre_id = int(genre.item_id)
2228
2229 await mass.music.database.insert(
2230 DB_TABLE_ALBUM_TRACKS,
2231 {"track_id": track_id, "album_id": album_id, "disc_number": 1, "track_number": 1},
2232 )
2233 await mass.music.database.insert(
2234 DB_TABLE_PROVIDER_MAPPINGS,
2235 {
2236 "media_type": "track",
2237 "item_id": track_id,
2238 "provider_domain": "filesystem_local",
2239 "provider_instance": instance_id,
2240 "provider_item_id": f"track_{track_id}",
2241 },
2242 )
2243 await mass.music.database.commit()
2244 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "PropDisableGenre")
2245
2246 mock_provider = MagicMock()
2247 mock_provider.domain = "filesystem_local"
2248 mock_provider.instance_id = instance_id
2249
2250 with (
2251 patch.object(
2252 type(mass.music),
2253 "providers",
2254 new_callable=PropertyMock,
2255 return_value=[mock_provider],
2256 ),
2257 patch.object(
2258 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2259 ),
2260 ):
2261 await genre_ctrl._propagate_genre_mappings_to_parents()
2262
2263 rows_after_enable = await mass.music.database.get_rows_from_query(
2264 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2265 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album' AND is_derived = 1",
2266 {"gid": genre_id, "mid": album_id},
2267 limit=0,
2268 )
2269 assert len(rows_after_enable) == 1
2270
2271 with (
2272 patch.object(
2273 type(mass.music),
2274 "providers",
2275 new_callable=PropertyMock,
2276 return_value=[mock_provider],
2277 ),
2278 patch.object(
2279 mass.config, "get_provider_config_value", new=AsyncMock(return_value=False)
2280 ),
2281 ):
2282 await genre_ctrl._propagate_genre_mappings_to_parents()
2283
2284 rows_after_disable = await mass.music.database.get_rows_from_query(
2285 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2286 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album' AND is_derived = 1",
2287 {"gid": genre_id, "mid": album_id},
2288 limit=0,
2289 )
2290 assert len(rows_after_disable) == 0
2291
2292 async def test_derived_mapping_replaced_by_direct_when_album_gains_genres(
2293 self, mass: MusicAssistant, genre_ctrl: GenreController
2294 ) -> None:
2295 """
2296 Derived mapping is replaced by direct when album gains own genre metadata.
2297
2298 Verifies the mapping becomes a direct one instead of leaving the album with no mapping.
2299 """
2300 instance_id = "fs_local_instance_transition"
2301 genre = await genre_ctrl.add_item_to_library(_make_genre("TransitionGenre"))
2302 artist = await _add_test_artist(mass, "Transition Artist")
2303 album = await _add_test_album(mass, "Transition Album")
2304 track = await mass.music.tracks.add_item_to_library(
2305 Track(
2306 item_id="0",
2307 provider="library",
2308 name="Transition Track",
2309 provider_mappings=set(),
2310 artists=UniqueList([artist]),
2311 )
2312 )
2313 track_id = int(track.item_id)
2314 album_id = int(album.item_id)
2315 genre_id = int(genre.item_id)
2316
2317 await mass.music.database.insert(
2318 DB_TABLE_ALBUM_TRACKS,
2319 {"track_id": track_id, "album_id": album_id, "disc_number": 1, "track_number": 1},
2320 )
2321 await mass.music.database.insert(
2322 DB_TABLE_PROVIDER_MAPPINGS,
2323 {
2324 "media_type": "track",
2325 "item_id": track_id,
2326 "provider_domain": "filesystem_local",
2327 "provider_instance": instance_id,
2328 "provider_item_id": f"track_{track_id}",
2329 },
2330 )
2331 await mass.music.database.commit()
2332 await _set_track_genres(mass, track_id, ["TransitionGenre"])
2333 await genre_ctrl.add_media_mapping(genre_id, MediaType.TRACK, track_id, "TransitionGenre")
2334
2335 mock_provider = MagicMock()
2336 mock_provider.domain = "filesystem_local"
2337 mock_provider.instance_id = instance_id
2338
2339 # Step 1: album has no genres yet → propagation creates a derived mapping.
2340 with (
2341 patch.object(
2342 type(mass.music),
2343 "providers",
2344 new_callable=PropertyMock,
2345 return_value=[mock_provider],
2346 ),
2347 patch.object(
2348 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2349 ),
2350 ):
2351 await genre_ctrl._propagate_genre_mappings_to_parents()
2352
2353 derived_rows = await mass.music.database.get_rows_from_query(
2354 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2355 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album'",
2356 {"gid": genre_id, "mid": album_id},
2357 limit=0,
2358 )
2359 assert len(derived_rows) == 1
2360 assert derived_rows[0]["is_derived"] == 1
2361
2362 # Step 2: album gains its own genre metadata.
2363 await _set_album_genres(mass, album_id, ["TransitionGenre"])
2364
2365 # Step 3: incremental scan must replace the derived mapping with a direct one,
2366 # not leave the album with no mapping at all.
2367 with (
2368 patch.object(
2369 type(mass.music),
2370 "providers",
2371 new_callable=PropertyMock,
2372 return_value=[mock_provider],
2373 ),
2374 patch.object(
2375 mass.config, "get_provider_config_value", new=AsyncMock(return_value=True)
2376 ),
2377 ):
2378 await genre_ctrl._bulk_scan_unmapped_genres()
2379
2380 final_rows = await mass.music.database.get_rows_from_query(
2381 f"SELECT * FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2382 "WHERE genre_id = :gid AND media_id = :mid AND media_type = 'album'",
2383 {"gid": genre_id, "mid": album_id},
2384 limit=0,
2385 )
2386 assert len(final_rows) == 1
2387 assert final_rows[0]["is_derived"] == 0
2388 assert final_rows[0]["alias"] == "TransitionGenre"
2389
2390
2391# ===================================================================
2392# Group N: Genre Media Counts (4 tests)
2393# ===================================================================
2394
2395
2396class TestGetGenreMediaCounts:
2397 """Tests for get_genre_media_counts."""
2398
2399 async def test_empty_ids_returns_empty(self, genre_ctrl: GenreController) -> None:
2400 """Empty input returns empty dict without hitting the database."""
2401 result = await genre_ctrl.get_genre_media_counts([])
2402 assert result == {}
2403
2404 async def test_all_media_types_present_with_zero_default(
2405 self, genre_ctrl: GenreController
2406 ) -> None:
2407 """Result contains every MEDIA_TABLES media type, defaulting to 0."""
2408 genre = await genre_ctrl.add_item_to_library(_make_genre("CountDefaults"))
2409 gid = genre.item_id
2410 result = await genre_ctrl.get_genre_media_counts([gid])
2411 assert gid in result
2412 expected_keys = {"track", "album", "artist", "playlist", "radio", "audiobook", "podcast"}
2413 assert set(result[gid].keys()) == expected_keys
2414 assert all(v == 0 for v in result[gid].values())
2415
2416 async def test_counts_track_mappings(
2417 self, mass: MusicAssistant, genre_ctrl: GenreController
2418 ) -> None:
2419 """Track mappings are reflected in the track count; other types remain 0."""
2420 genre = await genre_ctrl.add_item_to_library(_make_genre("CountTracks"))
2421 track1 = await _add_test_track(mass, "CountTrack1")
2422 track2 = await _add_test_track(mass, "CountTrack2")
2423 gid = genre.item_id
2424 await genre_ctrl.add_media_mapping(gid, MediaType.TRACK, track1.item_id, "CountTracks")
2425 await genre_ctrl.add_media_mapping(gid, MediaType.TRACK, track2.item_id, "CountTracks")
2426 result = await genre_ctrl.get_genre_media_counts([gid])
2427 assert result[gid]["track"] == 2
2428 assert result[gid]["album"] == 0
2429
2430 async def test_counts_multiple_genres_independently(
2431 self, mass: MusicAssistant, genre_ctrl: GenreController
2432 ) -> None:
2433 """Counts for multiple genre IDs are computed independently."""
2434 g1 = await genre_ctrl.add_item_to_library(_make_genre("MultiCountA"))
2435 g2 = await genre_ctrl.add_item_to_library(_make_genre("MultiCountB"))
2436 track = await _add_test_track(mass, "MultiCount Track")
2437 album = await _add_test_album(mass, "MultiCount Album")
2438 await genre_ctrl.add_media_mapping(
2439 g1.item_id, MediaType.TRACK, track.item_id, "MultiCountA"
2440 )
2441 await genre_ctrl.add_media_mapping(
2442 g2.item_id, MediaType.ALBUM, album.item_id, "MultiCountB"
2443 )
2444 result = await genre_ctrl.get_genre_media_counts([g1.item_id, g2.item_id])
2445 assert result[g1.item_id]["track"] == 1
2446 assert result[g1.item_id]["album"] == 0
2447 assert result[g2.item_id]["album"] == 1
2448 assert result[g2.item_id]["track"] == 0
2449
2450
2451# ===================================================================
2452# Group O: Global Genre Exclusion (9 tests)
2453# ===================================================================
2454
2455# Two distinct default entries with a translation_key for the tests below.
2456_tk_entries = [e for e in DEFAULT_GENRE_MAPPING if e.get("translation_key")]
2457assert len(_tk_entries) >= 2, (
2458 "DEFAULT_GENRE_MAPPING must contain at least two entries with a translation_key "
2459 "for global genre exclusion tests"
2460)
2461_DEFAULT_ENTRY_A = _tk_entries[0] # used for deletion-only test
2462_DEFAULT_ENTRY_B = _tk_entries[1] # used for delete-then-restore test
2463
2464
2465class TestGlobalGenreExclusion:
2466 """Tests for the global genre exclusion API and scanner guard."""
2467
2468 async def test_delete_sets_is_excluded_flag(
2469 self, mass: MusicAssistant, genre_ctrl: GenreController
2470 ) -> None:
2471 """remove_item_from_library sets is_excluded = 1 on the genre row."""
2472 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExcl1"))
2473 genre_id = int(genre.item_id)
2474 await genre_ctrl.remove_item_from_library(genre_id)
2475 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
2476 assert row is not None
2477 assert row["is_excluded"] == 1
2478
2479 async def test_get_exclusions_lists_deleted_genre(
2480 self, mass: MusicAssistant, genre_ctrl: GenreController
2481 ) -> None:
2482 """get_global_genre_exclusions includes a genre after it is deleted."""
2483 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExcl2"))
2484 await genre_ctrl.remove_item_from_library(int(genre.item_id))
2485 exclusions = await genre_ctrl.get_global_genre_exclusions()
2486 names = {e["name"] for e in exclusions}
2487 assert "GblExcl2" in names
2488
2489 async def test_default_genre_exclusion_preserves_translation_key(
2490 self, mass: MusicAssistant, genre_ctrl: GenreController
2491 ) -> None:
2492 """Excluding a default genre preserves its translation_key on the row."""
2493 tk = _DEFAULT_ENTRY_A["translation_key"]
2494 search_name = create_safe_string(_DEFAULT_ENTRY_A["genre"], True, True)
2495 db_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"search_name": search_name})
2496 assert db_row is not None, "default genre must be seeded at startup"
2497 await genre_ctrl.remove_item_from_library(int(db_row["item_id"]))
2498 excl_row = await mass.music.database.get_row(
2499 DB_TABLE_GENRES, {"item_id": int(db_row["item_id"]), "is_excluded": 1}
2500 )
2501 assert excl_row is not None
2502 assert excl_row["translation_key"] == tk
2503
2504 async def test_scanner_guard_blocks_excluded_name(self, genre_ctrl: GenreController) -> None:
2505 """_find_genres_for_alias returns [] for a globally excluded genre name."""
2506 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExclScan"))
2507 await genre_ctrl.remove_item_from_library(int(genre.item_id))
2508 result = await genre_ctrl._find_genres_for_alias("GblExclScan", None)
2509 assert result == []
2510
2511 async def test_restore_custom_genre_is_not_default(
2512 self, mass: MusicAssistant, genre_ctrl: GenreController
2513 ) -> None:
2514 """Restoring a custom (non-default) genre leaves is_default as 0."""
2515 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExclCustom"))
2516 genre_id = int(genre.item_id)
2517 await genre_ctrl.remove_item_from_library(genre_id)
2518 restored = await genre_ctrl.remove_global_genre_exclusion(genre_id)
2519 db_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
2520 assert db_row is not None
2521 assert db_row["is_excluded"] == 0
2522 assert db_row["is_default"] == 0
2523 assert int(restored.item_id) == genre_id
2524
2525 async def test_restore_default_genre_translation_key_preserved(
2526 self, mass: MusicAssistant, genre_ctrl: GenreController
2527 ) -> None:
2528 """Restoring a default genre retains the correct translation_key (row was never deleted)."""
2529 tk = _DEFAULT_ENTRY_B["translation_key"]
2530 search_name = create_safe_string(_DEFAULT_ENTRY_B["genre"], True, True)
2531 db_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"search_name": search_name})
2532 assert db_row is not None, "default genre must be seeded at startup"
2533 genre_id = int(db_row["item_id"])
2534 await genre_ctrl.remove_item_from_library(genre_id)
2535 await genre_ctrl.remove_global_genre_exclusion(genre_id)
2536 restored_row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
2537 assert restored_row is not None
2538 assert restored_row["is_excluded"] == 0
2539 assert restored_row["translation_key"] == tk
2540
2541 async def test_restore_clears_exclusion_flag(
2542 self, mass: MusicAssistant, genre_ctrl: GenreController
2543 ) -> None:
2544 """is_excluded is set back to 0 after a successful restore."""
2545 genre = await genre_ctrl.add_item_to_library(_make_genre("GblExclClean"))
2546 genre_id = int(genre.item_id)
2547 await genre_ctrl.remove_item_from_library(genre_id)
2548 await genre_ctrl.remove_global_genre_exclusion(genre_id)
2549 row = await mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": genre_id})
2550 assert row is not None
2551 assert row["is_excluded"] == 0
2552
2553 async def test_restore_nonexistent_raises_key_error(self, genre_ctrl: GenreController) -> None:
2554 """remove_global_genre_exclusion raises KeyError for an unknown genre id."""
2555 with pytest.raises(KeyError):
2556 await genre_ctrl.remove_global_genre_exclusion(999_999_999)
2557
2558 async def test_merge_does_not_exclude_source_genre(
2559 self, mass: MusicAssistant, genre_ctrl: GenreController
2560 ) -> None:
2561 """merge_genres hard-deletes the source: it must not appear in the exclusion list."""
2562 source = await genre_ctrl.add_item_to_library(_make_genre("GblMergeSource"))
2563 target = await genre_ctrl.add_item_to_library(_make_genre("GblMergeTarget"))
2564 await genre_ctrl.merge_genres([source.item_id], target.item_id)
2565 exclusions = await genre_ctrl.get_global_genre_exclusions()
2566 names = {e["name"] for e in exclusions}
2567 assert "GblMergeSource" not in names
2568
2569
2570class TestGenreContentTypeNamespacing:
2571 """Genre resolution and mappings are scoped per content_type taxonomy (2b)."""
2572
2573 async def test_find_genres_for_alias_namespaces_by_content_type(
2574 self, genre_ctrl: GenreController
2575 ) -> None:
2576 """The same name resolves to a distinct genre per taxonomy, each tagged correctly."""
2577 music = await genre_ctrl._find_genres_for_alias("Comedy", None)
2578 podcast = await genre_ctrl._find_genres_for_alias("Comedy", MediaType.PODCAST)
2579 audiobook = await genre_ctrl._find_genres_for_alias("Comedy", MediaType.AUDIOBOOK)
2580
2581 assert len({music[0], podcast[0], audiobook[0]}) == 3
2582 assert (await genre_ctrl.get_library_item(music[0])).content_type is None
2583 assert (await genre_ctrl.get_library_item(podcast[0])).content_type is MediaType.PODCAST
2584 assert (await genre_ctrl.get_library_item(audiobook[0])).content_type is MediaType.AUDIOBOOK
2585
2586 async def test_find_genres_for_alias_does_not_cross_namespaces(
2587 self, genre_ctrl: GenreController
2588 ) -> None:
2589 """A lookup in one taxonomy never returns a genre that lives in another."""
2590 music = await genre_ctrl._find_genres_for_alias("Zzklezmertest", None)
2591 podcast = await genre_ctrl._find_genres_for_alias("Zzklezmertest", MediaType.PODCAST)
2592 assert music[0] not in podcast
2593 assert (await genre_ctrl.get_library_item(music[0])).content_type is None
2594
2595 async def test_scanner_buckets_genres_by_content_type(
2596 self, mass: MusicAssistant, genre_ctrl: GenreController
2597 ) -> None:
2598 """The scanner files a track's and a podcast's same-named genre as distinct entities."""
2599 track = await _add_test_track(mass, "NsTrack")
2600 await _set_track_genres(mass, int(track.item_id), ["Zmystery9"])
2601 podcast = await _add_test_podcast(mass, "NsPodcast")
2602 await _set_podcast_genres(mass, int(podcast.item_id), ["Zmystery9"])
2603
2604 await genre_ctrl._bulk_scan_unmapped_genres()
2605
2606 rows = await mass.music.database.get_rows_from_query(
2607 f"SELECT item_id, content_type FROM {DB_TABLE_GENRES} WHERE search_name = :sn",
2608 {"sn": create_safe_string("Zmystery9", True, True)},
2609 limit=0,
2610 )
2611 by_content_type = {row["content_type"]: int(row["item_id"]) for row in rows}
2612 # one music genre (NULL) and one podcast genre exist for the same name
2613 assert None in by_content_type
2614 assert MediaType.PODCAST.value in by_content_type
2615 # the podcast maps to the podcast-namespace genre, never the music one
2616 pod_maps = await mass.music.database.get_rows_from_query(
2617 f"SELECT genre_id FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2618 "WHERE media_type = :mt AND media_id = :mid",
2619 {"mt": MediaType.PODCAST.value, "mid": int(podcast.item_id)},
2620 limit=0,
2621 )
2622 assert {int(r["genre_id"]) for r in pod_maps} == {by_content_type[MediaType.PODCAST.value]}
2623
2624 async def test_cleanup_rehomes_legacy_cross_namespace_mapping(
2625 self, mass: MusicAssistant, genre_ctrl: GenreController
2626 ) -> None:
2627 """Cleanup removes a legacy podcast→music-genre mapping so the item can re-home."""
2628 podcast = await _add_test_podcast(mass, "RehomePod")
2629 await _set_podcast_genres(mass, int(podcast.item_id), ["Spoken Word"])
2630 # simulate the pre-namespacing state: the podcast mapped to a music (NULL) genre
2631 music_genre = await genre_ctrl._find_genres_for_alias("Spoken Word", None)
2632 await mass.music.database.insert(
2633 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
2634 {
2635 "genre_id": music_genre[0],
2636 "media_id": int(podcast.item_id),
2637 "media_type": MediaType.PODCAST.value,
2638 "alias": "Spoken Word",
2639 "is_derived": 0,
2640 "is_manual": 0,
2641 },
2642 )
2643
2644 await genre_ctrl._cleanup_stale_genre_mappings()
2645
2646 remaining = await mass.music.database.get_rows_from_query(
2647 f"SELECT 1 FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
2648 "WHERE media_type = :mt AND media_id = :mid AND genre_id = :gid",
2649 {
2650 "mt": MediaType.PODCAST.value,
2651 "mid": int(podcast.item_id),
2652 "gid": music_genre[0],
2653 },
2654 limit=0,
2655 )
2656 assert remaining == []
2657
2658 async def test_soft_delete_restore_scoped_by_content_type(
2659 self, genre_ctrl: GenreController
2660 ) -> None:
2661 """
2662 A re-add restores only a soft-deleted genre of the same taxonomy.
2663
2664 A soft-deleted podcast "Comedy" must not be revived (and re-tagged) when a music
2665 "Comedy" is added; the music genre is inserted as a distinct, new row.
2666 """
2667 podcast = await genre_ctrl.add_item_to_library(
2668 Genre(
2669 item_id="0",
2670 provider="library",
2671 name="ScopedComedy",
2672 provider_mappings=set(),
2673 content_type=MediaType.PODCAST,
2674 )
2675 )
2676 await genre_ctrl.mass.music.database.update(
2677 DB_TABLE_GENRES, {"item_id": int(podcast.item_id)}, {"is_excluded": 1}
2678 )
2679
2680 music = await genre_ctrl.add_item_to_library(_make_genre("ScopedComedy"))
2681
2682 assert int(music.item_id) != int(podcast.item_id)
2683 assert music.content_type is None
2684 # the podcast row is left untouched (still soft-deleted)
2685 pod_rows = await genre_ctrl.mass.music.database.get_rows_from_query(
2686 f"SELECT is_excluded FROM {DB_TABLE_GENRES} WHERE item_id = :id",
2687 {"id": int(podcast.item_id)},
2688 limit=1,
2689 )
2690 assert pod_rows[0]["is_excluded"] == 1
2691
2692 # re-adding within the same taxonomy DOES restore the soft-deleted row
2693 revived = await genre_ctrl.add_item_to_library(
2694 Genre(
2695 item_id="0",
2696 provider="library",
2697 name="ScopedComedy",
2698 provider_mappings=set(),
2699 content_type=MediaType.PODCAST,
2700 )
2701 )
2702 assert int(revived.item_id) == int(podcast.item_id)
2703
2704
2705class TestDefaultTaxonomySeeding:
2706 """restore_default_genres seeds curated music, podcast, and audiobook taxonomies (2c)."""
2707
2708 async def test_full_restore_seeds_every_taxonomy(
2709 self, mass: MusicAssistant, genre_ctrl: GenreController
2710 ) -> None:
2711 """Full restore seeds the curated default genres of all three taxonomies."""
2712 await genre_ctrl.restore_default_genres(full_restore=True)
2713 rows = await mass.music.database.get_rows_from_query(
2714 f"SELECT content_type, COUNT(*) AS cnt FROM {DB_TABLE_GENRES} "
2715 "WHERE is_default = 1 GROUP BY content_type",
2716 limit=0,
2717 )
2718 counts = {row["content_type"]: row["cnt"] for row in rows}
2719 assert counts.get(None) == len(DEFAULT_GENRE_MAPPING)
2720 assert counts.get(MediaType.PODCAST.value) == len(DEFAULT_PODCAST_GENRE_MAPPING)
2721 assert counts.get(MediaType.AUDIOBOOK.value) == len(DEFAULT_AUDIOBOOK_GENRE_MAPPING)
2722
2723 async def test_curated_genre_carries_translation_key_and_content_type(
2724 self, genre_ctrl: GenreController
2725 ) -> None:
2726 """A seeded podcast default (True Crime) lands in the podcast namespace with its key."""
2727 await genre_ctrl.restore_default_genres(full_restore=True)
2728 items = await genre_ctrl.library_items(search="True Crime", hide_empty=False)
2729 match = next((g for g in items if g.content_type is MediaType.PODCAST), None)
2730 assert match is not None
2731 assert match.translation_key == "true_crime"
2732
2733 async def test_same_name_distinct_per_taxonomy(
2734 self, mass: MusicAssistant, genre_ctrl: GenreController
2735 ) -> None:
2736 """A name in both spoken-word lists (History) yields one genre per taxonomy."""
2737 await genre_ctrl.restore_default_genres(full_restore=True)
2738 rows = await mass.music.database.get_rows_from_query(
2739 f"SELECT content_type FROM {DB_TABLE_GENRES} WHERE search_name = :sn",
2740 {"sn": create_safe_string("History", True, True)},
2741 limit=0,
2742 )
2743 content_types = {row["content_type"] for row in rows}
2744 assert MediaType.PODCAST.value in content_types
2745 assert MediaType.AUDIOBOOK.value in content_types
2746
2747 async def test_partial_restore_is_idempotent(
2748 self, mass: MusicAssistant, genre_ctrl: GenreController
2749 ) -> None:
2750 """Re-running a partial restore does not create duplicate genres."""
2751 await genre_ctrl.restore_default_genres(full_restore=False)
2752 before = await mass.music.database.get_count(DB_TABLE_GENRES)
2753 await genre_ctrl.restore_default_genres(full_restore=False)
2754 after = await mass.music.database.get_count(DB_TABLE_GENRES)
2755 assert before == after
2756
2757 async def test_content_type_filter_composes_with_hide_empty(
2758 self, genre_ctrl: GenreController
2759 ) -> None:
2760 """content_type narrows library_items to a taxonomy and composes with hide_empty."""
2761 await genre_ctrl.restore_default_genres(full_restore=True)
2762 # default-only (hide_empty=None) within the podcast taxonomy = the curated podcast defaults
2763 podcast_defaults = await genre_ctrl.library_items(
2764 content_type="podcast", hide_empty=None, limit=0
2765 )
2766 assert len(podcast_defaults) == len(DEFAULT_PODCAST_GENRE_MAPPING)
2767 assert all(g.content_type is MediaType.PODCAST for g in podcast_defaults)
2768
2769 async def test_targeted_restore_seeds_only_requested_taxonomy(
2770 self, mass: MusicAssistant, genre_ctrl: GenreController
2771 ) -> None:
2772 """A non-destructive restore scoped to one taxonomy touches only that taxonomy."""
2773
2774 async def default_counts() -> dict[str | None, int]:
2775 rows = await mass.music.database.get_rows_from_query(
2776 f"SELECT content_type, COUNT(*) AS cnt FROM {DB_TABLE_GENRES} "
2777 "WHERE is_default = 1 GROUP BY content_type",
2778 limit=0,
2779 )
2780 return {row["content_type"]: row["cnt"] for row in rows}
2781
2782 # establish a deterministic, fully-seeded baseline, then drop one podcast default
2783 await genre_ctrl.restore_default_genres(full_restore=True)
2784 before = await default_counts()
2785 victim = await mass.music.database.get_rows_from_query(
2786 f"SELECT item_id FROM {DB_TABLE_GENRES} WHERE content_type = :ct AND is_default = 1",
2787 {"ct": MediaType.PODCAST.value},
2788 limit=1,
2789 )
2790 await mass.music.database.delete(DB_TABLE_GENRES, {"item_id": victim[0]["item_id"]})
2791
2792 created = await genre_ctrl.restore_default_genres(
2793 full_restore=False, content_type="podcast"
2794 )
2795 assert created
2796 assert all(g.content_type is MediaType.PODCAST for g in created)
2797
2798 after = await default_counts()
2799 # the podcast default was restored, the other taxonomies were left untouched
2800 assert after[MediaType.PODCAST.value] == before[MediaType.PODCAST.value]
2801 assert after.get(None) == before.get(None)
2802 assert after.get(MediaType.AUDIOBOOK.value) == before.get(MediaType.AUDIOBOOK.value)
2803
2804 async def test_targeted_restore_unknown_taxonomy_raises(
2805 self, genre_ctrl: GenreController
2806 ) -> None:
2807 """An unrecognised taxonomy is rejected rather than silently restoring nothing."""
2808 with pytest.raises(ValueError, match="Unknown genre taxonomy"):
2809 await genre_ctrl.restore_default_genres(full_restore=False, content_type="bogus")
2810 # show-all within the taxonomy never leaks genres from another taxonomy
2811 podcast_all = await genre_ctrl.library_items(
2812 content_type="podcast", hide_empty=False, limit=0
2813 )
2814 assert podcast_all
2815 assert all(g.content_type is MediaType.PODCAST for g in podcast_all)
2816
2817 async def test_content_type_music_filter(self, genre_ctrl: GenreController) -> None:
2818 """content_type="music" returns only the music/general (NULL) taxonomy genres."""
2819 await genre_ctrl.restore_default_genres(full_restore=True)
2820 music = await genre_ctrl.library_items(content_type="music", hide_empty=False, limit=0)
2821 assert music
2822 assert all(g.content_type is None for g in music)
2823 # spoken-word genres are distinct entities and never appear in the music taxonomy
2824 podcast = await genre_ctrl.library_items(content_type="podcast", hide_empty=False, limit=0)
2825 music_ids = {g.item_id for g in music}
2826 assert not any(g.item_id in music_ids for g in podcast)
2827
2828
2829class TestGenreIconMetadata:
2830 """_get_genre_icon_metadata prefers a taxonomy subfolder icon, falling back to flat."""
2831
2832 @staticmethod
2833 def _make_icons(tmp_path: Path, *rel_paths: str) -> None:
2834 for rel in rel_paths:
2835 icon = tmp_path / "genres" / rel
2836 icon.parent.mkdir(parents=True, exist_ok=True)
2837 icon.write_text("<svg/>")
2838
2839 def test_subfolder_icon_preferred(self, tmp_path: Path, monkeypatch: Any) -> None:
2840 """A taxonomy-specific icon wins over the flat one."""
2841 self._make_icons(tmp_path, "history.svg", "podcast/history.svg")
2842 monkeypatch.setattr(
2843 "music_assistant.controllers.music.media.genres.RESOURCES_DIR", tmp_path
2844 )
2845 md = GenreController._get_genre_icon_metadata("history", MediaType.PODCAST)
2846 assert md is not None
2847 assert md.images is not None
2848 assert md.images[0].path == "genres/podcast/history.svg"
2849
2850 def test_falls_back_to_flat(self, tmp_path: Path, monkeypatch: Any) -> None:
2851 """Without a taxonomy override, the flat/shared icon is used."""
2852 self._make_icons(tmp_path, "history.svg")
2853 monkeypatch.setattr(
2854 "music_assistant.controllers.music.media.genres.RESOURCES_DIR", tmp_path
2855 )
2856 md = GenreController._get_genre_icon_metadata("history", MediaType.PODCAST)
2857 assert md is not None
2858 assert md.images is not None
2859 assert md.images[0].path == "genres/history.svg"
2860
2861 def test_music_uses_flat(self, tmp_path: Path, monkeypatch: Any) -> None:
2862 """Music genres (content_type None) resolve to the flat path."""
2863 self._make_icons(tmp_path, "blues.svg")
2864 monkeypatch.setattr(
2865 "music_assistant.controllers.music.media.genres.RESOURCES_DIR", tmp_path
2866 )
2867 md = GenreController._get_genre_icon_metadata("blues", None)
2868 assert md is not None
2869 assert md.images is not None
2870 assert md.images[0].path == "genres/blues.svg"
2871
2872 def test_missing_icon_returns_none(self, tmp_path: Path, monkeypatch: Any) -> None:
2873 """No matching SVG (subfolder or flat) yields no metadata."""
2874 self._make_icons(tmp_path)
2875 monkeypatch.setattr(
2876 "music_assistant.controllers.music.media.genres.RESOURCES_DIR", tmp_path
2877 )
2878 assert GenreController._get_genre_icon_metadata("nope", MediaType.PODCAST) is None
2879