Allow adding custom musicbrainz api endpoint (#53)
* allow adding custom musicbrainz api endpoints * make format
This commit is contained in:
@@ -24,6 +24,7 @@ from api.v1.schemas.settings import (
|
|||||||
PrimaryMusicSourceSettings,
|
PrimaryMusicSourceSettings,
|
||||||
PlexConnectionSettings,
|
PlexConnectionSettings,
|
||||||
PlexVerifyResponse,
|
PlexVerifyResponse,
|
||||||
|
MusicBrainzConnectionSettings,
|
||||||
)
|
)
|
||||||
from api.v1.schemas.plex import PlexLibrarySectionInfo
|
from api.v1.schemas.plex import PlexLibrarySectionInfo
|
||||||
from api.v1.schemas.common import VerifyConnectionResponse
|
from api.v1.schemas.common import VerifyConnectionResponse
|
||||||
@@ -520,3 +521,34 @@ async def update_primary_music_source(
|
|||||||
except ConfigurationError as e:
|
except ConfigurationError as e:
|
||||||
logger.warning("Configuration error updating primary music source: %s", e)
|
logger.warning("Configuration error updating primary music source: %s", e)
|
||||||
raise HTTPException(status_code=400, detail="Invalid primary music source")
|
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)
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ class AdvancedSettings(AppStruct):
|
|||||||
recent_metadata_max_size_mb: int = 500
|
recent_metadata_max_size_mb: int = 500
|
||||||
recent_covers_max_size_mb: int = 1024
|
recent_covers_max_size_mb: int = 1024
|
||||||
persistent_metadata_ttl_hours: int = 24
|
persistent_metadata_ttl_hours: int = 24
|
||||||
musicbrainz_concurrent_searches: int = 6
|
|
||||||
discover_queue_size: int = 10
|
discover_queue_size: int = 10
|
||||||
discover_queue_ttl: int = 86400
|
discover_queue_ttl: int = 86400
|
||||||
discover_queue_auto_generate: bool = True
|
discover_queue_auto_generate: bool = True
|
||||||
@@ -163,7 +162,6 @@ class AdvancedSettings(AppStruct):
|
|||||||
"recent_metadata_max_size_mb": (100, 5000),
|
"recent_metadata_max_size_mb": (100, 5000),
|
||||||
"recent_covers_max_size_mb": (100, 10000),
|
"recent_covers_max_size_mb": (100, 10000),
|
||||||
"persistent_metadata_ttl_hours": (1, 168),
|
"persistent_metadata_ttl_hours": (1, 168),
|
||||||
"musicbrainz_concurrent_searches": (2, 10),
|
|
||||||
"artist_discovery_precache_concurrency": (1, 8),
|
"artist_discovery_precache_concurrency": (1, 8),
|
||||||
"sync_stall_timeout_minutes": (2, 30),
|
"sync_stall_timeout_minutes": (2, 30),
|
||||||
"sync_max_timeout_hours": (1, 48),
|
"sync_max_timeout_hours": (1, 48),
|
||||||
@@ -261,7 +259,6 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||||||
recent_metadata_max_size_mb: int = 500
|
recent_metadata_max_size_mb: int = 500
|
||||||
recent_covers_max_size_mb: int = 1024
|
recent_covers_max_size_mb: int = 1024
|
||||||
persistent_metadata_ttl_hours: int = 24
|
persistent_metadata_ttl_hours: int = 24
|
||||||
musicbrainz_concurrent_searches: int = 6
|
|
||||||
discover_queue_size: int = 10
|
discover_queue_size: int = 10
|
||||||
discover_queue_ttl: int = 24
|
discover_queue_ttl: int = 24
|
||||||
discover_queue_auto_generate: bool = True
|
discover_queue_auto_generate: bool = True
|
||||||
@@ -385,7 +382,6 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||||||
"recent_metadata_max_size_mb": (100, 5000),
|
"recent_metadata_max_size_mb": (100, 5000),
|
||||||
"recent_covers_max_size_mb": (100, 10000),
|
"recent_covers_max_size_mb": (100, 10000),
|
||||||
"persistent_metadata_ttl_hours": (1, 168),
|
"persistent_metadata_ttl_hours": (1, 168),
|
||||||
"musicbrainz_concurrent_searches": (2, 10),
|
|
||||||
"discover_queue_size": (1, 20),
|
"discover_queue_size": (1, 20),
|
||||||
"discover_queue_ttl": (1, 168),
|
"discover_queue_ttl": (1, 168),
|
||||||
"discover_queue_polling_interval": (1, 30),
|
"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_metadata_max_size_mb=settings.recent_metadata_max_size_mb,
|
||||||
recent_covers_max_size_mb=settings.recent_covers_max_size_mb,
|
recent_covers_max_size_mb=settings.recent_covers_max_size_mb,
|
||||||
persistent_metadata_ttl_hours=settings.persistent_metadata_ttl_hours,
|
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_size=settings.discover_queue_size,
|
||||||
discover_queue_ttl=settings.discover_queue_ttl // 3600,
|
discover_queue_ttl=settings.discover_queue_ttl // 3600,
|
||||||
discover_queue_auto_generate=settings.discover_queue_auto_generate,
|
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_metadata_max_size_mb=self.recent_metadata_max_size_mb,
|
||||||
recent_covers_max_size_mb=self.recent_covers_max_size_mb,
|
recent_covers_max_size_mb=self.recent_covers_max_size_mb,
|
||||||
persistent_metadata_ttl_hours=self.persistent_metadata_ttl_hours,
|
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_size=self.discover_queue_size,
|
||||||
discover_queue_ttl=self.discover_queue_ttl * 3600,
|
discover_queue_ttl=self.discover_queue_ttl * 3600,
|
||||||
discover_queue_auto_generate=self.discover_queue_auto_generate,
|
discover_queue_auto_generate=self.discover_queue_auto_generate,
|
||||||
|
|||||||
@@ -227,6 +227,22 @@ class PrimaryMusicSourceSettings(AppStruct):
|
|||||||
source: Literal["listenbrainz", "lastfm"] = "listenbrainz"
|
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):
|
class LidarrMetadataProfilePreferences(AppStruct):
|
||||||
profile_id: int
|
profile_id: int
|
||||||
profile_name: str
|
profile_name: str
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class RequestDeduplicator:
|
|||||||
self._pending: dict[str, asyncio.Future[Any]] = {}
|
self._pending: dict[str, asyncio.Future[Any]] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all pending deduplication entries (e.g. after endpoint change)."""
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
async def dedupe(
|
async def dedupe(
|
||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
|
|||||||
@@ -75,3 +75,9 @@ class TokenBucketRateLimiter:
|
|||||||
def update_capacity(self, new_capacity: int) -> None:
|
def update_capacity(self, new_capacity: int) -> None:
|
||||||
self.capacity = new_capacity
|
self.capacity = new_capacity
|
||||||
self._tokens = min(self._tokens, float(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
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter
|
|||||||
from infrastructure.queue.priority_queue import RequestPriority, get_priority_queue
|
from infrastructure.queue.priority_queue import RequestPriority, get_priority_queue
|
||||||
from infrastructure.http.deduplication import RequestDeduplicator
|
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(
|
mb_circuit_breaker = CircuitBreaker(
|
||||||
failure_threshold=5,
|
failure_threshold=5,
|
||||||
@@ -67,7 +76,7 @@ async def mb_api_get(
|
|||||||
async with semaphore:
|
async with semaphore:
|
||||||
await mb_rate_limiter.acquire()
|
await mb_rate_limiter.acquire()
|
||||||
client = get_mb_http_client()
|
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 = dict(params) if params else {}
|
||||||
request_params["fmt"] = "json"
|
request_params["fmt"] = "json"
|
||||||
response = await client.get(url, params=request_params)
|
response = await client.get(url, params=request_params)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import httpx
|
|||||||
from models.search import SearchResult
|
from models.search import SearchResult
|
||||||
from services.preferences_service import PreferencesService
|
from services.preferences_service import PreferencesService
|
||||||
from infrastructure.cache.memory_cache import CacheInterface
|
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_artist import MusicBrainzArtistMixin
|
||||||
from repositories.musicbrainz_album import MusicBrainzAlbumMixin
|
from repositories.musicbrainz_album import MusicBrainzAlbumMixin
|
||||||
|
|
||||||
@@ -19,6 +19,14 @@ class MusicBrainzRepository(MusicBrainzArtistMixin, MusicBrainzAlbumMixin):
|
|||||||
self._cache = cache
|
self._cache = cache
|
||||||
self._preferences_service = preferences_service
|
self._preferences_service = preferences_service
|
||||||
set_mb_http_client(http_client)
|
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(
|
async def search_grouped(
|
||||||
self,
|
self,
|
||||||
@@ -27,11 +35,6 @@ class MusicBrainzRepository(MusicBrainzArtistMixin, MusicBrainzAlbumMixin):
|
|||||||
buckets: Optional[list[str]] = None,
|
buckets: Optional[list[str]] = None,
|
||||||
included_secondary_types: Optional[set[str]] = None
|
included_secondary_types: Optional[set[str]] = None
|
||||||
) -> dict[str, list[SearchResult]]:
|
) -> 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 = []
|
tasks = []
|
||||||
task_keys = []
|
task_keys = []
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from api.v1.schemas.settings import (
|
|||||||
NAVIDROME_PASSWORD_MASK,
|
NAVIDROME_PASSWORD_MASK,
|
||||||
PlexConnectionSettings,
|
PlexConnectionSettings,
|
||||||
PLEX_TOKEN_MASK,
|
PLEX_TOKEN_MASK,
|
||||||
|
MusicBrainzConnectionSettings,
|
||||||
)
|
)
|
||||||
from api.v1.schemas.profile import ProfileSettings
|
from api.v1.schemas.profile import ProfileSettings
|
||||||
from api.v1.schemas.advanced_settings import AdvancedSettings
|
from api.v1.schemas.advanced_settings import AdvancedSettings
|
||||||
@@ -40,6 +41,7 @@ class PreferencesService:
|
|||||||
self._config_path = settings.config_file_path
|
self._config_path = settings.config_file_path
|
||||||
self._config_cache: Optional[dict] = None
|
self._config_cache: Optional[dict] = None
|
||||||
self._cache_lock = threading.Lock()
|
self._cache_lock = threading.Lock()
|
||||||
|
self._migrate_musicbrainz_settings()
|
||||||
|
|
||||||
def _load_config(self) -> dict:
|
def _load_config(self) -> dict:
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
@@ -436,3 +438,31 @@ class PreferencesService:
|
|||||||
config["_internal"] = internal
|
config["_internal"] = internal
|
||||||
self._save_config(config)
|
self._save_config(config)
|
||||||
return value
|
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())
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from api.v1.schemas.settings import (
|
|||||||
LASTFM_SECRET_MASK,
|
LASTFM_SECRET_MASK,
|
||||||
PlexConnectionSettings,
|
PlexConnectionSettings,
|
||||||
PLEX_TOKEN_MASK,
|
PLEX_TOKEN_MASK,
|
||||||
|
MusicBrainzConnectionSettings,
|
||||||
)
|
)
|
||||||
from core.config import Settings, get_settings
|
from core.config import Settings, get_settings
|
||||||
from core.exceptions import ExternalServiceError
|
from core.exceptions import ExternalServiceError
|
||||||
@@ -72,6 +73,11 @@ class LastFmVerifyResult(msgspec.Struct):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MusicBrainzVerifyResult(msgspec.Struct):
|
||||||
|
valid: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class SettingsService:
|
class SettingsService:
|
||||||
def __init__(self, preferences_service, cache: CacheInterface):
|
def __init__(self, preferences_service, cache: CacheInterface):
|
||||||
self._preferences_service = preferences_service
|
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)
|
temp_repo.configure(url=raw.plex_url, token=raw.plex_token, client_id=client_id)
|
||||||
sections = await temp_repo.get_music_libraries()
|
sections = await temp_repo.get_music_libraries()
|
||||||
return [(s.key, s.title) for s in sections]
|
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")
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { API } from '$lib/constants';
|
||||||
|
import { createSettingsForm } from '$lib/utils/settingsForm.svelte';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
type MusicBrainzConnectionSettings = {
|
||||||
|
api_url: string;
|
||||||
|
rate_limit: number;
|
||||||
|
concurrent_searches: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MusicBrainzTestResult = { valid: boolean; message: string };
|
||||||
|
type MusicBrainzSettingsForm = ReturnType<
|
||||||
|
typeof createSettingsForm<MusicBrainzConnectionSettings>
|
||||||
|
> & {
|
||||||
|
testResult: MusicBrainzTestResult | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = createSettingsForm<MusicBrainzConnectionSettings>({
|
||||||
|
loadEndpoint: API.settingsMusicbrainz(),
|
||||||
|
saveEndpoint: API.settingsMusicbrainz(),
|
||||||
|
testEndpoint: API.settingsMusicbrainzVerify(),
|
||||||
|
defaultValue: {
|
||||||
|
api_url: 'https://musicbrainz.org/ws/2',
|
||||||
|
rate_limit: 1.0,
|
||||||
|
concurrent_searches: 6
|
||||||
|
}
|
||||||
|
}) as MusicBrainzSettingsForm;
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
await form.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await form.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
await form.test();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToDefaults() {
|
||||||
|
if (form.data) {
|
||||||
|
form.data.api_url = 'https://musicbrainz.org/ws/2';
|
||||||
|
form.data.rate_limit = 1.0;
|
||||||
|
form.data.concurrent_searches = 6;
|
||||||
|
form.testResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasPassedTest = $derived(
|
||||||
|
form.testResult != null && (form.testResult as MusicBrainzTestResult).valid === true
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
form.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => form.cleanup());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">MusicBrainz</h2>
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if form.loading}
|
||||||
|
<div class="flex justify-center items-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if form.data}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="mb-api-url">
|
||||||
|
<span class="label-text">API Endpoint URL</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mb-api-url"
|
||||||
|
type="text"
|
||||||
|
bind:value={form.data.api_url}
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="https://musicbrainz.org/ws/2"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-base-content/50 mt-1 ml-1">
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="mb-rate-limit">
|
||||||
|
<span class="label-text">Rate Limit (requests/sec)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mb-rate-limit"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="50"
|
||||||
|
step="0.1"
|
||||||
|
bind:value={form.data.rate_limit}
|
||||||
|
class="input w-full"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-base-content/50 mt-1 ml-1">
|
||||||
|
Maximum sustained requests per second. The official MusicBrainz limit is ~1 req/sec.
|
||||||
|
Self-hosted instances may support higher rates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="mb-concurrent">
|
||||||
|
<span class="label-text">Concurrent Searches</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mb-concurrent"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
bind:value={form.data.concurrent_searches}
|
||||||
|
class="input w-full"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-base-content/50 mt-1 ml-1">
|
||||||
|
Burst capacity for parallel API requests (default: 6).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form.testResult}
|
||||||
|
<div
|
||||||
|
class="alert"
|
||||||
|
class:alert-success={form.testResult.valid}
|
||||||
|
class:alert-error={!form.testResult.valid}
|
||||||
|
>
|
||||||
|
<span>{form.testResult.message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if form.message}
|
||||||
|
<div
|
||||||
|
class="alert"
|
||||||
|
class:alert-success={form.messageType === 'success'}
|
||||||
|
class:alert-error={form.messageType === 'error'}
|
||||||
|
>
|
||||||
|
<span>{form.message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center pt-2">
|
||||||
|
<button type="button" class="btn btn-outline btn-error btn-sm" onclick={resetToDefaults}>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
onclick={test}
|
||||||
|
disabled={form.testing || !form.data.api_url}
|
||||||
|
>
|
||||||
|
{#if form.testing}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="tooltip"
|
||||||
|
class:tooltip-left={!hasPassedTest}
|
||||||
|
data-tip={!hasPassedTest ? 'Test connection before saving' : ''}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={save}
|
||||||
|
disabled={form.saving || !hasPassedTest}
|
||||||
|
>
|
||||||
|
{#if form.saving}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -93,13 +93,6 @@
|
|||||||
step={0.1}
|
step={0.1}
|
||||||
unit="sec"
|
unit="sec"
|
||||||
/>
|
/>
|
||||||
<SettingsNumberField
|
|
||||||
label="MusicBrainz Searches"
|
|
||||||
description="Parallel API requests (default: 3)"
|
|
||||||
bind:value={data.musicbrainz_concurrent_searches}
|
|
||||||
min={2}
|
|
||||||
max={5}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="divider my-4"></div>
|
<div class="divider my-4"></div>
|
||||||
<h4 class="font-medium text-sm text-base-content/70 mb-3">Library Sync</h4>
|
<h4 class="font-medium text-sm text-base-content/70 mb-3">Library Sync</h4>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export interface AdvancedSettingsForm {
|
|||||||
recent_metadata_max_size_mb: number;
|
recent_metadata_max_size_mb: number;
|
||||||
recent_covers_max_size_mb: number;
|
recent_covers_max_size_mb: number;
|
||||||
persistent_metadata_ttl_hours: number;
|
persistent_metadata_ttl_hours: number;
|
||||||
musicbrainz_concurrent_searches: number;
|
|
||||||
discover_queue_size: number;
|
discover_queue_size: number;
|
||||||
discover_queue_ttl: number;
|
discover_queue_ttl: number;
|
||||||
discover_queue_auto_generate: boolean;
|
discover_queue_auto_generate: boolean;
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ export const API = {
|
|||||||
plexAuthPoll: (pinId: number) => `/api/v1/plex/auth/poll?pin_id=${pinId}`,
|
plexAuthPoll: (pinId: number) => `/api/v1/plex/auth/poll?pin_id=${pinId}`,
|
||||||
settingsLocalFiles: () => '/api/v1/settings/local-files',
|
settingsLocalFiles: () => '/api/v1/settings/local-files',
|
||||||
settingsLocalFilesVerify: () => '/api/v1/settings/local-files/verify',
|
settingsLocalFilesVerify: () => '/api/v1/settings/local-files/verify',
|
||||||
|
settingsMusicbrainz: () => '/api/v1/settings/musicbrainz',
|
||||||
|
settingsMusicbrainzVerify: () => '/api/v1/settings/musicbrainz/verify',
|
||||||
profile: {
|
profile: {
|
||||||
get: () => '/api/v1/profile',
|
get: () => '/api/v1/profile',
|
||||||
update: () => '/api/v1/profile',
|
update: () => '/api/v1/profile',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import SettingsScrobbling from '$lib/components/settings/SettingsScrobbling.svelte';
|
import SettingsScrobbling from '$lib/components/settings/SettingsScrobbling.svelte';
|
||||||
import SettingsMusicSource from '$lib/components/settings/SettingsMusicSource.svelte';
|
import SettingsMusicSource from '$lib/components/settings/SettingsMusicSource.svelte';
|
||||||
import SettingsAdvanced from '$lib/components/settings/SettingsAdvanced.svelte';
|
import SettingsAdvanced from '$lib/components/settings/SettingsAdvanced.svelte';
|
||||||
|
import SettingsMusicBrainz from '$lib/components/settings/SettingsMusicBrainz.svelte';
|
||||||
import SettingsAbout from '$lib/components/settings/SettingsAbout.svelte';
|
import SettingsAbout from '$lib/components/settings/SettingsAbout.svelte';
|
||||||
import { getUpdateCheckQuery } from '$lib/queries/VersionQuery.svelte';
|
import { getUpdateCheckQuery } from '$lib/queries/VersionQuery.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Info,
|
Info,
|
||||||
ArrowUpCircle
|
ArrowUpCircle,
|
||||||
|
Globe
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
|
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
|
||||||
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
|
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
{ id: 'youtube', label: 'YouTube', group: 'Library & Sources', icon: Youtube },
|
{ id: 'youtube', label: 'YouTube', group: 'Library & Sources', icon: Youtube },
|
||||||
{ id: 'local-files', label: 'Local Files', group: 'Library & Sources', icon: Headphones },
|
{ id: 'local-files', label: 'Local Files', group: 'Library & Sources', icon: Headphones },
|
||||||
{ id: 'cache', label: 'Cache', group: 'System', icon: Database },
|
{ id: 'cache', label: 'Cache', group: 'System', icon: Database },
|
||||||
|
{ id: 'musicbrainz', label: 'MusicBrainz', group: 'System', icon: Globe },
|
||||||
{ id: 'advanced', label: 'Advanced', group: 'System', icon: Settings },
|
{ id: 'advanced', label: 'Advanced', group: 'System', icon: Settings },
|
||||||
{ id: 'about', label: 'About', group: 'System', icon: Info }
|
{ id: 'about', label: 'About', group: 'System', icon: Info }
|
||||||
];
|
];
|
||||||
@@ -189,6 +192,8 @@
|
|||||||
<SettingsLastFm />
|
<SettingsLastFm />
|
||||||
{:else if activeTab === 'scrobbling'}
|
{:else if activeTab === 'scrobbling'}
|
||||||
<SettingsScrobbling />
|
<SettingsScrobbling />
|
||||||
|
{:else if activeTab === 'musicbrainz'}
|
||||||
|
<SettingsMusicBrainz />
|
||||||
{:else if activeTab === 'advanced'}
|
{:else if activeTab === 'advanced'}
|
||||||
<SettingsAdvanced />
|
<SettingsAdvanced />
|
||||||
{:else if activeTab === 'about'}
|
{:else if activeTab === 'about'}
|
||||||
|
|||||||
Reference in New Issue
Block a user