/
/
1"""All logic for metadata retrieval."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7import os
8import random
9import sqlite3
10import threading
11from collections import OrderedDict
12from time import time
13from typing import TYPE_CHECKING, cast
14from uuid import NAMESPACE_URL, uuid5
15
16import aiohttp
17from music_assistant_models.auth import Scope
18from music_assistant_models.background_task import TaskSchedule
19from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
20from music_assistant_models.enums import (
21 AlbumType,
22 ConfigEntryType,
23 MediaType,
24 ProviderFeature,
25 ProviderType,
26)
27from music_assistant_models.errors import MusicAssistantError
28from music_assistant_models.media_items import BrowseFolder
29
30from music_assistant.constants import (
31 CONF_LANGUAGE,
32 DB_TABLE_ALBUMS,
33 DB_TABLE_ARTISTS,
34 DB_TABLE_PLAYLISTS,
35 VERBOSE_LOG_LEVEL,
36)
37from music_assistant.controllers.tasks.context import (
38 report_current_task_failure,
39 update_current_task_progress,
40 update_current_task_progress_from_index,
41 update_current_task_progress_text,
42)
43from music_assistant.helpers.api import api_command
44from music_assistant.helpers.images import cleanup_thumb_cache
45from music_assistant.helpers.lyrics import extract_lrc_lyrics, normalize_lrc_lyrics
46from music_assistant.helpers.throttle_retry import Throttler
47from music_assistant.helpers.util import try_parse_int
48from music_assistant.models.core_controller import CoreController
49from music_assistant.models.music_provider import MusicProvider
50
51from .constants import (
52 ALBUM_RECONCILIATION_TASK_ID,
53 CONF_ENABLE_ONLINE_METADATA,
54 CONF_ENABLE_RADIO_METADATA_LOOKUP,
55 CONF_PREFER_LOCAL_GENRES,
56 CONF_THUMB_CACHE_MAX_SIZE,
57 DEFAULT_LANGUAGE,
58 DEFAULT_THUMB_CACHE_MAX_SIZE_MB,
59 LOCALES,
60 METADATA_LOOKUP_TASK_ID_PREFIX,
61 METADATA_SCAN_BATCH_SIZE,
62 MISSING_ARTIST_METADATA_SCAN_TASK_ID,
63 PLAYLIST_METADATA_SCAN_TASK_ID,
64 REFRESH_INTERVAL,
65 THUMB_CACHE_CLEANUP_TASK_ID,
66)
67from .enrichment import MetadataEnrichmentMixin
68from .images import ImageProxyMixin
69from .radio import RadioArtworkMixin
70
71if TYPE_CHECKING:
72 from music_assistant_models.config_entries import CoreConfig
73 from music_assistant_models.media_items import (
74 Album,
75 Artist,
76 Audiobook,
77 MediaItemType,
78 Playlist,
79 Podcast,
80 Track,
81 )
82
83 from music_assistant import MusicAssistant
84 from music_assistant.controllers.music.media.base import MediaControllerBase
85 from music_assistant.helpers.json import SerializableType
86 from music_assistant.models.metadata_provider import MetadataProvider
87
88
89class MetaDataController(
90 ImageProxyMixin, RadioArtworkMixin, MetadataEnrichmentMixin, CoreController
91):
92 """Controller that handles metadata retrieval and management for media items."""
93
94 domain: str = "metadata"
95 config: CoreConfig
96
97 def __init__(self, mass: MusicAssistant) -> None:
98 """Initialize class."""
99 super().__init__(mass)
100 self.cache = self.mass.cache
101 self._pref_lang: str | None = None
102 self.manifest.name = "Metadata controller"
103 self.manifest.description = (
104 "Music Assistant's core controller which handles all metadata for music."
105 )
106 self.manifest.icon = "book-information-variant"
107 self._throttler = Throttler(1, 30)
108 # image-id bookkeeping, all bounded by _IMAGE_ID_LRU_MAX and sharing the
109 # same key/id string objects so the combined footprint stays small:
110 # - _image_id_forward: (provider, path) -> image_id memo so serializing a
111 # known image skips the sha256 and the lock entirely. Read lock-free
112 # (single dict lookup is atomic), mutated only while holding the lock.
113 # - _image_id_lru: image_id -> (provider, path). Write-through hot cache
114 # in front of the cache controller so that resolving an image by id
115 # never blocks on sqlite if the URL was generated recently.
116 # - _image_id_persisted: image_id -> epoch of the last persist to the
117 # cache db, so repeat encounters skip the sqlite write.
118 # The lock is needed because compute_image_id() runs from the executor
119 # thread during outbound websocket serialization.
120 self._image_id_forward: dict[tuple[str, str], str] = {}
121 self._image_id_lru: OrderedDict[str, tuple[str, str]] = OrderedDict()
122 self._image_id_persisted: dict[str, float] = {}
123 self._image_id_lock = threading.Lock()
124 # corrupt metadata rows found by the last scan pass, per table, for diagnostics
125 self._corrupt_metadata_rows: dict[str, list[dict[str, str | int]]] = {}
126
127 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
128 """Return all Config Entries for this core module (if any)."""
129 return (
130 # deliberately without a default_value: only values differing from the entry default
131 # are persisted, so declaring one would make a chosen DEFAULT_LANGUAGE
132 # indistinguishable from "never chosen". The locale property applies it on read.
133 ConfigEntry(
134 key=CONF_LANGUAGE,
135 type=ConfigEntryType.STRING,
136 required=False,
137 options=[ConfigValueOption(key, title=value) for key, value in LOCALES.items()],
138 ),
139 ConfigEntry(
140 key=CONF_ENABLE_ONLINE_METADATA,
141 type=ConfigEntryType.BOOLEAN,
142 required=False,
143 default_value=True,
144 ),
145 ConfigEntry(
146 key=CONF_PREFER_LOCAL_GENRES,
147 type=ConfigEntryType.BOOLEAN,
148 required=False,
149 default_value=False,
150 ),
151 ConfigEntry(
152 key=CONF_ENABLE_RADIO_METADATA_LOOKUP,
153 type=ConfigEntryType.BOOLEAN,
154 required=False,
155 default_value=True,
156 ),
157 ConfigEntry(
158 key=CONF_THUMB_CACHE_MAX_SIZE,
159 type=ConfigEntryType.INTEGER,
160 required=False,
161 default_value=DEFAULT_THUMB_CACHE_MAX_SIZE_MB,
162 range=(50, 5000),
163 ),
164 )
165
166 async def setup(self, config: CoreConfig) -> None:
167 """Async initialize of module."""
168 self.config = config
169 if not self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
170 # silence PIL logger
171 logging.getLogger("PIL").setLevel(logging.WARNING)
172 # make sure that our directory with collage images exists
173 self._collage_images_dir = os.path.join(self.mass.cache_path, "collage_images")
174 if not await asyncio.to_thread(os.path.exists, self._collage_images_dir):
175 await asyncio.to_thread(os.mkdir, self._collage_images_dir)
176
177 async def post_setup(self) -> None:
178 """Handle logic after all core controllers have been set up."""
179 # canonical opaque-id endpoint, served by both the public webserver
180 # and the streams server (the latter is what player metadata URLs hit)
181 self.mass.streams.register_dynamic_route("/imageproxy/*", self.handle_imageproxy)
182 self.mass.webserver.register_dynamic_route("/imageproxy/*", self.handle_imageproxy)
183 self._register_maintenance_tasks()
184
185 async def close(self) -> None:
186 """Handle logic on server stop."""
187 self.mass.streams.unregister_dynamic_route("/imageproxy/*")
188 self.mass.webserver.unregister_dynamic_route("/imageproxy/*")
189
190 @property
191 def providers(self) -> list[MetadataProvider]:
192 """Return all loaded/running MetadataProviders."""
193 return sorted(
194 cast("list[MetadataProvider]", self.mass.get_providers(ProviderType.METADATA)),
195 key=lambda p: p.priority,
196 )
197
198 @property
199 def preferred_language(self) -> str:
200 """Return preferred language for metadata (as 2 letter language code 'en')."""
201 return self.locale.split("_")[0]
202
203 @property
204 def locale(self) -> str:
205 """Return preferred language for metadata (as full locale code 'en_EN')."""
206 value = self.mass.config.get_raw_core_config_value(
207 self.domain, CONF_LANGUAGE, DEFAULT_LANGUAGE
208 )
209 return str(value)
210
211 @api_command("metadata/set_default_preferred_language", required_scope=Scope.CONFIG_CORE_WRITE)
212 def set_default_preferred_language(self, lang: str) -> None:
213 """
214 Set the default preferred language.
215
216 Reasoning behind this is that the backend can not make a wise choice for the default,
217 so relies on some external source that knows better to set this info, like the frontend
218 or a streaming provider.
219 Can only be set once (by this call or the user).
220 """
221 if self.mass.config.get_raw_core_config_value(self.domain, CONF_LANGUAGE):
222 return # already set
223 self.set_preferred_language(lang)
224
225 @api_command("metadata/set_preferred_language", required_scope=Scope.LIBRARY_MANAGE)
226 def set_preferred_language(self, lang: str) -> None:
227 """
228 Set the preferred language.
229
230 Note that this will not modify any existing metadata,
231 but will be used for future lookups.
232 """
233 # prefer exact match
234 if lang in LOCALES:
235 self.mass.config.set_raw_core_config_value(self.domain, CONF_LANGUAGE, lang)
236 return
237 # try strict matching on either locale code or region
238 lang = lang.lower().replace("-", "_")
239 for locale_code, lang_name in LOCALES.items():
240 if lang in (locale_code.lower(), lang_name.lower()):
241 self.mass.config.set_raw_core_config_value(self.domain, CONF_LANGUAGE, locale_code)
242 return
243 # attempt loose match on language code or region code
244 for lang_part in (lang[:2], lang[:-2]):
245 for locale_code in tuple(LOCALES):
246 language_code, region_code = locale_code.lower().split("_", 1)
247 if lang_part in (language_code, region_code):
248 self.mass.config.set_raw_core_config_value(
249 self.domain, CONF_LANGUAGE, locale_code
250 )
251 return
252 # if we reach this point, we couldn't match the language
253 self.logger.warning("%s is not a valid language", lang)
254
255 @api_command("metadata/update_metadata", required_scope=Scope.LIBRARY_MANAGE)
256 async def update_metadata(
257 self, item: str | MediaItemType, force_refresh: bool = False
258 ) -> MediaItemType:
259 """Get/update extra/enhanced metadata for/on given MediaItem."""
260 async with self.cache.handle_refresh(force_refresh):
261 if isinstance(item, str):
262 retrieved_item = await self.mass.music.get_item_by_uri(item)
263 if isinstance(retrieved_item, BrowseFolder):
264 raise TypeError("Cannot update metadata on a BrowseFolder item.")
265 item = retrieved_item
266
267 if item.provider != "library":
268 # this shouldn't happen but just in case.
269 raise RuntimeError("Metadata can only be updated for library items")
270
271 async with self._throttler:
272 if item.media_type == MediaType.ARTIST:
273 await self._update_artist_metadata(
274 cast("Artist", item), force_refresh=force_refresh
275 )
276 if item.media_type == MediaType.ALBUM:
277 await self._update_album_metadata(cast("Album", item), force_refresh=force_refresh)
278 if item.media_type == MediaType.TRACK:
279 await self._update_track_metadata(cast("Track", item), force_refresh=force_refresh)
280 if item.media_type == MediaType.PLAYLIST:
281 await self._update_playlist_metadata(
282 cast("Playlist", item), force_refresh=force_refresh
283 )
284 if item.media_type == MediaType.AUDIOBOOK:
285 await self._update_audiobook_metadata(
286 cast("Audiobook", item), force_refresh=force_refresh
287 )
288 if item.media_type == MediaType.PODCAST:
289 await self._update_podcast_metadata(
290 cast("Podcast", item), force_refresh=force_refresh
291 )
292 return item
293
294 def schedule_update_metadata(self, item: MediaItemType) -> None:
295 """Schedule metadata update for given MediaItem."""
296 if item.provider != "library":
297 # this shouldn't happen but just in case.
298 return
299 last_refresh = item.metadata.last_refresh or 0
300 needs_update = (time() - last_refresh) > REFRESH_INTERVAL
301 if not needs_update:
302 return
303 assert item.uri is not None
304 task_id = self._get_metadata_lookup_task_id(item.uri)
305 _item = item
306
307 self.mass.tasks.run_background_task(
308 task_id=task_id,
309 name=f"Update metadata for {item.name}",
310 handler=lambda: self.update_metadata(_item),
311 translation_key="update_metadata",
312 translation_args=[item.name],
313 translation_owner=self.translation_owner,
314 metadata={
315 "task_domain": "metadata_lookup",
316 "item_uri": item.uri,
317 },
318 )
319
320 @api_command("metadata/get_track_lyrics", required_scope=Scope.LIBRARY_READ)
321 async def get_track_lyrics(
322 self,
323 track: Track,
324 ) -> tuple[str | None, str | None]:
325 """
326 Get lyrics for given track from metadata providers.
327
328 Returns a tuple of (lyrics, lrc_lyrics) if found.
329 """
330 lyrics, lrc_lyrics = await self._get_track_lyrics(track)
331 # on-demand lookups are not stored in the library db, so normalize on the way out
332 # promoting LRC formatted text stored in the plain lyrics tag
333 return lyrics, normalize_lrc_lyrics(lrc_lyrics or extract_lrc_lyrics(lyrics))
334
335 async def get_diagnostics(self) -> dict[str, SerializableType] | None:
336 """Return diagnostics info for this controller to include in diagnostics reports."""
337 if not self._corrupt_metadata_rows:
338 return None
339 return {"corrupt_metadata_rows": cast("SerializableType", self._corrupt_metadata_rows)}
340
341 async def _get_track_lyrics(
342 self,
343 track: Track,
344 ) -> tuple[str | None, str | None]:
345 """Look up (lyrics, lrc_lyrics) for the given track."""
346 if track.metadata and track.metadata.lyrics:
347 return track.metadata.lyrics, track.metadata.lrc_lyrics
348
349 if track.provider == "library":
350 # try to update metadata first
351 await self._update_track_metadata(track, force_refresh=False)
352 return track.metadata.lyrics, track.metadata.lrc_lyrics
353
354 # prefer lyrics from the track's own provider
355 track_provider = self.mass.get_provider(track.provider, provider_type=MusicProvider)
356 if track_provider and ProviderFeature.LYRICS in track_provider.supported_features:
357 full_track = await self.mass.music.tracks.get_provider_item(
358 track.item_id, track.provider
359 )
360 if full_track.metadata and full_track.metadata.lyrics:
361 return full_track.metadata.lyrics, full_track.metadata.lrc_lyrics
362
363 # fallback to other metadata providers
364 for provider in self.providers:
365 if ProviderFeature.LYRICS not in provider.supported_features:
366 continue
367 try:
368 metadata = await provider.get_track_metadata(track)
369 except Exception as err:
370 # a provider failure must not abort the lookup — skip to the next provider
371 self.logger.warning(
372 "Error fetching lyrics for %s from provider %s: %s",
373 track.name,
374 provider.name,
375 err,
376 exc_info=err if self.logger.isEnabledFor(10) else None,
377 )
378 continue
379 if metadata and (metadata.lyrics or metadata.lrc_lyrics):
380 return metadata.lyrics, metadata.lrc_lyrics
381 return None, None
382
383 def _register_maintenance_tasks(self) -> None:
384 """Register the recurring metadata maintenance background tasks."""
385 # Spread across the full day so instances don't all hit the shared MusicBrainz mirror at once
386 utc_hour, utc_minute = divmod(random.randint(0, 24 * 60 - 1), 60)
387 desired_schedule = TaskSchedule.daily(hour=utc_hour, minute=utc_minute)
388 self.mass.tasks.register_scheduled_task(
389 task_id=MISSING_ARTIST_METADATA_SCAN_TASK_ID,
390 name="Scan missing artist metadata",
391 handler=self._scan_missing_artist_metadata,
392 schedule=desired_schedule,
393 translation_key="scan_missing_artist_metadata",
394 translation_owner=self.translation_owner,
395 metadata={"task_domain": "metadata_missing_artist_metadata_scan"},
396 allow_retry=True,
397 )
398 self.mass.tasks.register_scheduled_task(
399 task_id=PLAYLIST_METADATA_SCAN_TASK_ID,
400 name="Refresh playlist metadata",
401 handler=self._refresh_playlist_metadata_batch,
402 schedule=desired_schedule,
403 translation_key="refresh_playlist_metadata",
404 translation_owner=self.translation_owner,
405 metadata={"task_domain": "metadata_playlist_metadata_scan"},
406 allow_retry=True,
407 )
408 self.mass.tasks.register_scheduled_task(
409 task_id=THUMB_CACHE_CLEANUP_TASK_ID,
410 name="Cleanup thumbnail cache",
411 handler=self._cleanup_thumb_cache,
412 schedule=desired_schedule,
413 translation_key="cleanup_thumbnail_cache",
414 translation_owner=self.translation_owner,
415 metadata={"task_domain": "metadata_thumb_cache_cleanup"},
416 allow_retry=True,
417 )
418 # runs every hour rather than spread across the day: it is bounded to a small
419 # batch of albums per run, so there is no shared-mirror stampede to avoid
420 self.mass.tasks.register_scheduled_task(
421 task_id=ALBUM_RECONCILIATION_TASK_ID,
422 name="Reconcile duplicate albums",
423 handler=self._reconcile_duplicate_albums,
424 schedule=TaskSchedule.hourly(),
425 translation_key="reconcile_duplicate_albums",
426 translation_owner=self.translation_owner,
427 metadata={"task_domain": "metadata_album_reconciliation"},
428 allow_retry=True,
429 )
430
431 @staticmethod
432 def _get_metadata_lookup_task_id(uri: str) -> str:
433 """Return deterministic task id for a metadata lookup."""
434 return f"{METADATA_LOOKUP_TASK_ID_PREFIX}_{uuid5(NAMESPACE_URL, uri).hex}"
435
436 async def _scan_missing_artist_metadata(self) -> None:
437 """Scan for artists with missing metadata."""
438 update_current_task_progress_text("Searching for artists with missing metadata")
439 missing_images = (
440 f"(json_extract({DB_TABLE_ARTISTS}.metadata,'$.images') ISNULL "
441 f"OR json_extract({DB_TABLE_ARTISTS}.metadata,'$.images') = '[]')"
442 )
443 missing_description = f"json_extract({DB_TABLE_ARTISTS}.metadata,'$.description') ISNULL"
444 never_refreshed = f"json_extract({DB_TABLE_ARTISTS}.metadata,'$.last_refresh') ISNULL"
445 query = f"({missing_images} OR {missing_description}) AND {never_refreshed}"
446 artists = await self._get_scan_batch(self.mass.music.artists, DB_TABLE_ARTISTS, query)
447 if not artists:
448 update_current_task_progress_text("No artists with missing metadata found")
449 return
450 for index, artist in enumerate(artists, 1):
451 try:
452 update_current_task_progress_from_index(
453 index,
454 len(artists),
455 f"Refreshing metadata for artist {index}/{len(artists)}: {artist.name}",
456 )
457 await self._update_artist_metadata(artist, force_refresh=False)
458 except Exception as err:
459 report_current_task_failure(f"{artist.name}: {err}")
460 self.logger.warning(
461 "Error while updating artist metadata for %s: %s",
462 artist.name,
463 str(err),
464 exc_info=err if self.logger.isEnabledFor(10) else None,
465 )
466 update_current_task_progress(100, f"Processed {len(artists)} artist(s)")
467
468 async def _refresh_playlist_metadata_batch(self) -> None:
469 """Refresh metadata for a small batch of library playlists."""
470 update_current_task_progress_text("Searching for playlists needing metadata refresh")
471 refresh_before = int(time() - REFRESH_INTERVAL)
472 query = (
473 f"{DB_TABLE_PLAYLISTS}.is_dynamic = 0 AND ("
474 f"json_extract({DB_TABLE_PLAYLISTS}.metadata,'$.last_refresh') ISNULL "
475 f"OR json_extract({DB_TABLE_PLAYLISTS}.metadata,'$.last_refresh') < {refresh_before})"
476 )
477 playlists = await self._get_scan_batch(self.mass.music.playlists, DB_TABLE_PLAYLISTS, query)
478 if not playlists:
479 update_current_task_progress_text("No playlists require metadata refresh")
480 return
481 for index, playlist in enumerate(playlists, 1):
482 try:
483 update_current_task_progress_from_index(
484 index,
485 len(playlists),
486 f"Refreshing playlist metadata {index}/{len(playlists)}: {playlist.name}",
487 )
488 await self._update_playlist_metadata(playlist, force_refresh=False)
489 except Exception as err:
490 report_current_task_failure(f"{playlist.name}: {err}")
491 self.logger.warning(
492 "Error while refreshing playlist metadata for %s: %s",
493 playlist.name,
494 str(err),
495 exc_info=err if self.logger.isEnabledFor(10) else None,
496 )
497 update_current_task_progress(100, f"Processed {len(playlists)} playlist(s)")
498
499 async def _reconcile_duplicate_albums(self) -> None:
500 """Enrich and re-match a small batch of sparse, unenriched albums."""
501 update_current_task_progress_text("Searching for albums needing reconciliation")
502 # albums that are still unknown-typed keep retrying at the normal REFRESH_INTERVAL
503 # cadence (e.g. after a transient provider outage), rather than only ever once
504 refresh_before = int(time() - REFRESH_INTERVAL)
505 query = (
506 f"{DB_TABLE_ALBUMS}.album_type = '{AlbumType.UNKNOWN.value}' AND ("
507 f"json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') ISNULL "
508 f"OR json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') < {refresh_before})"
509 )
510 albums = await self._get_scan_batch(self.mass.music.albums, DB_TABLE_ALBUMS, query)
511 if not albums:
512 update_current_task_progress_text("No albums require reconciliation")
513 return
514 for index, album in enumerate(albums, 1):
515 try:
516 update_current_task_progress_from_index(
517 index,
518 len(albums),
519 f"Reconciling album {index}/{len(albums)}: {album.name}",
520 )
521 # enrich sparse provider data (type/year/metadata) first so the follow-up
522 # match has full album details to work with, then re-fetch the now-enriched
523 # library row before re-matching: match_providers merges a confirmed mapping
524 # into an existing duplicate through the safe add_provider_mappings path
525 await self._update_album_metadata(album, force_refresh=False)
526 reconciled_album = await self.mass.music.albums.get_library_item(album.item_id)
527 await self.mass.music.albums.match_providers(reconciled_album)
528 except (MusicAssistantError, aiohttp.ClientError, TimeoutError) as err:
529 report_current_task_failure(f"{album.name}: {err}")
530 self.logger.warning(
531 "Error while reconciling album %s: %s",
532 album.name,
533 str(err),
534 exc_info=err if self.logger.isEnabledFor(10) else None,
535 )
536 update_current_task_progress(100, f"Processed {len(albums)} album(s)")
537
538 async def _cleanup_thumb_cache(self) -> None:
539 """Remove oldest thumbnails when the cache folder exceeds the configured limit."""
540 max_size_mb = (
541 try_parse_int(
542 self.config.get_value(CONF_THUMB_CACHE_MAX_SIZE), DEFAULT_THUMB_CACHE_MAX_SIZE_MB
543 )
544 or DEFAULT_THUMB_CACHE_MAX_SIZE_MB
545 )
546 removed = await cleanup_thumb_cache(self.mass.cache_path, max_size_mb * 1024 * 1024)
547 if removed:
548 self.logger.debug("Thumbnail cache cleanup: removed %s file(s)", removed)
549
550 async def _get_scan_batch[ItemCls: MediaItemType](
551 self,
552 media_controller: MediaControllerBase[ItemCls],
553 table: str,
554 query: str,
555 ) -> list[ItemCls]:
556 """Fetch a metadata-scan batch, tolerating rows with corrupt metadata JSON."""
557 try:
558 items = await media_controller.get_library_items_by_query(
559 limit=METADATA_SCAN_BATCH_SIZE,
560 order_by="random",
561 extra_query_parts=[query],
562 )
563 except sqlite3.OperationalError as err:
564 if "malformed JSON" not in str(err):
565 raise
566 await self._report_corrupt_metadata_rows(table)
567 return await media_controller.get_library_items_by_query(
568 limit=METADATA_SCAN_BATCH_SIZE,
569 order_by="random",
570 extra_query_parts=[f"{_valid_metadata_guard(table)} AND {query}"],
571 )
572 # a clean scan proves the table currently holds no corrupt rows
573 self._corrupt_metadata_rows.pop(table, None)
574 return items
575
576 async def _report_corrupt_metadata_rows(self, table: str) -> None:
577 """Report library rows whose metadata column holds invalid JSON."""
578 rows = await self.mass.music.database.get_rows_from_query(
579 f"SELECT item_id, name FROM {table} "
580 f"WHERE {table}.metadata IS NOT NULL AND NOT json_valid({table}.metadata)",
581 limit=25,
582 )
583 # keep the findings for the diagnostics report, replacing the previous
584 # pass so repaired rows drop out again
585 if rows:
586 self._corrupt_metadata_rows[table] = [
587 {"item_id": row["item_id"], "name": row["name"]} for row in rows
588 ]
589 else:
590 self._corrupt_metadata_rows.pop(table, None)
591 for row in rows:
592 message = (
593 f"'{row['name']}' has corrupt metadata and was skipped. To repair, remove "
594 f"'{row['name']}' from the library; it will be re-added with fresh metadata "
595 f"on the next library sync ({table} id {row['item_id']})."
596 )
597 report_current_task_failure(message)
598 self.logger.warning(message)
599
600
601def _valid_metadata_guard(table: str) -> str:
602 """Return a query part that excludes rows with invalid JSON in the metadata column."""
603 # sqlite's json functions raise a fatal 'malformed JSON' error on invalid input,
604 # which would fail the entire scan query because of a single corrupt row
605 return f"({table}.metadata IS NULL OR json_valid({table}.metadata))"
606