From 3a393161f784bda5db07d270e8dac33cf004fff1 Mon Sep 17 00:00:00 2001 From: Harvey <64276030+HabiRabbu@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:53:14 +0000 Subject: [PATCH] Allow adding custom musicbrainz api endpoint (#53) * allow adding custom musicbrainz api endpoints * make format --- backend/api/v1/routes/settings.py | 32 +++ backend/api/v1/schemas/advanced_settings.py | 6 - backend/api/v1/schemas/settings.py | 16 ++ backend/infrastructure/http/deduplication.py | 4 + .../infrastructure/resilience/rate_limiter.py | 6 + backend/repositories/musicbrainz_base.py | 13 +- .../repositories/musicbrainz_repository.py | 15 +- backend/services/preferences_service.py | 30 +++ backend/services/settings_service.py | 68 +++++++ .../settings/SettingsMusicBrainz.svelte | 189 ++++++++++++++++++ .../settings/SettingsNetworkBatch.svelte | 7 - .../settings/advanced-settings-types.ts | 1 - frontend/src/lib/constants.ts | 2 + frontend/src/routes/settings/+page.svelte | 7 +- 14 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 frontend/src/lib/components/settings/SettingsMusicBrainz.svelte diff --git a/backend/api/v1/routes/settings.py b/backend/api/v1/routes/settings.py index 692b7d2..5592d2c 100644 --- a/backend/api/v1/routes/settings.py +++ b/backend/api/v1/routes/settings.py @@ -24,6 +24,7 @@ from api.v1.schemas.settings import ( PrimaryMusicSourceSettings, PlexConnectionSettings, PlexVerifyResponse, + MusicBrainzConnectionSettings, ) from api.v1.schemas.plex import PlexLibrarySectionInfo from api.v1.schemas.common import VerifyConnectionResponse @@ -520,3 +521,34 @@ async def update_primary_music_source( except ConfigurationError as e: logger.warning("Configuration error updating primary music source: %s", e) raise HTTPException(status_code=400, detail="Invalid primary music source") + + +@router.get("/musicbrainz", response_model=MusicBrainzConnectionSettings) +async def get_musicbrainz_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_musicbrainz_connection() + + +@router.put("/musicbrainz", response_model=MusicBrainzConnectionSettings) +async def update_musicbrainz_settings( + settings: MusicBrainzConnectionSettings = MsgSpecBody(MusicBrainzConnectionSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_musicbrainz_connection(settings) + await settings_service.on_musicbrainz_settings_changed(settings) + return settings + except ConfigurationError as e: + logger.warning(f"Configuration error updating MusicBrainz settings: {e}") + raise HTTPException(status_code=400, detail="MusicBrainz settings are incomplete or invalid") + + +@router.post("/musicbrainz/verify", response_model=VerifyConnectionResponse) +async def verify_musicbrainz_connection( + settings: MusicBrainzConnectionSettings = MsgSpecBody(MusicBrainzConnectionSettings), + settings_service: SettingsService = Depends(get_settings_service), +): + result = await settings_service.verify_musicbrainz(settings) + return VerifyConnectionResponse(valid=result.valid, message=result.message) diff --git a/backend/api/v1/schemas/advanced_settings.py b/backend/api/v1/schemas/advanced_settings.py index 7c5ba4a..a02da92 100644 --- a/backend/api/v1/schemas/advanced_settings.py +++ b/backend/api/v1/schemas/advanced_settings.py @@ -76,7 +76,6 @@ class AdvancedSettings(AppStruct): recent_metadata_max_size_mb: int = 500 recent_covers_max_size_mb: int = 1024 persistent_metadata_ttl_hours: int = 24 - musicbrainz_concurrent_searches: int = 6 discover_queue_size: int = 10 discover_queue_ttl: int = 86400 discover_queue_auto_generate: bool = True @@ -163,7 +162,6 @@ class AdvancedSettings(AppStruct): "recent_metadata_max_size_mb": (100, 5000), "recent_covers_max_size_mb": (100, 10000), "persistent_metadata_ttl_hours": (1, 168), - "musicbrainz_concurrent_searches": (2, 10), "artist_discovery_precache_concurrency": (1, 8), "sync_stall_timeout_minutes": (2, 30), "sync_max_timeout_hours": (1, 48), @@ -261,7 +259,6 @@ class AdvancedSettingsFrontend(AppStruct): recent_metadata_max_size_mb: int = 500 recent_covers_max_size_mb: int = 1024 persistent_metadata_ttl_hours: int = 24 - musicbrainz_concurrent_searches: int = 6 discover_queue_size: int = 10 discover_queue_ttl: int = 24 discover_queue_auto_generate: bool = True @@ -385,7 +382,6 @@ class AdvancedSettingsFrontend(AppStruct): "recent_metadata_max_size_mb": (100, 5000), "recent_covers_max_size_mb": (100, 10000), "persistent_metadata_ttl_hours": (1, 168), - "musicbrainz_concurrent_searches": (2, 10), "discover_queue_size": (1, 20), "discover_queue_ttl": (1, 168), "discover_queue_polling_interval": (1, 30), @@ -469,7 +465,6 @@ class AdvancedSettingsFrontend(AppStruct): recent_metadata_max_size_mb=settings.recent_metadata_max_size_mb, recent_covers_max_size_mb=settings.recent_covers_max_size_mb, persistent_metadata_ttl_hours=settings.persistent_metadata_ttl_hours, - musicbrainz_concurrent_searches=settings.musicbrainz_concurrent_searches, discover_queue_size=settings.discover_queue_size, discover_queue_ttl=settings.discover_queue_ttl // 3600, discover_queue_auto_generate=settings.discover_queue_auto_generate, @@ -556,7 +551,6 @@ class AdvancedSettingsFrontend(AppStruct): recent_metadata_max_size_mb=self.recent_metadata_max_size_mb, recent_covers_max_size_mb=self.recent_covers_max_size_mb, persistent_metadata_ttl_hours=self.persistent_metadata_ttl_hours, - musicbrainz_concurrent_searches=self.musicbrainz_concurrent_searches, discover_queue_size=self.discover_queue_size, discover_queue_ttl=self.discover_queue_ttl * 3600, discover_queue_auto_generate=self.discover_queue_auto_generate, diff --git a/backend/api/v1/schemas/settings.py b/backend/api/v1/schemas/settings.py index d7b88c2..6fd066c 100644 --- a/backend/api/v1/schemas/settings.py +++ b/backend/api/v1/schemas/settings.py @@ -227,6 +227,22 @@ class PrimaryMusicSourceSettings(AppStruct): source: Literal["listenbrainz", "lastfm"] = "listenbrainz" +class MusicBrainzConnectionSettings(AppStruct): + api_url: str = "https://musicbrainz.org/ws/2" + rate_limit: float = 1.0 + concurrent_searches: int = 6 + + def __post_init__(self) -> None: + self.api_url = self.api_url.strip() + if not self.api_url or not self.api_url.startswith(("http://", "https://")): + self.api_url = "https://musicbrainz.org/ws/2" + self.api_url = self.api_url.rstrip("/") + if self.rate_limit < 0.1 or self.rate_limit > 50.0: + raise msgspec.ValidationError("rate_limit must be between 0.1 and 50.0") + if self.concurrent_searches < 1 or self.concurrent_searches > 30: + raise msgspec.ValidationError("concurrent_searches must be between 1 and 30") + + class LidarrMetadataProfilePreferences(AppStruct): profile_id: int profile_name: str diff --git a/backend/infrastructure/http/deduplication.py b/backend/infrastructure/http/deduplication.py index 7d68c9d..74341e4 100644 --- a/backend/infrastructure/http/deduplication.py +++ b/backend/infrastructure/http/deduplication.py @@ -27,6 +27,10 @@ class RequestDeduplicator: self._pending: dict[str, asyncio.Future[Any]] = {} self._lock = asyncio.Lock() + def clear(self) -> None: + """Clear all pending deduplication entries (e.g. after endpoint change).""" + self._pending.clear() + async def dedupe( self, key: str, diff --git a/backend/infrastructure/resilience/rate_limiter.py b/backend/infrastructure/resilience/rate_limiter.py index 3ae3484..f755127 100644 --- a/backend/infrastructure/resilience/rate_limiter.py +++ b/backend/infrastructure/resilience/rate_limiter.py @@ -75,3 +75,9 @@ class TokenBucketRateLimiter: def update_capacity(self, new_capacity: int) -> None: self.capacity = new_capacity self._tokens = min(self._tokens, float(new_capacity)) + + def update_rate(self, new_rate: float) -> None: + """Update the token refill rate (tokens/sec).""" + if new_rate <= 0: + raise ValueError(f"Rate must be positive, got {new_rate}") + self.rate = new_rate diff --git a/backend/repositories/musicbrainz_base.py b/backend/repositories/musicbrainz_base.py index 31b92cd..994f84a 100644 --- a/backend/repositories/musicbrainz_base.py +++ b/backend/repositories/musicbrainz_base.py @@ -9,7 +9,16 @@ from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter from infrastructure.queue.priority_queue import RequestPriority, get_priority_queue from infrastructure.http.deduplication import RequestDeduplicator -MB_API_BASE = "https://musicbrainz.org/ws/2" +_mb_api_base: str = "https://musicbrainz.org/ws/2" + + +def get_mb_api_base() -> str: + return _mb_api_base + + +def set_mb_api_base(url: str) -> None: + global _mb_api_base + _mb_api_base = url.rstrip("/") mb_circuit_breaker = CircuitBreaker( failure_threshold=5, @@ -67,7 +76,7 @@ async def mb_api_get( async with semaphore: await mb_rate_limiter.acquire() client = get_mb_http_client() - url = f"{MB_API_BASE}{path}" + url = f"{get_mb_api_base()}{path}" request_params = dict(params) if params else {} request_params["fmt"] = "json" response = await client.get(url, params=request_params) diff --git a/backend/repositories/musicbrainz_repository.py b/backend/repositories/musicbrainz_repository.py index 0e04ae8..b5d1cbc 100644 --- a/backend/repositories/musicbrainz_repository.py +++ b/backend/repositories/musicbrainz_repository.py @@ -7,7 +7,7 @@ import httpx from models.search import SearchResult from services.preferences_service import PreferencesService from infrastructure.cache.memory_cache import CacheInterface -from repositories.musicbrainz_base import mb_rate_limiter, set_mb_http_client +from repositories.musicbrainz_base import mb_rate_limiter, set_mb_http_client, set_mb_api_base from repositories.musicbrainz_artist import MusicBrainzArtistMixin from repositories.musicbrainz_album import MusicBrainzAlbumMixin @@ -19,6 +19,14 @@ class MusicBrainzRepository(MusicBrainzArtistMixin, MusicBrainzAlbumMixin): self._cache = cache self._preferences_service = preferences_service set_mb_http_client(http_client) + self._apply_settings() + + def _apply_settings(self) -> None: + settings = self._preferences_service.get_musicbrainz_connection() + set_mb_api_base(settings.api_url) + mb_rate_limiter.update_rate(settings.rate_limit) + if mb_rate_limiter.capacity != settings.concurrent_searches: + mb_rate_limiter.update_capacity(settings.concurrent_searches) async def search_grouped( self, @@ -27,11 +35,6 @@ class MusicBrainzRepository(MusicBrainzArtistMixin, MusicBrainzAlbumMixin): buckets: Optional[list[str]] = None, included_secondary_types: Optional[set[str]] = None ) -> dict[str, list[SearchResult]]: - advanced_settings = self._preferences_service.get_advanced_settings() - new_capacity = advanced_settings.musicbrainz_concurrent_searches - if mb_rate_limiter.capacity != new_capacity: - mb_rate_limiter.update_capacity(new_capacity) - tasks = [] task_keys = [] diff --git a/backend/services/preferences_service.py b/backend/services/preferences_service.py index e46bd43..4aaccc2 100644 --- a/backend/services/preferences_service.py +++ b/backend/services/preferences_service.py @@ -21,6 +21,7 @@ from api.v1.schemas.settings import ( NAVIDROME_PASSWORD_MASK, PlexConnectionSettings, PLEX_TOKEN_MASK, + MusicBrainzConnectionSettings, ) from api.v1.schemas.profile import ProfileSettings from api.v1.schemas.advanced_settings import AdvancedSettings @@ -40,6 +41,7 @@ class PreferencesService: self._config_path = settings.config_file_path self._config_cache: Optional[dict] = None self._cache_lock = threading.Lock() + self._migrate_musicbrainz_settings() def _load_config(self) -> dict: with self._cache_lock: @@ -436,3 +438,31 @@ class PreferencesService: config["_internal"] = internal self._save_config(config) return value + + def get_musicbrainz_connection(self) -> MusicBrainzConnectionSettings: + return self._get_section("musicbrainz_settings", MusicBrainzConnectionSettings) + + def save_musicbrainz_connection(self, settings: MusicBrainzConnectionSettings) -> None: + try: + settings.api_url = settings.api_url.rstrip("/") + self._save_section("musicbrainz_settings", settings) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save MusicBrainz settings: {e}") + raise ConfigurationError(f"Failed to save MusicBrainz settings: {e}") + + def _migrate_musicbrainz_settings(self) -> None: + """One-time migration of musicbrainz_concurrent_searches from advanced_settings.""" + try: + config = self._load_config() + if config.get("musicbrainz_settings") is not None: + return + + advanced_data = config.get("advanced_settings", {}) + old_value = advanced_data.get("musicbrainz_concurrent_searches") + if old_value is not None: + settings = MusicBrainzConnectionSettings(concurrent_searches=int(old_value)) + self._save_section("musicbrainz_settings", settings) + logger.info(f"Migrated musicbrainz_concurrent_searches={old_value} to musicbrainz_settings") + except Exception: # noqa: BLE001 + logger.warning("Failed to migrate musicbrainz_concurrent_searches, using defaults") + self._save_section("musicbrainz_settings", MusicBrainzConnectionSettings()) diff --git a/backend/services/settings_service.py b/backend/services/settings_service.py index 0f3aec9..bbadfc0 100644 --- a/backend/services/settings_service.py +++ b/backend/services/settings_service.py @@ -18,6 +18,7 @@ from api.v1.schemas.settings import ( LASTFM_SECRET_MASK, PlexConnectionSettings, PLEX_TOKEN_MASK, + MusicBrainzConnectionSettings, ) from core.config import Settings, get_settings from core.exceptions import ExternalServiceError @@ -72,6 +73,11 @@ class LastFmVerifyResult(msgspec.Struct): message: str +class MusicBrainzVerifyResult(msgspec.Struct): + valid: bool + message: str + + class SettingsService: def __init__(self, preferences_service, cache: CacheInterface): self._preferences_service = preferences_service @@ -725,3 +731,65 @@ class SettingsService: temp_repo.configure(url=raw.plex_url, token=raw.plex_token, client_id=client_id) sections = await temp_repo.get_music_libraries() return [(s.key, s.title) for s in sections] + + async def verify_musicbrainz( + self, settings: MusicBrainzConnectionSettings + ) -> MusicBrainzVerifyResult: + try: + import httpx + from infrastructure.validators import validate_service_url + from core.exceptions import ValidationError as AppValidationError + from repositories.musicbrainz_base import mb_circuit_breaker + + validate_service_url(settings.api_url, label="MusicBrainz API URL") + mb_circuit_breaker.reset() + + app_settings = get_settings() + client = get_http_client(app_settings) + response = await client.get( + f"{settings.api_url.rstrip('/')}/artist", + params={"query": "test", "fmt": "json", "limit": 1}, + ) + if response.status_code == 200: + return MusicBrainzVerifyResult( + valid=True, message="Connected to MusicBrainz" + ) + if response.status_code == 503: + return MusicBrainzVerifyResult( + valid=True, + message="Connected, but rate-limited. Try lowering your rate limit.", + ) + return MusicBrainzVerifyResult( + valid=False, + message=f"Unexpected response: HTTP {response.status_code}", + ) + except AppValidationError as e: + return MusicBrainzVerifyResult(valid=False, message=str(e)) + except httpx.ConnectError: + return MusicBrainzVerifyResult( + valid=False, message="Could not connect to the specified endpoint" + ) + except Exception as e: # noqa: BLE001 + logger.exception("Failed to verify MusicBrainz connection: %s", e) + return MusicBrainzVerifyResult( + valid=False, message="Couldn't finish the connection test" + ) + + async def on_musicbrainz_settings_changed( + self, settings: MusicBrainzConnectionSettings + ) -> None: + from repositories.musicbrainz_base import ( + set_mb_api_base, mb_rate_limiter, mb_circuit_breaker, mb_deduplicator, + ) + + set_mb_api_base(settings.api_url) + mb_rate_limiter.update_rate(settings.rate_limit) + mb_rate_limiter.update_capacity(settings.concurrent_searches) + mb_circuit_breaker.reset() + mb_deduplicator.clear() + + total = 0 + for prefix in musicbrainz_prefixes(): + total += await self._cache.clear_prefix(prefix) + if total: + logger.info(f"Cleared {total} MusicBrainz cache entries after settings change") diff --git a/frontend/src/lib/components/settings/SettingsMusicBrainz.svelte b/frontend/src/lib/components/settings/SettingsMusicBrainz.svelte new file mode 100644 index 0000000..b509e78 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsMusicBrainz.svelte @@ -0,0 +1,189 @@ + + +
+ Configure the MusicBrainz API endpoint and rate limiting. Defaults work for the public API. + Change these only if you run a self-hosted MusicBrainz instance. +
+ + {#if form.loading} ++ The full URL to the MusicBrainz API, including the version path. For self-hosted + instances, use your server's API URL (e.g. https://my-mb-server.example.org/ws/2). +
++ Maximum sustained requests per second. The official MusicBrainz limit is ~1 req/sec. + Self-hosted instances may support higher rates. +
++ Burst capacity for parallel API requests (default: 6). +
+