/
/
/
1"""Manage MediaItems of type Genre."""
2
3from __future__ import annotations
4
5import asyncio
6import json
7import logging
8import time
9from typing import TYPE_CHECKING, Any
10
11from music_assistant_models.enums import EventType, ImageType, MediaType
12from music_assistant_models.media_items import (
13 Album,
14 Artist,
15 Genre,
16 MediaItemImage,
17 MediaItemMetadata,
18 RecommendationFolder,
19 Track,
20)
21from music_assistant_models.unique_list import UniqueList
22
23from music_assistant.constants import (
24 DB_TABLE_ALBUMS,
25 DB_TABLE_ARTISTS,
26 DB_TABLE_AUDIOBOOKS,
27 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
28 DB_TABLE_GENRES,
29 DB_TABLE_PLAYLISTS,
30 DB_TABLE_PODCASTS,
31 DB_TABLE_RADIOS,
32 DB_TABLE_TRACKS,
33 DEFAULT_GENRE_MAPPING,
34 GENRE_ICONS_DIR,
35)
36from music_assistant.helpers.compare import create_safe_string
37from music_assistant.helpers.database import UNSET
38from music_assistant.helpers.json import serialize_to_json
39
40from .base import MediaControllerBase
41
42if TYPE_CHECKING:
43 from music_assistant_models.event import MassEvent
44
45 from music_assistant import MusicAssistant
46
47
48class GenreController(MediaControllerBase[Genre]):
49 """Controller for Genre entities."""
50
51 db_table = DB_TABLE_GENRES
52 media_type = MediaType.GENRE
53 item_cls = Genre
54
55 def __init__(self, mass: MusicAssistant) -> None:
56 """Initialize class."""
57 super().__init__(mass)
58 # Background scanner state tracking
59 self._scanner_running: bool = False
60 self._last_scan_time: float = 0
61 self._last_scan_mapped: int = 0
62 self.base_query = f"""
63 SELECT
64 {DB_TABLE_GENRES}.*,
65 (SELECT JSON_GROUP_ARRAY(
66 json_object(
67 'item_id', provider_mappings.provider_item_id,
68 'provider_domain', provider_mappings.provider_domain,
69 'provider_instance', provider_mappings.provider_instance,
70 'available', provider_mappings.available,
71 'audio_format', json(provider_mappings.audio_format),
72 'url', provider_mappings.url,
73 'details', provider_mappings.details,
74 'in_library', provider_mappings.in_library,
75 'is_unique', provider_mappings.is_unique
76 )) FROM provider_mappings
77 WHERE provider_mappings.item_id = {DB_TABLE_GENRES}.item_id
78 AND provider_mappings.media_type = '{MediaType.GENRE.value}'
79 ) AS provider_mappings
80 FROM {DB_TABLE_GENRES}"""
81
82 # register extra api handlers
83 self.mass.register_api_command(
84 "music/genres/add_alias", self.add_alias, required_role="admin"
85 )
86 self.mass.register_api_command(
87 "music/genres/remove_alias", self.remove_alias, required_role="admin"
88 )
89 self.mass.register_api_command(
90 "music/genres/add_media_mapping", self.add_media_mapping, required_role="admin"
91 )
92 self.mass.register_api_command(
93 "music/genres/remove_media_mapping",
94 self.remove_media_mapping,
95 required_role="admin",
96 )
97 self.mass.register_api_command(
98 "music/genres/promote_alias",
99 self.promote_alias_to_genre,
100 required_role="admin",
101 )
102 self.mass.register_api_command(
103 "music/genres/restore_defaults",
104 self.restore_default_genres,
105 required_role="admin",
106 )
107 self.mass.register_api_command(
108 "music/genres/add",
109 self.add_item_to_library,
110 required_role="admin",
111 )
112 self.mass.register_api_command(
113 "music/genres/overview",
114 self.get_overview,
115 )
116 self.mass.register_api_command(
117 "music/genres/radio_mode_base_tracks",
118 self.get_radio_mode_base_tracks,
119 )
120 self.mass.register_api_command(
121 "music/genres/scan_mappings",
122 self.scan_mappings,
123 required_role="admin",
124 )
125 self.mass.register_api_command(
126 "music/genres/scanner_status",
127 self.get_scanner_status,
128 )
129 self.mass.register_api_command(
130 "music/genres/genres_for_media_item",
131 self.get_genres_for_media_item,
132 )
133
134 # Run genre mapping scanner after library sync completes
135 self.mass.subscribe(self._on_sync_tasks_updated, EventType.SYNC_TASKS_UPDATED)
136
137 @staticmethod
138 def _get_genre_icon_metadata(translation_key: str | None) -> MediaItemMetadata | None:
139 """Build metadata with genre icon image if an SVG exists for the translation key.
140
141 :param translation_key: The genre's translation key (matches SVG filename).
142 """
143 if not translation_key:
144 return None
145 icon_path = GENRE_ICONS_DIR / f"{translation_key}.svg"
146 if not icon_path.is_file():
147 return None
148 image = MediaItemImage(
149 type=ImageType.THUMB,
150 path=str(icon_path),
151 provider="builtin",
152 )
153 return MediaItemMetadata(images=UniqueList([image]))
154
155 @staticmethod
156 def _dedup_aliases(existing: list[str], new: list[str]) -> list[str]:
157 """Merge alias lists, deduplicating by normalized form (create_safe_string).
158
159 Preserves the first occurrence's original casing.
160
161 :param existing: Current aliases (ordering preserved).
162 :param new: New aliases to add if not already present.
163 """
164 seen: set[str] = set()
165 result: list[str] = []
166 for alias in [*existing, *new]:
167 norm = create_safe_string(alias, True, True)
168 if norm and norm not in seen:
169 seen.add(norm)
170 result.append(alias)
171 return result
172
173 @property
174 def _search_filter_clause(self) -> str:
175 """Return search filter that also matches genre aliases."""
176 return (
177 f"({self.db_table}.search_name LIKE :search"
178 " OR EXISTS("
179 f"SELECT 1 FROM json_each({self.db_table}.genre_aliases) "
180 "WHERE LOWER(json_each.value) LIKE :search_raw))"
181 )
182
183 async def _add_library_item(self, item: Genre, overwrite_existing: bool = False) -> int:
184 """Add a new genre record to the database."""
185 aliases: list[str] = list(item.genre_aliases) if item.genre_aliases else [item.name]
186 # Ensure the genre's own name is always in aliases (normalized comparison)
187 name_norm = create_safe_string(item.name, True, True)
188 if not any(create_safe_string(a, True, True) == name_norm for a in aliases):
189 aliases.insert(0, item.name)
190 db_id = await self.mass.music.database.insert(
191 self.db_table,
192 {
193 "name": item.name,
194 "sort_name": item.sort_name,
195 "translation_key": item.translation_key,
196 "description": item.metadata.description if item.metadata else None,
197 "favorite": item.favorite,
198 "metadata": serialize_to_json(item.metadata),
199 "external_ids": serialize_to_json(item.external_ids),
200 "genre_aliases": serialize_to_json(aliases),
201 "play_count": 0,
202 "last_played": 0,
203 "search_name": create_safe_string(item.name, True, True),
204 "search_sort_name": create_safe_string(item.sort_name or "", True, True),
205 "timestamp_added": UNSET,
206 },
207 )
208 self.logger.debug("added %s to database (id: %s)", item.name, db_id)
209 return db_id
210
211 async def _update_library_item(
212 self, item_id: str | int, update: Genre, overwrite: bool = False
213 ) -> None:
214 """Update existing genre record in the database."""
215 db_id = int(item_id)
216 cur_item = await self.get_library_item(db_id)
217 metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata)
218 cur_item.external_ids.update(update.external_ids)
219 name = update.name if overwrite else cur_item.name
220 sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name
221 existing_description = await self._get_description(db_id)
222 description = (
223 update.metadata.description
224 if update.metadata and update.metadata.description is not None
225 else None
226 if overwrite
227 else existing_description
228 )
229 # Merge aliases: keep existing, add any new from update (normalized dedup)
230 existing_aliases = list(cur_item.genre_aliases) if cur_item.genre_aliases else []
231 update_aliases = list(update.genre_aliases) if update.genre_aliases else []
232 if overwrite:
233 merged_aliases = self._dedup_aliases(update_aliases, [name])
234 else:
235 merged_aliases = self._dedup_aliases(existing_aliases, [*update_aliases, name])
236
237 await self.mass.music.database.update(
238 self.db_table,
239 {"item_id": db_id},
240 {
241 "name": name,
242 "sort_name": sort_name,
243 "translation_key": update.translation_key
244 if overwrite
245 else cur_item.translation_key,
246 "description": description,
247 "favorite": update.favorite,
248 "metadata": serialize_to_json(metadata),
249 "external_ids": serialize_to_json(
250 update.external_ids if overwrite else cur_item.external_ids
251 ),
252 "genre_aliases": serialize_to_json(merged_aliases),
253 "search_name": create_safe_string(name, True, True),
254 "search_sort_name": create_safe_string(sort_name or "", True, True),
255 "timestamp_added": UNSET,
256 },
257 )
258 self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
259
260 async def library_items(
261 self,
262 favorite: bool | None = None,
263 search: str | None = None,
264 limit: int = 500,
265 offset: int = 0,
266 order_by: str = "sort_name",
267 provider: str | list[str] | None = None,
268 genre: int | list[int] | None = None,
269 **kwargs: Any,
270 ) -> list[Genre]:
271 """Get genres in the library.
272
273 :param genre: NOT SUPPORTED - Filtering genres by genres doesn't make sense.
274 """
275 if genre is not None:
276 msg = "genre parameter is not supported for Genre.library_items()"
277 raise ValueError(msg)
278 # Genres are library-only items without provider_mappings, so ignore
279 # the provider filter (the frontend always sends provider="library").
280 # Pass raw lowered search for alias matching (search_raw),
281 # since the normalized :search param strips spaces/special chars.
282 extra_params: dict[str, Any] | None = None
283 if search:
284 extra_params = {"search_raw": f"%{search.strip().lower()}%"}
285 return await self.get_library_items_by_query(
286 favorite=favorite,
287 search=search,
288 limit=limit,
289 offset=offset,
290 order_by=order_by,
291 extra_query_params=extra_params,
292 )
293
294 async def radio_mode_base_tracks(
295 self,
296 item: Genre,
297 preferred_provider_instances: list[str] | None = None,
298 ) -> list[Track]:
299 """Get the list of base tracks for a genre.
300
301 :param item: The Genre to get base tracks for.
302 :param preferred_provider_instances: List of preferred provider instance IDs to use.
303 """
304 db_id = int(item.item_id)
305 gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
306 query = (
307 f"EXISTS(SELECT 1 FROM {gm} gm "
308 "WHERE gm.media_id = tracks.item_id "
309 "AND gm.media_type = 'track' "
310 "AND gm.genre_id = :genre_id)"
311 )
312 return await self.mass.music.tracks.get_library_items_by_query(
313 extra_query_parts=[query],
314 extra_query_params={"genre_id": db_id},
315 limit=50,
316 order_by="random",
317 )
318
319 async def mapped_media(
320 self,
321 item: Genre,
322 limit: int = 0,
323 offset: int = 0,
324 track_limit: int | None = None,
325 album_limit: int | None = None,
326 artist_limit: int | None = None,
327 order_by: str | None = None,
328 ) -> tuple[list[Track], list[Album], list[Artist]]:
329 """Return tracks, albums, and artists mapped to a genre.
330
331 :param item: The genre to fetch mapped media for.
332 :param limit: Default limit applied to all media types (0 = unlimited).
333 :param offset: Offset for pagination.
334 :param track_limit: Override limit for tracks (defaults to limit).
335 :param album_limit: Override limit for albums (defaults to limit).
336 :param artist_limit: Override limit for artists (defaults to limit).
337 :param order_by: Sort order for all queries (e.g. "random").
338 """
339 db_id = int(item.item_id)
340 gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
341 t_limit = track_limit if track_limit is not None else limit
342 a_limit = album_limit if album_limit is not None else limit
343 ar_limit = artist_limit if artist_limit is not None else limit
344
345 track_query = (
346 f"EXISTS(SELECT 1 FROM {gm} gm "
347 "WHERE gm.media_id = tracks.item_id "
348 "AND gm.media_type = 'track' AND gm.genre_id = :genre_id)"
349 )
350 album_query = (
351 f"EXISTS(SELECT 1 FROM {gm} gm "
352 "WHERE gm.media_id = albums.item_id "
353 "AND gm.media_type = 'album' AND gm.genre_id = :genre_id)"
354 )
355 artist_query = (
356 f"EXISTS(SELECT 1 FROM {gm} gm "
357 "WHERE gm.media_id = artists.item_id "
358 "AND gm.media_type = 'artist' AND gm.genre_id = :genre_id)"
359 )
360
361 tracks, albums, artists = await asyncio.gather(
362 self.mass.music.tracks.get_library_items_by_query(
363 extra_query_parts=[track_query],
364 extra_query_params={"genre_id": db_id},
365 limit=t_limit,
366 offset=offset,
367 order_by=order_by,
368 ),
369 self.mass.music.albums.get_library_items_by_query(
370 extra_query_parts=[album_query],
371 extra_query_params={"genre_id": db_id},
372 limit=a_limit,
373 offset=offset,
374 order_by=order_by,
375 ),
376 self.mass.music.artists.get_library_items_by_query(
377 extra_query_parts=[artist_query],
378 extra_query_params={"genre_id": db_id},
379 limit=ar_limit,
380 offset=offset,
381 order_by=order_by,
382 ),
383 )
384 return tracks, albums, artists
385
386 async def get_genres_for_media_item(
387 self, media_type: MediaType, media_id: str | int
388 ) -> list[Genre]:
389 """Return all genres mapped to a given media item.
390
391 :param media_type: The type of media item.
392 :param media_id: The database ID of the media item.
393 """
394 media_id_int = int(media_id)
395 gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
396 query = (
397 f"EXISTS(SELECT 1 FROM {gm} gm "
398 f"WHERE gm.genre_id = {self.db_table}.item_id "
399 "AND gm.media_type = :media_type AND gm.media_id = :media_id)"
400 )
401 return await self.get_library_items_by_query(
402 extra_query_parts=[query],
403 extra_query_params={
404 "media_type": media_type.value,
405 "media_id": media_id_int,
406 },
407 )
408
409 async def get_radio_mode_base_tracks(
410 self,
411 item_id: str,
412 provider_instance_id_or_domain: str | None = None,
413 preferred_provider_instances: list[str] | None = None,
414 ) -> list[Track]:
415 """Return base tracks for genre radio mode."""
416 provider = provider_instance_id_or_domain or "library"
417 item = await self.get(item_id, provider)
418 return await self.radio_mode_base_tracks(item, preferred_provider_instances)
419
420 async def get_overview(
421 self,
422 item_id: str,
423 provider_instance_id_or_domain: str | None = None,
424 limit: int = 25,
425 ) -> list[RecommendationFolder]:
426 """Return overview rows for a genre (all media types)."""
427 provider = provider_instance_id_or_domain or "library"
428 item = await self.get(item_id, provider)
429 db_id = int(item.item_id)
430 gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
431 media_rows: list[tuple[MediaType, str]] = [
432 (MediaType.ARTIST, "Artists"),
433 (MediaType.ALBUM, "Albums"),
434 (MediaType.TRACK, "Tracks"),
435 (MediaType.PLAYLIST, "Playlists"),
436 (MediaType.RADIO, "Radio"),
437 (MediaType.PODCAST, "Podcasts"),
438 (MediaType.AUDIOBOOK, "Audiobooks"),
439 ]
440
441 async def _fetch_media_type(
442 media_type: MediaType, title: str
443 ) -> RecommendationFolder | None:
444 ctrl = self.mass.music.get_controller(media_type)
445 query = (
446 f"EXISTS(SELECT 1 FROM {gm} gm "
447 f"WHERE gm.media_id = {ctrl.db_table}.item_id "
448 "AND gm.media_type = :media_type "
449 "AND gm.genre_id = :genre_id)"
450 )
451 items = await ctrl.get_library_items_by_query(
452 extra_query_parts=[query],
453 extra_query_params={
454 "genre_id": db_id,
455 "media_type": media_type.value,
456 },
457 limit=limit,
458 )
459 if not items:
460 return None
461 return RecommendationFolder(
462 item_id=f"genre_{media_type.value}",
463 name=title,
464 provider="library",
465 items=UniqueList(items[:limit]),
466 )
467
468 results = await asyncio.gather(*[_fetch_media_type(mt, title) for mt, title in media_rows])
469 return [r for r in results if r is not None]
470
471 async def match_providers(self, db_item: Genre) -> None:
472 """No provider matching for genres at this time."""
473 return
474
475 async def restore_default_genres(self, full_restore: bool = False) -> list[Genre]:
476 """Restore default genres from genre_mapping.json.
477
478 :param full_restore: If True, delete all existing genres and recreate from defaults.
479 If False (default), only add missing genres and ensure aliases exist.
480 """
481 if full_restore:
482 self.logger.warning("Performing FULL restore - deleting all existing genres")
483 await self.mass.music.database.delete(DB_TABLE_GENRE_MEDIA_ITEM_MAPPING)
484 await self.mass.music.database.delete(DB_TABLE_GENRES)
485 existing = set()
486 else:
487 rows = await self.mass.music.database.get_rows_from_query(
488 f"SELECT search_name FROM {DB_TABLE_GENRES}", limit=0
489 )
490 existing = {row["search_name"] for row in rows}
491
492 created_ids: list[int] = []
493 for entry in DEFAULT_GENRE_MAPPING:
494 name = entry.get("genre")
495 if not name:
496 continue
497 normalized = self._normalize_genre_name(name)
498 if not normalized:
499 continue
500 name_value, sort_name, search_name, search_sort_name = normalized
501 all_aliases = [name_value, *entry.get("aliases", [])]
502
503 # Partial restore: Ensure aliases are up to date
504 if search_name in existing:
505 if db_row := await self.mass.music.database.get_row(
506 DB_TABLE_GENRES, {"search_name": search_name}
507 ):
508 genre_id = int(db_row["item_id"])
509 await self._ensure_aliases(genre_id, all_aliases)
510 continue
511
512 # Create new genre
513 translation_key = entry.get("translation_key")
514 icon_metadata = self._get_genre_icon_metadata(translation_key)
515 genre_id = await self.mass.music.database.insert(
516 DB_TABLE_GENRES,
517 {
518 "name": name_value,
519 "sort_name": sort_name,
520 "translation_key": translation_key,
521 "description": None,
522 "favorite": 0,
523 "metadata": serialize_to_json(icon_metadata.to_dict() if icon_metadata else {}),
524 "external_ids": serialize_to_json(set()),
525 "genre_aliases": serialize_to_json(all_aliases),
526 "play_count": 0,
527 "last_played": 0,
528 "search_name": search_name,
529 "search_sort_name": search_sort_name,
530 "timestamp_added": UNSET,
531 },
532 )
533 created_ids.append(genre_id)
534 existing.add(search_name)
535
536 if full_restore:
537 await self._bulk_scan_media_genres()
538
539 if not created_ids:
540 return []
541 return [await self.get_library_item(item_id) for item_id in created_ids]
542
543 async def _bulk_scan_media_genres(self) -> None:
544 """Bulk-scan all media items and rebuild genre mappings using CTE.
545
546 Uses the same approach as the initial migration: extracts all unique genre names
547 from metadata.genres across all media tables, resolves them to genre IDs via alias
548 lookup, then does a single INSERT per media type using a CTE join.
549 """
550 db = self.mass.music.database
551
552 media_tables = (
553 (DB_TABLE_TRACKS, MediaType.TRACK),
554 (DB_TABLE_ALBUMS, MediaType.ALBUM),
555 (DB_TABLE_ARTISTS, MediaType.ARTIST),
556 (DB_TABLE_PLAYLISTS, MediaType.PLAYLIST),
557 (DB_TABLE_RADIOS, MediaType.RADIO),
558 (DB_TABLE_AUDIOBOOKS, MediaType.AUDIOBOOK),
559 (DB_TABLE_PODCASTS, MediaType.PODCAST),
560 )
561
562 # Build alias -> genre_ids lookup from all genres in the database.
563 # One alias can map to multiple genres (n:n relationship).
564 alias_to_genre: dict[str, list[int]] = {}
565 genre_rows = await db.get_rows_from_query(
566 f"SELECT item_id, genre_aliases FROM {DB_TABLE_GENRES}", limit=0
567 )
568 for row in genre_rows:
569 genre_id = int(row["item_id"])
570 aliases = json.loads(row["genre_aliases"]) if row["genre_aliases"] else []
571 for alias in aliases:
572 norm = create_safe_string(alias.strip(), True, True)
573 if norm:
574 alias_to_genre.setdefault(norm, [])
575 if genre_id not in alias_to_genre[norm]:
576 alias_to_genre[norm].append(genre_id)
577
578 # Extract all unique raw genre names from metadata across all media tables
579 union_parts = [
580 f"SELECT DISTINCT TRIM(g.value) AS raw_name "
581 f"FROM {table}, json_each(json_extract({table}.metadata, '$.genres')) AS g "
582 f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
583 f"AND json_extract({table}.metadata, '$.genres') != '[]'"
584 for table, _ in media_tables
585 ]
586 unique_names_sql = " UNION ".join(union_parts)
587 rows = await db.get_rows_from_query(unique_names_sql, limit=0)
588 unique_raw_names = [row["raw_name"] for row in rows if row["raw_name"]]
589
590 self.logger.debug(
591 "Bulk genre scan - discovered %d unique genre names", len(unique_raw_names)
592 )
593
594 # Resolve each raw name to genre_ids via alias lookup.
595 # One raw name can map to multiple genres (n:n).
596 raw_name_to_genres: dict[str, list[int]] = {}
597 for raw_name in unique_raw_names:
598 norm = create_safe_string(raw_name.strip(), True, True)
599 if not norm:
600 continue
601 if norm in alias_to_genre:
602 raw_name_to_genres[raw_name] = alias_to_genre[norm]
603 self.logger.debug(
604 "Bulk scan - resolved %r -> genre_ids %s (alias match)",
605 raw_name,
606 alias_to_genre[norm],
607 )
608 else:
609 resolved_ids = await self._find_genres_for_alias(raw_name)
610 if resolved_ids:
611 raw_name_to_genres[raw_name] = resolved_ids
612 alias_to_genre[norm] = resolved_ids
613 self.logger.debug(
614 "Bulk scan - resolved %r -> genre_ids %s (new genre)",
615 raw_name,
616 resolved_ids,
617 )
618
619 self.logger.info(
620 "Bulk genre scan - resolved %d unique genre names", len(raw_name_to_genres)
621 )
622
623 # Add discovered raw names as aliases to their resolved genres so that
624 # future searches by raw name (e.g. "Synthpop") find the parent genre
625 # even when the stored alias differs (e.g. "synth-pop").
626 genre_new_aliases: dict[int, list[str]] = {}
627 for raw_name, gids in raw_name_to_genres.items():
628 for gid in gids:
629 genre_new_aliases.setdefault(gid, []).append(raw_name)
630 for gid, new_aliases in genre_new_aliases.items():
631 await self._ensure_aliases(gid, new_aliases)
632
633 # Build CTE with (raw_name, genre_id) pairs. One raw name can produce
634 # multiple rows when it maps to multiple genres (n:n).
635 if raw_name_to_genres:
636 cte_values = ", ".join(
637 f"(LOWER('{name.replace(chr(39), chr(39) + chr(39))}'), {gid})"
638 for name, gids in raw_name_to_genres.items()
639 for gid in gids
640 )
641 cte = f"WITH genre_lookup(raw_name, genre_id) AS (VALUES {cte_values})"
642
643 for table, media_type in media_tables:
644 full_query = (
645 f"{cte} INSERT OR REPLACE INTO {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING}"
646 f"(genre_id, media_id, media_type, alias) "
647 f"SELECT gl.genre_id, {table}.item_id, "
648 f"'{media_type.value}', TRIM(g.value) "
649 f"FROM {table}, "
650 f"json_each(json_extract({table}.metadata, '$.genres')) AS g "
651 f"JOIN genre_lookup gl ON gl.raw_name = LOWER(TRIM(g.value)) "
652 f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
653 f"AND json_extract({table}.metadata, '$.genres') != '[]'"
654 )
655 await db.execute(full_query)
656 await db.commit()
657
658 self.logger.info(
659 "Bulk genre scan completed - mapped %d unique names to genres",
660 len(raw_name_to_genres),
661 )
662
663 async def _bulk_scan_unmapped_genres(self) -> int:
664 """Scan only unmapped media items and create genre mappings using CTE.
665
666 Similar to _bulk_scan_media_genres but filters to items not yet in
667 genre_media_item_mapping. Used by the incremental scanner after syncs.
668
669 :return: Total number of items mapped.
670 """
671 db = self.mass.music.database
672 gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
673
674 media_tables = (
675 (DB_TABLE_TRACKS, MediaType.TRACK),
676 (DB_TABLE_ALBUMS, MediaType.ALBUM),
677 (DB_TABLE_ARTISTS, MediaType.ARTIST),
678 (DB_TABLE_PLAYLISTS, MediaType.PLAYLIST),
679 (DB_TABLE_RADIOS, MediaType.RADIO),
680 (DB_TABLE_AUDIOBOOKS, MediaType.AUDIOBOOK),
681 (DB_TABLE_PODCASTS, MediaType.PODCAST),
682 )
683
684 # Build alias -> genre_ids lookup (n:n) from all genres in the database.
685 alias_to_genre: dict[str, list[int]] = {}
686 genre_rows = await db.get_rows_from_query(
687 f"SELECT item_id, genre_aliases FROM {DB_TABLE_GENRES}", limit=0
688 )
689 for row in genre_rows:
690 genre_id = int(row["item_id"])
691 aliases = json.loads(row["genre_aliases"]) if row["genre_aliases"] else []
692 for alias in aliases:
693 norm = create_safe_string(alias.strip(), True, True)
694 if norm:
695 alias_to_genre.setdefault(norm, [])
696 if genre_id not in alias_to_genre[norm]:
697 alias_to_genre[norm].append(genre_id)
698
699 # Extract all unique raw genre names from media items.
700 # We don't filter by unmapped items here because a media item may
701 # have some genres mapped but not all (e.g. added a new genre tag).
702 union_parts = [
703 f"SELECT DISTINCT TRIM(g.value) AS raw_name "
704 f"FROM {table}, json_each(json_extract({table}.metadata, '$.genres')) AS g "
705 f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
706 f"AND json_extract({table}.metadata, '$.genres') != '[]'"
707 for table, _mtype in media_tables
708 ]
709 unique_names_sql = " UNION ".join(union_parts)
710 rows = await db.get_rows_from_query(unique_names_sql, limit=0)
711 unique_raw_names = [row["raw_name"] for row in rows if row["raw_name"]]
712
713 if not unique_raw_names:
714 return 0
715
716 self.logger.debug(
717 "Incremental genre scan - discovered %d unique genre names from unmapped items",
718 len(unique_raw_names),
719 )
720
721 # Resolve each raw name to genre_ids (n:n)
722 raw_name_to_genres: dict[str, list[int]] = {}
723 for raw_name in unique_raw_names:
724 norm = create_safe_string(raw_name.strip(), True, True)
725 if not norm:
726 continue
727 if norm in alias_to_genre:
728 raw_name_to_genres[raw_name] = alias_to_genre[norm]
729 self.logger.debug(
730 "Scanner - resolved %r -> genre_ids %s (alias match)",
731 raw_name,
732 alias_to_genre[norm],
733 )
734 else:
735 resolved_ids = await self._find_genres_for_alias(raw_name)
736 if resolved_ids:
737 raw_name_to_genres[raw_name] = resolved_ids
738 alias_to_genre[norm] = resolved_ids
739 self.logger.debug(
740 "Scanner - resolved %r -> genre_ids %s (new genre)",
741 raw_name,
742 resolved_ids,
743 )
744
745 if not raw_name_to_genres:
746 return 0
747
748 # Add discovered raw names as aliases to their resolved genres
749 genre_new_aliases: dict[int, list[str]] = {}
750 for raw_name, gids in raw_name_to_genres.items():
751 for gid in gids:
752 genre_new_aliases.setdefault(gid, []).append(raw_name)
753 for gid, new_aliases in genre_new_aliases.items():
754 await self._ensure_aliases(gid, new_aliases)
755
756 # Build CTE with n:n pairs and INSERT only for unmapped items
757 cte_values = ", ".join(
758 f"(LOWER('{name.replace(chr(39), chr(39) + chr(39))}'), {gid})"
759 for name, gids in raw_name_to_genres.items()
760 for gid in gids
761 )
762 cte = f"WITH genre_lookup(raw_name, genre_id) AS (VALUES {cte_values})"
763
764 count_before = await db.get_count(gm)
765 for table, media_type in media_tables:
766 full_query = (
767 f"{cte} INSERT OR IGNORE INTO {gm}"
768 f"(genre_id, media_id, media_type, alias) "
769 f"SELECT gl.genre_id, {table}.item_id, "
770 f"'{media_type.value}', TRIM(g.value) "
771 f"FROM {table}, "
772 f"json_each(json_extract({table}.metadata, '$.genres')) AS g "
773 f"JOIN genre_lookup gl ON gl.raw_name = LOWER(TRIM(g.value)) "
774 f"WHERE json_extract({table}.metadata, '$.genres') IS NOT NULL "
775 f"AND json_extract({table}.metadata, '$.genres') != '[]' "
776 f"AND NOT EXISTS ("
777 f"SELECT 1 FROM {gm} ex "
778 f"WHERE ex.genre_id = gl.genre_id "
779 f"AND ex.media_id = {table}.item_id "
780 f"AND ex.media_type = '{media_type.value}')"
781 )
782 await db.execute(full_query)
783 await db.commit()
784 count_after = await db.get_count(gm)
785
786 return count_after - count_before
787
788 async def remove_item_from_library(self, item_id: str | int, recursive: bool = True) -> None:
789 """Delete genre record from the database."""
790 db_id = int(item_id)
791 await self.mass.music.database.delete(
792 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING, {"genre_id": db_id}
793 )
794 await super().remove_item_from_library(item_id, recursive)
795
796 async def add_alias(self, genre_id: str | int, alias: str) -> Genre:
797 """Add an alias string to a genre.
798
799 :param genre_id: Database ID of the genre.
800 :param alias: Alias string to add.
801 """
802 db_id = int(genre_id)
803 genre = await self.get_library_item(db_id)
804 aliases = list(genre.genre_aliases) if genre.genre_aliases else []
805 aliases = self._dedup_aliases(aliases, [alias])
806 await self.mass.music.database.update(
807 self.db_table,
808 {"item_id": db_id},
809 {"genre_aliases": serialize_to_json(aliases)},
810 )
811 updated = await self.get_library_item(db_id)
812 self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, updated.uri, updated)
813 return updated
814
815 async def remove_alias(self, genre_id: str | int, alias: str) -> Genre:
816 """Remove an alias string from a genre.
817
818 :param genre_id: Database ID of the genre.
819 :param alias: Alias string to remove.
820 :raises ValueError: If trying to remove the genre's own name.
821 """
822 db_id = int(genre_id)
823 genre = await self.get_library_item(db_id)
824 if create_safe_string(alias, True, True) == create_safe_string(genre.name, True, True):
825 msg = (
826 f"Cannot remove self-alias '{alias}' from genre '{genre.name}'. "
827 f"Delete the genre instead."
828 )
829 raise ValueError(msg)
830 aliases = list(genre.genre_aliases) if genre.genre_aliases else []
831 alias_norm = create_safe_string(alias, True, True)
832 aliases = [a for a in aliases if create_safe_string(a, True, True) != alias_norm]
833 await self.mass.music.database.update(
834 self.db_table,
835 {"item_id": db_id},
836 {"genre_aliases": serialize_to_json(aliases)},
837 )
838 # Remove media mappings that were created via this alias (case-insensitive)
839 await self.mass.music.database.execute(
840 f"DELETE FROM {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
841 "WHERE genre_id = :genre_id AND LOWER(alias) = LOWER(:alias)",
842 {"genre_id": db_id, "alias": alias},
843 )
844 updated = await self.get_library_item(db_id)
845 self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, updated.uri, updated)
846 return updated
847
848 async def add_media_mapping(
849 self, genre_id: str | int, media_type: MediaType, media_id: str | int, alias: str
850 ) -> None:
851 """Map a media item to a genre.
852
853 :param genre_id: Database ID of the genre.
854 :param media_type: Type of media item (track, album, artist).
855 :param media_id: Database ID of the media item.
856 :param alias: The alias string that caused this mapping.
857 """
858 await self.mass.music.database.insert(
859 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
860 {
861 "genre_id": int(genre_id),
862 "media_id": int(media_id),
863 "media_type": media_type.value,
864 "alias": alias,
865 },
866 allow_replace=True,
867 )
868
869 async def remove_media_mapping(
870 self, genre_id: str | int, media_type: MediaType, media_id: str | int
871 ) -> None:
872 """Remove a media item mapping from a genre.
873
874 :param genre_id: Database ID of the genre.
875 :param media_type: Type of media item (track, album, artist).
876 :param media_id: Database ID of the media item.
877 """
878 await self.mass.music.database.delete(
879 DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
880 {
881 "genre_id": int(genre_id),
882 "media_id": int(media_id),
883 "media_type": media_type.value,
884 },
885 )
886
887 async def promote_alias_to_genre(self, genre_id: str | int, alias: str) -> Genre:
888 """Promote an alias to become a standalone genre.
889
890 Creates a new Genre with the alias's name, moves all media mappings
891 for that alias to the new genre, and removes the alias from the
892 original genre.
893
894 :param genre_id: Database ID of the source genre.
895 :param alias: The alias string to promote.
896 :return: The newly created Genre.
897 """
898 db_genre_id = int(genre_id)
899 source_genre = await self.get_library_item(db_genre_id)
900
901 if create_safe_string(alias, True, True) == create_safe_string(
902 source_genre.name, True, True
903 ):
904 msg = (
905 f"Cannot promote self-alias '{alias}'. "
906 f"This alias is the primary name for genre '{source_genre.name}'."
907 )
908 raise ValueError(msg)
909
910 # Create new genre with the alias as its name
911 new_genre = Genre(
912 item_id="0",
913 provider="library",
914 name=alias,
915 sort_name=alias,
916 translation_key=None,
917 provider_mappings=set(),
918 favorite=False,
919 )
920 created_genre = await self.add_item_to_library(new_genre)
921 new_genre_id = int(created_genre.item_id)
922
923 # Move media mappings from source genre to new genre for this alias (case-insensitive)
924 await self.mass.music.database.execute(
925 f"UPDATE {DB_TABLE_GENRE_MEDIA_ITEM_MAPPING} "
926 "SET genre_id = :new_id WHERE genre_id = :old_id AND LOWER(alias) = LOWER(:alias)",
927 {"new_id": new_genre_id, "old_id": db_genre_id, "alias": alias},
928 )
929
930 # Remove alias from source genre (normalized comparison)
931 alias_norm = create_safe_string(alias, True, True)
932 aliases = list(source_genre.genre_aliases) if source_genre.genre_aliases else []
933 aliases = [a for a in aliases if create_safe_string(a, True, True) != alias_norm]
934 await self.mass.music.database.update(
935 self.db_table,
936 {"item_id": db_genre_id},
937 {"genre_aliases": serialize_to_json(list(aliases))},
938 )
939
940 return await self.get_library_item(new_genre_id)
941
942 async def sync_media_item_genres(
943 self, media_type: MediaType, media_id: str | int, genre_names: set[str]
944 ) -> None:
945 """Sync genre mappings for a media item.
946
947 Ensures genre records exist and updates genre-media mappings.
948 Removes mappings that are no longer present in the incoming genre_names set.
949
950 :param media_type: The type of media item being synced.
951 :param media_id: The database ID of the media item.
952 :param genre_names: Set of genre names from the provider.
953 """
954 media_id_int = int(media_id)
955 gm = DB_TABLE_GENRE_MEDIA_ITEM_MAPPING
956
957 # Build target set: (genre_id, alias_name) from incoming names.
958 # One alias can map to multiple genres (n:n).
959 target_mappings: dict[int, str] = {}
960 for name in genre_names:
961 normalized = self._normalize_genre_name(name)
962 if not normalized:
963 continue
964 genre_ids = await self._find_genres_for_alias(normalized[0])
965 for gid in genre_ids:
966 if gid not in target_mappings:
967 target_mappings[gid] = normalized[0]
968
969 # Get current genre_ids from database
970 rows = await self.mass.music.database.get_rows_from_query(
971 f"SELECT genre_id FROM {gm} WHERE media_type = :media_type AND media_id = :media_id",
972 {"media_type": media_type.value, "media_id": media_id_int},
973 limit=0,
974 )
975 existing_genre_ids = {int(row["genre_id"]) for row in rows}
976
977 to_add = set(target_mappings.keys()) - existing_genre_ids
978 to_remove = existing_genre_ids - set(target_mappings.keys())
979
980 for genre_id in to_remove:
981 await self.mass.music.database.delete(
982 gm,
983 {
984 "genre_id": genre_id,
985 "media_id": media_id_int,
986 "media_type": media_type.value,
987 },
988 )
989
990 for genre_id in to_add:
991 await self.mass.music.database.insert(
992 gm,
993 {
994 "genre_id": genre_id,
995 "media_id": media_id_int,
996 "media_type": media_type.value,
997 "alias": target_mappings[genre_id],
998 },
999 allow_replace=True,
1000 )
1001
1002 async def _ensure_aliases(self, genre_id: int, aliases: list[str]) -> None:
1003 """Ensure a genre has all the specified aliases in its genre_aliases JSON.
1004
1005 :param genre_id: Database ID of the genre.
1006 :param aliases: List of alias strings that should be present.
1007 """
1008 genre = await self.get_library_item(genre_id)
1009 existing = list(genre.genre_aliases) if genre.genre_aliases else []
1010 merged = self._dedup_aliases(existing, aliases)
1011 if len(merged) != len(existing):
1012 await self.mass.music.database.update(
1013 self.db_table,
1014 {"item_id": genre_id},
1015 {"genre_aliases": serialize_to_json(merged)},
1016 )
1017
1018 async def _find_genres_for_alias(self, name: str) -> list[int]:
1019 """Find all genres that own the given alias name, or create a new genre.
1020
1021 An alias can map to multiple genres (n:n relationship). For example,
1022 "anime" could be an alias of both an "Anime" genre and an "Anime Music" genre.
1023 If no genre owns this alias, creates a new genre.
1024
1025 :param name: The alias name to find/create a genre for.
1026 :return: List of genre IDs (empty if name is invalid).
1027 """
1028 normalized = self._normalize_genre_name(name)
1029 if not normalized:
1030 return []
1031 name_value, sort_name, search_name, search_sort_name = normalized
1032
1033 async with self._db_add_lock:
1034 found_ids: list[int] = []
1035
1036 # Check if a genre exists with this name as its own name
1037 if db_row := await self.mass.music.database.get_row(
1038 DB_TABLE_GENRES, {"search_name": search_name}
1039 ):
1040 found_ids.append(int(db_row["item_id"]))
1041
1042 # Search genre_aliases JSON columns (case-insensitive, can match multiple)
1043 rows = await self.mass.music.database.get_rows_from_query(
1044 f"SELECT item_id FROM {DB_TABLE_GENRES} "
1045 "WHERE EXISTS("
1046 "SELECT 1 FROM json_each(genre_aliases) "
1047 "WHERE LOWER(json_each.value) = LOWER(:alias_name)"
1048 ")",
1049 {"alias_name": name_value},
1050 limit=0,
1051 )
1052 for row in rows:
1053 gid = int(row["item_id"])
1054 if gid not in found_ids:
1055 found_ids.append(gid)
1056
1057 # Also check via normalized comparison (create_safe_string).
1058 # This catches genres that stages 1-2 miss due to normalization
1059 # differences, e.g. genre A has "synthpop", genre B has "synth-pop"
1060 # â both normalize to "synthpop" but LOWER can't bridge the gap.
1061 all_genres = await self.mass.music.database.get_rows_from_query(
1062 f"SELECT item_id, genre_aliases FROM {DB_TABLE_GENRES}", limit=0
1063 )
1064 for row in all_genres:
1065 aliases = json.loads(row["genre_aliases"]) if row["genre_aliases"] else []
1066 for alias in aliases:
1067 if create_safe_string(alias.strip(), True, True) == search_name:
1068 gid = int(row["item_id"])
1069 if gid not in found_ids:
1070 found_ids.append(gid)
1071
1072 if found_ids:
1073 return found_ids
1074
1075 # No genre owns this alias â create a new one
1076 new_id = await self.mass.music.database.insert(
1077 DB_TABLE_GENRES,
1078 {
1079 "name": name_value,
1080 "sort_name": sort_name,
1081 "description": None,
1082 "favorite": 0,
1083 "metadata": serialize_to_json({}),
1084 "external_ids": serialize_to_json(set()),
1085 "genre_aliases": serialize_to_json([name_value]),
1086 "play_count": 0,
1087 "last_played": 0,
1088 "search_name": search_name,
1089 "search_sort_name": search_sort_name,
1090 "timestamp_added": UNSET,
1091 },
1092 )
1093 return [new_id]
1094
1095 async def _get_description(self, item_id: int) -> str | None:
1096 if db_row := await self.mass.music.database.get_row(DB_TABLE_GENRES, {"item_id": item_id}):
1097 return dict(db_row).get("description")
1098 return None
1099
1100 @staticmethod
1101 def _normalize_genre_name(raw_name: str) -> tuple[str, str, str, str] | None:
1102 """Normalize a raw genre name for storage and search.
1103
1104 :param raw_name: Raw genre name from provider.
1105 :return: Tuple of (name, sort_name, search_name, search_sort_name) or None if invalid.
1106 """
1107 name = raw_name.strip()
1108 if not name:
1109 return None
1110 sort_name = name
1111 search_name = create_safe_string(name, True, True)
1112 if not search_name:
1113 return None
1114 search_sort_name = create_safe_string(sort_name or "", True, True)
1115 return name, sort_name, search_name, search_sort_name
1116
1117 def _on_sync_tasks_updated(self, _event: MassEvent) -> None:
1118 """Trigger genre mapping scan when all sync tasks complete."""
1119 if self.mass.music.in_progress_syncs or self._scanner_running:
1120 return
1121 self._scanner_running = True
1122 self.mass.create_task(self._scan_genre_mappings())
1123
1124 async def _scan_genre_mappings(self) -> None:
1125 """Scan media items with metadata.genres and map them to genres.
1126
1127 Triggered after library sync completes or via manual API call.
1128 Callers must set _scanner_running = True before calling this method.
1129 """
1130 # Double-check syncs haven't started since the event was dispatched
1131 if self.mass.music.in_progress_syncs:
1132 self.logger.debug("Syncs still in progress, deferring genre scan")
1133 self._scanner_running = False
1134 return
1135 self._last_scan_time = time.time()
1136
1137 try:
1138 self.logger.debug("Starting genre mapping scan...")
1139 self._last_scan_mapped = await self._bulk_scan_unmapped_genres()
1140 self.logger.info(
1141 "Genre mapping scan completed: %d items mapped (%.1fs)",
1142 self._last_scan_mapped,
1143 time.time() - self._last_scan_time,
1144 )
1145
1146 except Exception as err:
1147 self.logger.error(
1148 "Error in genre mapping scanner: %s",
1149 str(err),
1150 exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
1151 )
1152
1153 finally:
1154 self._scanner_running = False
1155
1156 async def scan_mappings(self) -> dict[str, Any]:
1157 """Manually trigger a genre mapping scan (admin only).
1158
1159 :return: Status information about the scan trigger.
1160 """
1161 if self._scanner_running:
1162 return {
1163 "status": "already_running",
1164 "message": "Genre mapping scanner is already running",
1165 }
1166
1167 self._scanner_running = True
1168 self.mass.create_task(self._scan_genre_mappings())
1169
1170 return {
1171 "status": "triggered",
1172 "message": "Genre mapping scan triggered",
1173 "last_scan": self._last_scan_time,
1174 }
1175
1176 async def get_scanner_status(self) -> dict[str, Any]:
1177 """Get status of the genre mapping background scanner.
1178
1179 :return: Scanner status information.
1180 """
1181 return {
1182 "running": self._scanner_running,
1183 "last_scan_time": self._last_scan_time,
1184 "last_scan_ago_seconds": (
1185 int(time.time() - self._last_scan_time) if self._last_scan_time else None
1186 ),
1187 "last_scan_mapped": self._last_scan_mapped,
1188 }
1189