23c9125ad8
Backend CI / Lint (push) Waiting to run
Backend CI / Tests (push) Waiting to run
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).
316 lines
13 KiB
Python
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
|