Files
musicseerr/backend/core/config.py
T
shaunrd0 23c9125ad8
Backend CI / Lint (push) Waiting to run
Backend CI / Tests (push) Waiting to run
Personal fork of habirabbu/musicseerr — multi-instance + inline downloads + lidarr-request
Squashes 26 incremental fork commits (Apr–May 2026) onto upstream main as a single
diff for cleaner cross-fork comparison. Original history preserved on the
pre-squash-backup tag locally.

Feature additions
─────────────────

• Inline single-track download via yt-dlp-worker proxy
  New routes: POST /api/v1/track-download/search (source: youtube | spotify),
  POST /api/v1/track-download, GET /api/v1/track-download/{id}. Frontend
  TrackDownloadButton in album track list AND popular-songs row, with a per-button
  source picker. Per-user rate limits live in the worker's SQLite store. On
  completion the backend fires Lidarr RefreshArtist + Plex library refresh +
  cache invalidation, and the popular-songs list auto-refreshes.

• Per-instance library pinning via MUSICSEERR_LIBRARY env
  Backend stamps the library label server-side (music / music-personal /
  music-shared); clients cannot override. Drives an instance-segregated
  deployment of three musicseerr containers sharing one source tree.

• Lidarr-request flow (single-track requests via Lidarr indexers)
  New routes: POST /api/v1/lidarr-request, GET /api/v1/lidarr-request/status.
  Per-album asyncio.Lock keyed on album_mbid so rapid-clicks on the same album
  serialize correctly. Cross-release track matcher with foreignTrackId →
  foreignRecordingId → position+disc → exact-title → substring fallback chain,
  evaluated per release (recording UUIDs frequently differ between album,
  single, and deluxe edition releases of the same song). Flips
  artist.monitored = True on request so Lidarr's WantedAlbums query reaches
  the track. Full Lidarr-chain gate (artist AND album AND track) for the
  status endpoint to avoid false-positive REQUESTED display. Persistent UI
  state so button icons survive refresh and cross-album navigation.

• Privacy: show_now_playing toggle in Settings → Home
  Default off. Plex /status/sessions returns active audio sessions across the
  whole server with no library-section filter, so a shared instance leaks
  every household member's listening activity. The merged store still emits
  the user's local MusicSeerr playback bar; only server-derived sessions
  (Plex / Jellyfin / Navidrome) are gated.

• Per-button visibility prefs for the track-row action cluster
  Settings → Preferences → Download Options / Playback Buttons. Per-context
  (popular_songs / album_page) force-off flags layered on top of the existing
  source-availability gate.

• UX: wrap action cluster on mobile, hide LidarrRequestButton in tight
  layouts, cross-album status-leak fix in AlbumTrackList ($effect keyed on
  album.musicbrainz_id to rebuild lookup; map keyed by
  "{albumMbid}:{position}:{disc}").

Test coverage
─────────────

Backend pytest: full suite green (2031/2031 as of squash). New: schema-default
tests for HomeSettings, lidarr_request_service cross-release matcher
regression test, singleton-registry expected-count bump to 59. Frontend
vitest: SettingsHome.svelte.spec covers new toggle, nowPlayingSessions
.svelte.spec covers the privacy gate (no fetch when off; fetches when on).
2026-05-29 23:55:54 +00:00

316 lines
13 KiB
Python

from pathlib import Path
from pydantic import Field, TypeAdapter, ValidationError as PydanticValidationError, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Self
import logging
import msgspec
from core.exceptions import ConfigurationError
from infrastructure.file_utils import atomic_write_json, read_json
logger = logging.getLogger(__name__)
_VALID_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="allow"
)
lidarr_url: str = Field(default="http://lidarr:8686")
lidarr_api_key: str = Field(default="")
lidarr_timeout: float = Field(
default=30.0,
ge=5.0,
le=600.0,
description="HTTP read/write timeout in seconds for Lidarr API calls.",
)
jellyfin_url: str = Field(default="http://jellyfin:8096")
contact_email: str = Field(
default="contact@musicseerr.com",
description="Contact email for MusicBrainz API User-Agent. Override with your own if desired."
)
quality_profile_id: int = Field(default=1)
metadata_profile_id: int = Field(default=1)
root_folder_path: str = Field(default="/music")
port: int = Field(default=8688)
debug: bool = Field(default=False)
log_level: str = Field(default="INFO")
cache_ttl_default: int = Field(default=60)
cache_ttl_artist: int = Field(default=3600)
cache_ttl_album: int = Field(default=3600)
cache_ttl_covers: int = Field(default=86400, description="Cover cache TTL in seconds (default: 24 hours)")
cache_cleanup_interval: int = Field(default=300)
root_app_dir: Path = Field(default=Path("/app"), description="Root application directory")
cache_dir: Path = Field(default=Path("/app/cache"), description="Root directory for all cache files")
library_db_path: Path = Field(default=Path("/app/cache/library.db"), description="SQLite library database path")
cover_cache_max_size_mb: int = Field(default=500, description="Maximum cover cache size in MB")
queue_db_path: Path = Field(default=Path("/app/cache/queue.db"), description="SQLite queue database path")
shutdown_grace_period: float = Field(default=10.0, description="Seconds to wait for tasks on shutdown")
http_timeout: float = Field(default=10.0)
http_connect_timeout: float = Field(default=5.0)
http_max_connections: int = Field(default=200)
http_max_keepalive: int = Field(default=50)
config_file_path: Path = Field(default=Path("/app/config/config.json"))
audiodb_api_key: str = Field(default="123")
audiodb_premium: bool = Field(default=False, description="Set to true if using a premium AudioDB API key")
instance_id: str = Field(default="", description="Auto-generated per-instance UUID for User-Agent differentiation")
# Inline track-download feature (fork-only). Stamped per musicseerr instance via env:
# public musicseerr -> "music"; admin musicseerr-personal -> "music-personal";
# public musicseerr-shared -> "music-shared". Backend rejects any other value
# and the client cannot override it.
musicseerr_library: str = Field(
default="music",
description="Target library for track downloads: 'music', 'music-personal', or 'music-shared'.",
)
yt_dlp_worker_url: str = Field(
default="http://yt-dlp-worker:4949",
description="Base URL for the yt-dlp-worker sidecar that performs single-track downloads.",
)
# Plex notification on download-complete (fork-only). When all three are set,
# the track-download flow fires `/library/sections/<id>/refresh` after each
# successful download so Plex picks up the new file immediately. Per-instance:
# musicseerr -> section 3, musicseerr-personal -> 6, musicseerr-shared -> 7.
# Empty = feature disabled (download still works, Plex just won't auto-scan).
plex_url: str = Field(
default="",
description="Plex base URL for post-download library-section refresh. Empty disables the integration.",
)
plex_token: str = Field(
default="",
description="Plex authentication token (X-Plex-Token header).",
)
plex_section_id: int = Field(
default=0,
description="Plex library section ID matching this musicseerr's library (3/6/7 in our deployment).",
)
@field_validator("musicseerr_library")
@classmethod
def validate_musicseerr_library(cls, v: str) -> str:
normalised = v.strip().lower()
if normalised not in {"music", "music-personal", "music-shared"}:
raise ValueError(
f"musicseerr_library must be 'music', 'music-personal', or 'music-shared'; got '{v}'"
)
return normalised
@field_validator("yt_dlp_worker_url")
@classmethod
def validate_worker_url(cls, v: str) -> str:
return v.rstrip("/")
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
normalised = v.upper()
if normalised not in _VALID_LOG_LEVELS:
raise ValueError(
f"Invalid log_level '{v}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}"
)
return normalised
@field_validator("lidarr_url", "jellyfin_url")
@classmethod
def validate_url(cls, v: str) -> str:
return v.rstrip("/")
@model_validator(mode='after')
def validate_config(self) -> Self:
# Dynamically resolve paths relative to root_app_dir
if self.cache_dir == Path("/app/cache"):
self.cache_dir = self.root_app_dir / "cache"
if self.library_db_path == Path("/app/cache/library.db"):
self.library_db_path = self.cache_dir / "library.db"
if self.queue_db_path == Path("/app/cache/queue.db"):
self.queue_db_path = self.cache_dir / "queue.db"
if self.config_file_path == Path("/app/config/config.json"):
self.config_file_path = self.root_app_dir / "config" / "config.json"
errors = []
warnings = []
for url_field in ['lidarr_url', 'jellyfin_url']:
url = getattr(self, url_field, '')
if url and not url.startswith(('http://', 'https://')):
errors.append(f"{url_field} must start with http:// or https://")
if self.http_max_connections < self.http_max_keepalive * 2:
warnings.append(
f"http_max_connections ({self.http_max_connections}) should be "
f"at least 2x http_max_keepalive ({self.http_max_keepalive})"
)
if not self.lidarr_api_key:
warnings.append("LIDARR_API_KEY is not set - Lidarr features will not work")
for warning in warnings:
logger.warning(warning)
if errors:
raise ConfigurationError(
f"Critical configuration errors: {'; '.join(errors)}"
)
return self
def get_user_agent(self) -> str:
id_part = self.instance_id[:8] if self.instance_id else "unknown"
return f"Musicseerr/1.0 ({id_part}; {self.contact_email}; https://www.musicseerr.com)"
def load_from_file(self) -> None:
if not self.config_file_path.exists():
self._create_default_config()
return
try:
config_data = read_json(self.config_file_path, default={})
if not isinstance(config_data, dict):
raise ValueError("Config file JSON root must be an object")
type_errors: list[str] = []
model_fields = type(self).model_fields
validated_values: dict[str, object] = {}
for key, value in config_data.items():
if key not in model_fields:
logger.warning("Unknown config key '%s', ignoring", key)
continue
try:
field_info = model_fields[key]
adapter = TypeAdapter(field_info.annotation)
validated_values[key] = adapter.validate_python(value)
except PydanticValidationError as e:
type_errors.append(
f"'{key}': {e.errors()[0].get('msg', str(e))}"
)
except (TypeError, ValueError) as e:
type_errors.append(f"'{key}': {e}")
if type_errors:
raise ConfigurationError(
f"Config file type errors: {'; '.join(type_errors)}"
)
# Run field validators that TypeAdapter doesn't invoke
try:
for url_field in ('lidarr_url', 'jellyfin_url'):
if url_field in validated_values:
validated_values[url_field] = type(self).validate_url(
validated_values[url_field]
)
if 'log_level' in validated_values:
validated_values['log_level'] = type(self).validate_log_level(
validated_values['log_level']
)
except ValueError as e:
raise ConfigurationError(f"Config file validation error: {e}")
# Dry-run cross-field validation on merged candidate state
self._validate_merged(validated_values)
# All validation passed; apply atomically.
for key, value in validated_values.items():
setattr(self, key, value)
except (ConfigurationError, ValueError):
raise
except msgspec.DecodeError as e:
logger.error(f"Invalid JSON in config file: {e}")
raise ValueError(f"Config file is not valid JSON: {e}")
except Exception as e:
logger.error(f"Failed to load config: {e}")
raise
def _validate_merged(self, overrides: dict[str, object]) -> None:
"""Validate cross-field constraints against candidate merged state without mutating self."""
errors = []
def _get(field: str) -> object:
return overrides.get(field, getattr(self, field))
for url_field in ('lidarr_url', 'jellyfin_url'):
url = _get(url_field)
if url and not str(url).startswith(('http://', 'https://')):
errors.append(f"{url_field} must start with http:// or https://")
if errors:
raise ConfigurationError(
f"Critical configuration errors: {'; '.join(errors)}"
)
def _create_default_config(self) -> None:
self.config_file_path.parent.mkdir(parents=True, exist_ok=True)
config_data = {
"lidarr_url": self.lidarr_url,
"lidarr_api_key": self.lidarr_api_key,
"lidarr_timeout": self.lidarr_timeout,
"jellyfin_url": self.jellyfin_url,
"contact_email": self.contact_email,
"quality_profile_id": self.quality_profile_id,
"metadata_profile_id": self.metadata_profile_id,
"root_folder_path": self.root_folder_path,
"port": self.port,
"audiodb_api_key": self.audiodb_api_key,
"audiodb_premium": self.audiodb_premium,
"user_preferences": {
"primary_types": ["album", "ep", "single"],
"secondary_types": ["studio"],
"release_statuses": ["official"],
},
}
atomic_write_json(self.config_file_path, config_data)
def save_to_file(self) -> None:
try:
self.config_file_path.parent.mkdir(parents=True, exist_ok=True)
config_data = {}
if self.config_file_path.exists():
loaded = read_json(self.config_file_path, default={})
config_data = loaded if isinstance(loaded, dict) else {}
config_data.update({
"lidarr_url": self.lidarr_url,
"lidarr_api_key": self.lidarr_api_key,
"lidarr_timeout": self.lidarr_timeout,
"jellyfin_url": self.jellyfin_url,
"contact_email": self.contact_email,
"quality_profile_id": self.quality_profile_id,
"metadata_profile_id": self.metadata_profile_id,
"root_folder_path": self.root_folder_path,
"port": self.port,
"audiodb_api_key": self.audiodb_api_key,
"audiodb_premium": self.audiodb_premium,
})
atomic_write_json(self.config_file_path, config_data)
except Exception as e:
logger.error(f"Failed to save config: {e}")
raise
_settings: Settings | None = None
def get_settings() -> Settings:
global _settings
if _settings is None:
settings = Settings()
settings.load_from_file()
_settings = settings
return _settings