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