Ctrl K
ComunidadGuías…

backend/config_settings.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Sublarr application settings — Boot + UI split (since v0.88.0-beta).

Structure:
- ``BootSettings(BaseSettings)``  — env-loadable. Holds the small set of
  fundamentals that have to be set before the database is reachable
  (DB URL, mount paths, port, log level, …). Curated allowlist enforced
  by ``tools/lint_no_new_env_fields.py``.
- ``UISettings(BaseModel)``        — DB-only. Holds every other configurable
  behaviour. ``BaseModel`` (not ``BaseSettings``) means Pydantic does NOT
  auto-load env vars, so the UI is the sole writer.
- ``Settings``                     — composite. Forwards attribute access to
  ``boot`` or ``ui`` so existing call-sites (``settings.api_key``,
  ``settings.opensubtitles_api_key``) keep working unchanged.

UI-first convention (V1, decided 2026-05-04): every new setting lands in
``UISettings``. The CI linter rejects new fields added to ``BootSettings``
outside ``_ALLOWED_BOOT_FIELDS``.

Importing rules:
- This module imports ``config_views`` for the property accessor return
  types — ``config_views`` does NOT import back (TYPE_CHECKING guard).
- Singleton management lives in ``config_singleton.py``; this module does
  NOT cache instances.
"""

import hashlib
import logging
import os
from typing import Any, Literal

from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings

from config_language_data import _get_language_tags

# Runtime import of the view classes — used by the @property accessors below.
#
# Dependency contract (one-directional): config_settings -> config_views.
# config_views MUST NOT import config_settings at runtime (it uses
# TYPE_CHECKING + from __future__ import annotations to keep the forward
# reference lazy). Adding a runtime import of Settings into config_views
# would create a circular import at module load time.
from config_views import (
    GeneralSettings,
    MediaServerSettings,
    ProviderSettings,
    ScanningSettings,
    TranslationSettings,
)

logger = logging.getLogger(__name__)


# Curated allowlist for env-loadable fields. Any new entry here must clear
# the linter check (`tools/lint_no_new_env_fields.py`). Adding a UI feature?
# Put the field on UISettings, not here.
_ALLOWED_BOOT_FIELDS: frozenset[str] = frozenset(
    {
        "port",
        "api_key",
        "media_path",
        "db_path",
        "database_url",
        "config_dir",
        "log_level",
        "log_file",
        "log_format",
        "cors_origins",
        "redis_url",
        # Anonymous-stats kill-switch/redirect — must be env-overridable
        # pre-DB so self-hosters can disable sending before first boot.
        "stats_endpoint",
    }
)


# Words that mark a setting as a credential. Matched against the name's
# underscore-separated segments, NOT as substrings: "pin" as a substring also
# matches "map-pin-g", which is how path_mapping and the boolean
# anidb_fallback_to_mapping came back as "***configured***" from GET /config.
# The path-mapping editor then showed the mask, and saving it would have
# written that literal string over the user's real paths (2026-09-10).
_SENSITIVE_SEGMENTS = frozenset({"password", "pin", "secret", "token", "key", "apikey"})
# Credentials whose names carry none of the words above.
_EXPLICIT_MASKED = frozenset({"database_url", "redis_url"})


def is_sensitive_config_key(key: str) -> bool:
    """Whether a settings field must be masked before it leaves the API."""
    if key.startswith("hub."):
        # Catalog provider settings: the manifests name their credentials.
        from sensitive_keys import is_sensitive_key

        return is_sensitive_key(key)
    if key in _EXPLICIT_MASKED:
        return True
    if "api_key" in key:
        return True
    return bool(set(key.split("_")) & _SENSITIVE_SEGMENTS)


class BootSettings(BaseSettings):
    """Bootstrap settings — env-loadable, pre-DB.

    Membership is gated by ``_ALLOWED_BOOT_FIELDS`` and enforced in CI by
    ``tools/lint_no_new_env_fields.py``. Add a UI feature? Put the field
    on ``UISettings`` instead.
    """

    # Server bind
    port: int = 5765

    # Initial auth bootstrap (empty = no auth required)
    api_key: str = ""

    # Mount paths — Docker volume targets that must exist before any
    # path-based code runs.
    media_path: str = "/media"
    config_dir: str = "/config"

    # Database — ``database_url`` (PG) wins over ``db_path`` (SQLite) at
    # ``Settings.get_database_url()`` time.
    db_path: str = "/config/sublarr.db"
    database_url: str = ""

    # Anonymous usage statistics (opt-in). Empty string disables sending entirely,
    # regardless of the in-app consent toggle (self-hoster kill-switch / redirect).
    stats_endpoint: str = "https://stats.sublarr.de/v1/ping"

    # Redis (optional cache + queue backend)
    redis_url: str = ""

    # Logging — needed before any log line can land in the right format.
    log_level: str = "INFO"
    # Source-checkout default. The Docker image overrides it to
    # /config/sublarr.log via ENV, so a container logs onto the persistent
    # volume rather than into its own layer.
    log_file: str = "log/sublarr.log"
    log_format: str = "text"  # "text" or "json" (structured for log aggregation)

    # Comma-separated EXTRA Socket.IO origins (e.g. "https://app.example.com").
    # The app's own origin (request Host) is always allowed, so the empty
    # default means same-origin only. Set "*" only in fully trusted environments.
    cors_origins: str = ""

    model_config = {
        "env_prefix": "SUBLARR_",
        "env_file": ".env",
        "env_file_encoding": "utf-8",
        "extra": "ignore",
    }


class UISettings(BaseModel):
    """UI-configured settings — DB-only.

    ``BaseModel`` (not ``BaseSettings``) means Pydantic does NOT load env
    vars for these fields. The UI writes them through ``config_entries``
    and ``reload_settings()`` overlays them onto the defaults below.

    UI-first convention: every new configurable behaviour lands here.
    """

    # Ollama
    ollama_url: str = "http://localhost:11434"
    ollama_model: str = "qwen2.5:14b-instruct"
    batch_size: int = 15
    request_timeout: int = 90
    temperature: float = 0.3
    max_retries: int = 3
    backoff_base: int = 5

    # Translation
    translation_enabled: bool = False  # Off by default; the user enables it explicitly
    source_language: str = "en"
    target_language: str = "de"
    source_language_name: str = "English"
    target_language_name: str = "German"
    # Multi-language auto-translate source. When on, the missing target is
    # translated FROM whatever source subtitle actually exists (any language),
    # preferring the profile/global source; otherwise only the preferred source
    # language is used. ``auto_translate_provider_multilang`` additionally lets
    # the provider search try several source languages, and
    # ``auto_translate_source_languages`` is the ordered candidate/preference
    # list used for that search and for choosing among available local sources.
    auto_translate_any_source: bool = True
    auto_translate_provider_multilang: bool = True
    auto_translate_source_languages: list[str] = [
        "en",
        "ja",
        "zh",
        "ko",
        "es",
        "fr",
        "de",
        "pt",
        "it",
        "ru",
    ]

    @field_validator("auto_translate_source_languages", mode="before")
    @classmethod
    def _parse_source_language_list(cls, v):
        """Coerce a persisted config value into a list of language codes.

        The generic /config writer stores non-string values via ``str()``, so a
        list round-trips as its Python repr (``"['ja', 'en']"``). Accept a real
        list, a JSON array, that Python-repr string, or a comma-separated string
        so the setting survives an API/DB round-trip regardless of how it was
        stored.
        """
        if isinstance(v, list):
            return [str(x).strip() for x in v if str(x).strip()]
        if isinstance(v, str):
            s = v.strip()
            if not s:
                return []
            import ast as _ast
            import json as _json

            for _parse in (_json.loads, _ast.literal_eval):
                try:
                    parsed = _parse(s)
                    if isinstance(parsed, list):
                        return [str(x).strip() for x in parsed if str(x).strip()]
                except (ValueError, SyntaxError):
                    pass
            return [p.strip() for p in s.split(",") if p.strip()]
        return v

    prompt_template: str = ""  # Empty = auto-generated from languages
    # Global default translation backend + optional single fallback. A language
    # profile with an empty translation_backend inherits these. Declared here as
    # UISettings so the /config API surfaces AND accepts them (the Backends-page
    # "Default translation backend" control reads/writes via /config). The
    # resolver reads them via db.config.get_config_entry — this default is only
    # the fallback when no config_entry row exists.
    translation_default_backend: str = "ollama"
    translation_default_fallback: str = ""

    # Subtitle Providers
    provider_priorities: str = "animetosho,jimaku,opensubtitles,subdl"
    providers_enabled: str = ""  # Empty = all registered providers enabled
    # Providers taken over from the Bazarr+ Provider Hub catalog
    # (providers/hub). Off by default: they are third-party scrapers that run
    # in-process, so an install turns them on deliberately. Each one still
    # has to be enabled in the provider list after that.
    provider_hub_enabled: bool = False
    providers_hidden: str = ""  # Comma-separated provider names hidden from UI grid
    # Per-provider language exclusion (#192): JSON object mapping provider
    # name to ISO 639-1 codes it must never serve, e.g. {"opensubtitles": ["sr"]}
    provider_language_excludes_json: str = ""

    # Reverse-proxy header authentication (Authelia/authentik SSO).
    # When enabled, a request whose DIRECT peer IP (request.remote_addr — no
    # ProxyFix) is within proxy_auth_trusted_ips is authenticated if it carries
    # a non-empty proxy_auth_header. OFF by default; fails closed with no allowlist.
    proxy_auth_enabled: bool = False
    proxy_auth_trusted_ips: str = ""  # comma-separated IPs / CIDRs of trusted proxies
    proxy_auth_header: str = "Remote-User"

    # Addic7ed (TV subtitles — optional credentials increase download limit)
    addic7ed_username: str = ""
    addic7ed_password: str = ""

    # Turkcealtyazi (Turkish subtitles — account required)
    turkcealtyazi_username: str = ""
    turkcealtyazi_password: str = ""

    # Titlovi (Balkan subtitles — account with API access required, #191)
    titlovi_username: str = ""
    titlovi_password: str = ""

    # Accounts of providers whose config_fields named bare "username" /
    # "password" / "apikey": no such setting existed, so the form could not
    # save them and the provider never saw them (#61 A01). Stored as
    # <provider name>_<field>; the manager strips the prefix again.
    legendasdivx_username: str = ""
    legendasdivx_password: str = ""
    napisy24_username: str = ""
    napisy24_password: str = ""
    addic7ed_subliminal_username: str = ""
    addic7ed_subliminal_password: str = ""
    opensubtitles_subliminal_username: str = ""
    opensubtitles_subliminal_password: str = ""
    opensubtitlescom_subliminal_apikey: str = ""
    opensubtitlescom_subliminal_username: str = ""
    opensubtitlescom_subliminal_password: str = ""
    provider_search_timeout: int = 30  # Global timeout fallback (seconds)
    provider_cache_ttl_minutes: int = 5  # Cache TTL for provider search results
    provider_auto_prioritize: bool = True  # Auto-prioritize providers based on success rate
    provider_rate_limit_enabled: bool = True  # Enable rate limiting per provider
    dedup_on_download: bool = True  # Skip download if identical content already exists (SHA-256)
    # Plan B5 — subtitle repair pass before saving. Set False to disable.
    enable_subtitle_repair: bool = True
    github_token: str = ""  # Optional GitHub API token for higher rate limits

    # Dynamic Provider Timeouts (Phase 3)
    provider_dynamic_timeout_enabled: bool = True
    provider_dynamic_timeout_min_samples: int = 5
    provider_dynamic_timeout_multiplier: float = 3.0
    provider_dynamic_timeout_buffer_secs: float = 2.0
    provider_dynamic_timeout_min_secs: int = 5
    provider_dynamic_timeout_max_secs: int = 30

    # OpenSubtitles.com (API v2)
    opensubtitles_api_key: str = ""
    betaseries_api_key: str = ""
    opensubtitles_username: str = ""
    opensubtitles_password: str = ""

    # Jimaku (anime subtitles)
    jimaku_api_key: str = ""
    # "ja" is how unmarked files were read before this setting existed.
    jimaku_unknown_language_fallback: str = "ja"

    # AnimeTosho: subtitles attached to a torrent rather than a file ("on"/"off").
    # On: multi-sub releases carry them only there (torrent 695392: 40, German
    # among them, none per file).
    animetosho_root_attachments: str = "on"

    # SubDL (Subscene successor)
    subdl_api_key: str = ""

    # SubSource API v1 (configured in Settings > Providers)
    subsource_api_key: str = ""

    # SubsDump (self-hosted Subscene archive)
    subsdump_url: str = ""
    subsdump_api_key: str = ""

    # Custom HTTP/JSON provider (generic, user-configured REST endpoint —
    # see docs/CUSTOM_PROVIDER_API.md for the contract)
    customapi_base_url: str = ""
    customapi_api_key: str = ""
    customapi_api_key_header: str = "X-API-Key"
    customapi_search_path: str = "/search"
    customapi_download_path: str = "/download/{id}"
    customapi_results_path: str = "results"
    customapi_field_map: str = ""  # JSON object: SubtitleResult field -> response path
    customapi_extra_params: str = ""  # JSON object: extra query params sent on search
    customapi_instances_json: str = ""  # JSON array of additional instances

    # Sonarr (optional)
    sonarr_url: str = ""
    sonarr_api_key: str = ""
    sonarr_instances_json: str = ""  # JSON array of Sonarr instances

    # Radarr (optional — for anime movies)
    radarr_url: str = ""
    radarr_api_key: str = ""
    radarr_instances_json: str = ""  # JSON array of Radarr instances

    # Jellyfin/Emby (optional — library refresh)
    jellyfin_url: str = ""
    jellyfin_api_key: str = ""

    # Media Servers (multi-backend: Jellyfin, Plex, Kodi)
    media_servers_json: str = ""  # JSON array of media server instances

    # Path Mapping (remote → local, for when *arr apps run on different host)
    # Format: "remote_prefix=local_prefix" (semicolon-separated for multiple)
    # Example: "/data/media=Z:\\Media;/anime=Z:\\Anime"
    path_mapping: str = ""

    # ffmpeg / ffprobe
    # Seconds before ffmpeg subtitle extraction is killed; files over ~2 GB get
    # 60 s per GB on top (utils/io_timeout.py), since ffmpeg reads the whole file.
    ffmpeg_timeout: int = 120

    # Scan Metadata Engine
    scan_metadata_engine: str = "auto"  # "ffprobe" | "mediainfo" | "auto"
    scan_metadata_max_workers: int = 2  # Parallel workers for batch metadata scans
    # How many disk-heavy media subprocesses (remux, extraction, sync, probe
    # batches) may run at once, process-wide. See services/media_io_gate.py.
    media_io_max_parallel: int = 1

    # Translation Workers
    translation_max_workers: int = 4  # Parallel workers in the job queue thread pool

    # Wanted System
    wanted_scan_interval_hours: int = 0  # 0 = disabled; event-driven
    wanted_anime_only: bool = True
    wanted_anime_movies_only: bool = False  # Filter Radarr movies by anime tag
    wanted_scan_on_startup: bool = False
    wanted_auto_extract: bool = False  # Auto-extract embedded subs during wanted scan
    wanted_auto_translate: bool = False  # Auto-translate after auto-extract
    wanted_max_search_attempts: int = 3
    # Give exhausted items a bounded second chance (#199). Attempt exhaustion
    # is otherwise permanent and provider-blind: an item that burned its
    # attempts while a provider was broken stays parked forever, and fixing
    # that provider afterwards does nothing for it. 0 = off, which is the
    # default — reviving on its own schedule is a behaviour change nobody
    # asked for on upgrade.
    wanted_revive_exhausted_after_days: int = Field(
        default=0,
        ge=0,
        le=3650,
        description=(
            "Reset search attempts on items that have been exhausted for this "
            "many days, so they get another chance. 0 disables it."
        ),
    )
    # Ceiling per revival run, so a large parked backlog comes back in
    # predictable slices instead of flooding the next search cycle. The
    # reporting install had 869 exhausted items at once.
    wanted_revive_max_per_run: int = Field(default=200, ge=1, le=10000)
    # The provider-change trigger, separately switchable (#214). Its own flag
    # rather than allowing the cap above to reach 0, because that cap is shared
    # with the age trigger — turning one off would silently take the other with
    # it, and the reporter wants to keep revive-by-age.
    wanted_revive_on_provider_change: bool = Field(
        default=True,
        description=(
            "Give exhausted items another chance when a provider is added or "
            "gets a new credential. Off leaves them for the age trigger."
        ),
    )
    # After ``wanted_max_search_attempts`` slow-mode cycles also fail, escalate
    # to status='unsourceable' so the row stops eating scheduler budget. Set
    # to a very high value to keep slow-mode forever (legacy behaviour).
    wanted_search_max_slow_cycles: int = Field(
        default=3,
        ge=0,
        le=999,
        description=(
            "After this many slow-mode no_result cycles, mark the wanted item "
            "as 'unsourceable' to remove it from the active scheduler queue."
        ),
    )
    use_embedded_subs: bool = True  # Check embedded subtitle streams in MKV files
    scan_yield_ms: int = 0  # Sleep between series/movies (ms) to yield CPU

    # 0.71.0 — Subtitle Automation (batteries-included extract/SDH/cleanup bundle)
    subtitle_automation_enabled: bool = False
    subtitle_automation_queue_enabled: bool = True  # Drain worker for pending extracts
    subtitle_automation_drain_interval_minutes: int = 2  # Scheduler tick cadence

    # SDH source tolerance.
    embedded_allow_sdh: bool = True
    embedded_sdh_penalty: int = 5

    # Issue #159: container stream removal after extraction used to be an
    # unconditional side effect. Opt-in now; even when enabled, streams in
    # the keep-languages set are never removed.
    embedded_extract_remove_from_container: bool = False
    # Extract embedded ASS/SSA by stream copy, re-encoding only when the copy
    # fails. Opt-in: the default keeps the ASS encoder used until 1.20.0.
    embedded_extract_ass_stream_copy: bool = False

    # Dubtitle detection (roadmap A4 / issue #146). Off by default: surfaces
    # the dubtitle among multiple English tracks; suggest-then-confirm, never
    # silently applied. dubtitle_min_score is the Tier-2 audio-match threshold.
    dubtitle_detection: bool = False
    dubtitle_min_score: float = 0.55
    # Unattended-mode guardrails (Gemini review 2026-06-14): require a clear
    # gap between the best and runner-up audio match before auto-flagging, and
    # demand a denser dialogue track than the on-demand path so a sparse signs
    # track can't fluke a single-window match.
    dubtitle_min_margin: float = 0.15
    dubtitle_auto_min_cues: int = 70
    # When True, an English sidecar downloaded for an episode with English dub
    # audio is scored against the dub (Tier-2) before being kept; a sub that is a
    # confident low-score mismatch is KEPT but flagged (not trashed). Opt-in:
    # verification needs Whisper + dub audio and falls back to "keep" when either
    # is unavailable.
    dubtitle_verify_on_download: bool = False

    # AI subtitle-quality badge (advisory only). After a download, an
    # LLM samples ~ai_quality_max_cues cues from the saved sidecar and rates
    # machine-translation likelihood, OCR artifacts and grammar; the verdict is
    # shown as a green/yellow/red badge in History. Read-only by design: the
    # verdict never modifies files and never feeds into scoring or upgrades.
    # Uses the configured Ollama instance (local-first); ai_quality_model
    # empty = reuse ollama_model.
    ai_quality_enabled: bool = False
    ai_quality_model: str = ""
    ai_quality_max_cues: int = 30

    # Foreign-track cleanup. Destructive (remuxes the MKV with backup-to-trash).
    cleanup_foreign_tracks_default: bool = False
    cleanup_foreign_tracks_keep_und: bool = False
    # Languages always kept by foreign-track cleanup, on top of the item's
    # target/wanted languages. Guarantees e.g. English survives even when
    # only German is the download target. Empty list = target languages only.
    cleanup_foreign_tracks_keep_languages: list[str] = ["de", "en"]

    # 1.15.0: which variants of a kept language survive the cleanup.
    # "all" keeps every track of a kept language (the 1.14.x behaviour);
    # "one_per_language" keeps the best full track plus forced/SDH per the
    # two toggles below.
    cleanup_track_variant_mode: Literal["all", "one_per_language"] = "all"
    cleanup_keep_forced: bool = True
    cleanup_keep_sdh: bool = False
    # "drop_if_real_sidecar": a genuine (non-MT) sidecar in the language makes
    # the embedded main track redundant. Machine translations never count.
    cleanup_sidecar_policy: Literal["keep_embedded", "drop_if_real_sidecar"] = "keep_embedded"

    # Batched foreign-track sweep. Off by default: it rewrites media files, so
    # it is enabled deliberately, not inherited by an upgrade.
    foreign_track_sweep_enabled: bool = False
    # Wall-clock budget per tick, checked after each completed file. Overshoot
    # is bounded by the slowest single file, which for a large remux is minutes.
    # Bounds are enforced on save by PUT /config (reload_settings applies DB
    # overrides with model_copy, which does not validate). The upper bound is
    # half the foreign_track_sweep JobSpec timeout, so a full-budget run plus
    # one large file never reads as a scheduler timeout.
    foreign_track_sweep_budget_s: int = Field(
        default=1800,
        ge=300,
        le=3600,
        description="Seconds one foreign-track sweep run may keep starting new files",
    )
    # Idle time before a new generation walks the library again.
    foreign_track_sweep_rescan_days: int = 7
    # Files modified more recently than this are skipped, so an import that is
    # still being written is never probed or rewritten mid-write.
    foreign_track_min_file_age_s: int = 600
    # Skip files larger than this. 0 keeps every file, which is the behaviour
    # every install had before this setting existed.
    #
    # The sweep rewrites the whole container to drop a few subtitle streams, so
    # its cost scales with file size while its benefit does not. Prod
    # 2026-09-28 measured one 2160p film: 49 min to remux, 47 min more to move
    # the 70 GB original to the trash, 4622 s of the run for a single file —
    # against 143 MB freed across the whole day. A library of 4K films and
    # 1.5 GB episodes wants the episodes swept and the films left alone.
    foreign_track_sweep_max_file_gb: float = Field(
        default=0,
        ge=0,
        le=10000,
        description="Skip files larger than this many GB (0 = no size limit)",
    )

    # Signs/forced/songs removal level (cleanup_signs rule + extract hook).
    # off | signs | signs_forced | signs_forced_songs. Default off.
    cleanup_signs_removal_level: str = "off"

    # Provider Re-ranking
    provider_reranking_enabled: bool = False  # Auto-adjust score modifiers
    provider_reranking_min_downloads: int = 20  # Min downloads before modifier applied
    provider_reranking_max_modifier: int = 50  # Absolute cap on computed modifier (±)

    # Release Group Filtering
    release_group_prefer: str = ""  # Comma-separated preferred release groups
    release_group_exclude: str = ""  # Comma-separated blocked release groups
    release_group_prefer_bonus: int = 20  # Score bonus for preferred release group matches

    # Upgrade System
    upgrade_enabled: bool = True
    upgrade_min_score_delta: int = 50
    upgrade_window_days: int = 7
    upgrade_prefer_ass: bool = True  # SRT->ASS always upgrade
    # Never auto-replace subtitles the user hand-edited in the editor
    upgrade_protect_user_modified: bool = True
    # Automatic repair (services.auto_repair): shift or refuse downloads whose
    # timing does not fit the video, treat signs/credits-only sidecars as
    # missing, and let the health sweep shift or quarantine sidecars on disk.
    # Off by default: an update never changes what Sublarr does to a library.
    auto_repair_enabled: bool = False

    # Decision Log — record the full selection pipeline per wanted-item search
    # (provider hits/skips, filter rejections, final pick) and store it on the
    # download row / wanted item so "why was this chosen?" is answerable in the UI.
    decision_log_enabled: bool = True

    # Log rotation — how much history the log file keeps. UI-configurable via
    # /api/v1/logs/rotation, consumed by app_logging._setup_logging. Declared
    # here because UISettings uses extra="ignore": while these keys were absent
    # they were persisted to config_entries and then silently dropped on
    # overlay, so the setting could never reach the handler. Bounds and
    # defaults live in app_logging (LOG_MAX_SIZE_MB_*, LOG_BACKUP_COUNT_*).
    log_max_size_mb: int = 10
    log_backup_count: int = 5

    # Hearing Impaired
    hi_removal_enabled: bool = False
    hi_preference: str = "include"  # include | prefer | exclude | only

    # Staff Credit Filtering
    credit_threshold_sec: int = 90
    """Seconds from end of subtitle file to treat as credits region."""
    op_window_sec: int = 300  # seconds from start/end of file to consider OP/ED window

    # Webhook Automation
    webhook_delay_minutes: int = 5  # Wait time after Sonarr/Radarr webhook
    webhook_auto_scan: bool = True
    webhook_auto_search: bool = True
    webhook_auto_translate: bool = True
    # Secret for /api/v1/webhook/* only; generated on first start (#71).
    webhook_secret: str = ""
    # Whether webhooks still accept the API key. On by default so an update
    # keeps existing Sonarr/Radarr/Jellyfin connections working.
    webhook_legacy_auth: bool = True
    jellyfin_play_translate_enabled: bool = False  # Auto-translate when Jellyfin starts playback

    # Video Sync (ffsubsync / alass)
    auto_sync_after_download: bool = False  # Auto-sync subtitle against video after download
    auto_sync_engine: str = "ffsubsync"  # Engine for auto-sync: "ffsubsync" | "alass"
    sync_sanity_threshold_ms: int = 45_000
    """Reject engine results whose absolute shift exceeds this many ms; the
    orchestrator then falls through to the next engine. ffsubsync's
    speech-detection sometimes mis-locks onto a wrong reference (intro vs
    cold-open), producing shifts clustered just under 60s with no real
    alignment. Default lowered from 60000 to 45000 (2026-06-10) after a prod
    bulk run surfaced a mis-lock cluster at ±56-60ms — just under the old
    ceiling. 45000 still admits genuine large offsets while rejecting that
    cluster; raise it again per-install if legitimate >45s shifts get cut."""

    # Post-download subtitle processing pipeline
    auto_process_common_fixes: bool = False
    auto_process_common_fixes_config_json: str = ""  # JSON; empty = all defaults
    auto_process_hi_removal: bool = False
    auto_process_credit_removal: bool = False

    # Post-download shell command
    post_download_command: str = ""  # Shell command to run after each subtitle download
    post_processing_enabled: bool = False  # Must be explicitly enabled

    # NFO Export
    auto_nfo_export: bool = False  # Expert: write XML NFO sidecar after every download

    # Glossary
    glossary_enabled: bool = True  # Enable glossary injection during translation
    glossary_max_terms: int = 100  # Maximum number of glossary terms injected per translation

    # Web Player
    streaming_enabled: bool = True  # Enable HTTP range-request video streaming endpoint

    # Wanted Search Scheduler
    wanted_search_interval_hours: int = 24  # 0 = disabled
    wanted_search_on_startup: bool = True
    wanted_search_max_items_per_run: int = 500
    # Wall-clock budget for the local-sidecar translate phase inside one
    # wanted_search run. A count cannot bound it: one item measured ~14.5
    # minutes on production 2026-08-13 and the cost scales with subtitle
    # length, so the 100-item cap still meant roughly a day of holding the
    # global search lock. 0 disables the inline phase entirely.
    wanted_search_sidecar_budget_s: int = Field(
        default=600,
        ge=0,
        description="Seconds the inline local-sidecar translate phase may run per tick (0 = off)",
    )
    # Wall-clock bound for one subtitle-automation drain tick. `max_items`
    # counts rows whose cost ranges from a few seconds to the ~870s of one
    # sidecar translation, and the JobSpec's `timeout_s` does not cancel — it
    # stops waiting and sets the abort event. So ticks doing exactly the work
    # asked of them overran the wait and were booked as `timeout` (prod
    # 2026-09-06: 12 of 70 in 24h). This bound fires first, before the next
    # row is claimed, leaving the 2400s timeout as headroom for the item
    # already in flight. No "off" value on purpose: unbounded is the bug.
    subtitle_automation_budget_s: int = Field(
        default=1800,
        ge=60,
        le=86400,
        description="Seconds one subtitle-automation drain tick may keep claiming new rows",
    )
    wanted_search_order: str = "fair"  # 'fair' | 'newest_first' | 'weighted'
    wanted_scheduler_priority_weighting_enabled: bool = True
    wanted_scheduler_backlog_reserve_pct: int = 50
    provider_budget_enabled: bool = True
    # Push a notification when a provider degrades quietly — auto-disabled, or
    # still searching while nothing downloads. Off by default: the conditions
    # are heuristics about a fleet whose shape varies a lot between installs,
    # and an unasked-for alert is a support ticket. Detection runs regardless
    # and writes the finding to the log.
    provider_degradation_alerts_enabled: bool = False
    provider_budget_stretch_mode: str = "stretch"  # 'stretch' | 'burst' | 'off'
    provider_budget_burst_window_hours: int = 6
    # Reserve this % below each provider's declared limit. Must be a declared
    # field (not just read via getattr) so the first-run wizard's profile
    # presets (light=40, balanced=20, aggressive=10) actually persist —
    # UISettings uses extra="ignore", so an undeclared key is silently dropped
    # and the budget manager would always fall back to the 20 default.
    provider_budget_safety_margin_pct: int = 20
    scheduler_profile: str = "balanced"  # 'light' | 'balanced' | 'aggressive' | 'custom'
    scheduler_history_retention_days: int = Field(
        default=30,
        ge=1,
        le=365,
        description=(
            "Keep scheduler job-run history for this many days before "
            "the scheduler_history_cleanup cron deletes old rows."
        ),
    )
    translation_events_retention_days: int = Field(
        default=90,
        ge=7,
        le=365,
        description="Keep translation_events rows for this many days.",
    )
    setup_wizard_completed: bool = False

    # Upgrade Scheduler
    upgrade_scan_interval_hours: int = 0  # 0 = disabled; user must opt in

    # Wanted Adaptive Backoff
    wanted_adaptive_backoff_enabled: bool = True
    wanted_backoff_base_hours: float = 1.0
    wanted_backoff_cap_hours: int = 168  # 7 days

    # Wanted Early Exit
    wanted_skip_srt_on_no_ass: bool = True  # Skip SRT steps if no ASS found in steps 1+2

    # Wanted Disk Safety-Valve — pause wanted_search when /config disk pressure is critical.
    # /config holds the application DB and download cache; pushing it over the cliff
    # corrupts both. Set to >=100.0 to disable the gate entirely.
    wanted_search_disk_pause_pct: float = Field(
        default=98.0,
        ge=50.0,
        le=100.0,
        description=(
            "Pause wanted_search when /config disk usage reaches this percentage. "
            "Set to 100.0 to disable."
        ),
    )

    # Notifications (Apprise)
    notification_urls_json: str = ""  # JSON array or newline-separated Apprise URLs
    notify_on_download: bool = True
    notify_on_upgrade: bool = True
    notify_on_batch_complete: bool = True
    notify_on_error: bool = True
    notify_manual_actions: bool = False

    # Anti-Captcha
    anti_captcha_provider: str = ""  # "" | "anticaptcha" | "capmonster"
    anti_captcha_api_key: str = ""

    # Remux / Stream Removal
    remux_trash_dir: str = ".sublarr"  # Relative (to media_path) or absolute path
    remux_backup_retention_days: int = 7  # 0 = keep forever
    remux_use_reflink: bool = True  # CoW reflink on Btrfs/XFS for zero-cost backups
    remux_arr_pause_enabled: bool = True  # Pause Sonarr/Radarr during remux
    # Issue #160: rewriting a hardlinked file (the recommended *arr setup)
    # breaks the link — disk usage doubles and the seeding copy diverges.
    # "skip" refuses the remux and logs; "warn" proceeds with an auditable
    # warning; "allow" is the pre-1.9.5 behaviour.
    remux_hardlink_policy: str = "skip"  # skip | warn | allow

    # Subtitle sanitizer limits. Plain-text formats remain on the fixed 5 MiB cap;
    # typeset ASS/SSA tracks can be much larger due to signs, drawings and karaoke.
    max_ass_subtitle_size_mb: int = Field(
        default=20,
        ge=1,
        le=50,
        description="Maximum accepted ASS/SSA subtitle size in MiB (provider downloads are capped at 50 MiB)",
    )

    # Subtitle backup files (.bak.srt/.bak.ass)
    subtitle_bak_retention_days: int = 30  # 0 = keep forever

    # Circuit Breaker
    circuit_breaker_failure_threshold: int = 5  # Consecutive failures before opening
    circuit_breaker_cooldown_seconds: int = 300  # Seconds in OPEN before HALF_OPEN probe
    provider_auto_disable_cooldown_minutes: int = (
        30  # Minutes before auto-disabled provider re-enables
    )
    provider_rate_limit_throttle_minutes: int = 60  # Extended throttle on HTTP 429

    # Database Backup
    backup_dir: str = "/config/backups"
    backup_retention_daily: int = 7
    backup_retention_weekly: int = 4
    backup_retention_monthly: int = 3

    # Plugin System
    plugins_dir: str = "/config/plugins"
    plugin_hot_reload: bool = False  # Enable watchdog file watcher for plugins directory

    # Standalone Mode
    standalone_enabled: bool = False
    standalone_scan_interval_hours: int = 6  # 0 = disabled
    standalone_debounce_seconds: int = 10
    standalone_skip_extras: bool = True  # Skip trailers/featurettes/samples during scan
    tmdb_api_key: str = ""  # TMDB API v3 Bearer token
    tvdb_api_key: str = ""  # TVDB API v4 key (optional)
    tvdb_pin: str = ""  # TVDB PIN (optional)
    metadata_cache_ttl_days: int = 30

    # Sidecar Auto-Cleanup
    auto_cleanup_after_extract: bool = False
    auto_cleanup_keep_languages: str = ""  # Comma-separated ISO-639-1 codes to keep
    auto_cleanup_keep_formats: str = "any"  # "ass" | "srt" | "any"

    # Subtitle Trash / Soft-Delete
    subtitle_trash_retention_days: int = 30  # 0 = keep forever

    # AniDB Integration
    anidb_enabled: bool = True  # Enable AniDB ID resolution
    anidb_cache_ttl_days: int = 30  # Cache TTL for TVDB → AniDB mappings
    anidb_custom_field_name: str = "anidb_id"  # Custom field name in Sonarr
    anidb_fallback_to_mapping: bool = True  # Use cache/mapping as fallback

    # Database (PERF-01, PERF-02) — pool tuning, not connection (URL is in BootSettings)
    db_pool_size: int = 5  # SQLAlchemy pool_size (ignored for SQLite)
    db_pool_max_overflow: int = 10  # SQLAlchemy max_overflow (ignored for SQLite)
    db_pool_recycle: int = 3600  # Recycle connections after N seconds

    # Redis behaviour (URL is in BootSettings)
    redis_cache_enabled: bool = True  # Use Redis for provider cache
    # RQ requires a SEPARATE `python worker.py` process (see docker-compose.redis.yml).
    # Sublarr ships single-container without that worker, so RQ must NOT be the
    # default: enabling it without a worker leaves every queued job stuck in
    # `queued` forever (silent). Opt in only alongside the rq-worker service.
    redis_queue_enabled: bool = False  # Use Redis+RQ for job queue (needs worker.py)

    # Interface Preferences (Step 37)
    interface_language: str = "en"
    items_per_page: int = 25
    default_library_view: str = "grid"  # "grid" | "list"

    # Auto Backup (Step 40)
    backup_auto_enabled: bool = False
    backup_auto_interval_hours: int = 24
    backup_auto_on_startup: bool = False
    backup_notify_on_failure: bool = True

    # Disk Monitoring (Step 41)
    disk_warning_threshold_percent: int = 90
    disk_warning_notify: bool = True

    # Per-Language Score Thresholds (Step 43)
    score_threshold_per_language: str = "{}"  # JSON object: {"de": 80, "fr": 70}

    # Gestdown
    gestdown_retry_delay_s: float = 1.0
    """Wartezeit in Sekunden vor dem Retry nach HTTP 423 (Locked) von Gestdown."""

    # Translation Context Window (Phase A4 — lookback/lookahead for LLM backends)
    # Declared here because the settings UI writes them and GET /config only
    # returns model fields — until they were declared the page could not read
    # its own stored values back and rendered these defaults instead. Their
    # readers (translator/_helpers.py) still go through config_entries, so the
    # defaults below must match the ones written there.
    translation_memory_enabled: bool = Field(
        default=True,
        description="Reuse previously translated lines from the translation memory.",
    )
    translation_memory_similarity_threshold: float = Field(
        default=1.0,
        ge=0.0,
        le=1.0,
        description=(
            "How close a stored line must be to count as a hit. 1.0 is exact "
            "match; lower values trade accuracy for reuse."
        ),
    )
    translation_quality_enabled: bool = Field(
        default=True,
        description=(
            "Score each translated line with the LLM and retry the ones below "
            "the threshold. Costs one extra model call per line scored."
        ),
    )
    translation_quality_threshold: int = Field(
        default=50,
        ge=0,
        le=100,
        description="Lines scoring below this are translated again.",
    )
    translation_quality_max_retries: int = Field(
        default=2,
        ge=0,
        le=5,
        description="How often a single low-scoring line may be retried.",
    )
    # Read by base_path.PrefixMiddleware, which strips it from the request path,
    # and injected into the served index.html as <base href>. Deliberately read
    # off this settings object rather than through get_config_entry: the
    # middleware runs before Flask pushes an application context.
    base_url: str = Field(
        default="",
        description=(
            "Path prefix when served behind a reverse proxy, e.g. /sublarr. "
            "Sublarr answers under the prefix and at the root, so a wrong "
            "value cannot lock you out of the settings page."
        ),
    )
    translation_context_enabled: bool = Field(
        default=True,
        description=(
            "Include lookback/lookahead subtitle lines as context when "
            "translating via LLM backends. Improves narrative coherence "
            "and pronoun resolution. Token cost: ~300 extra tokens per "
            "batch of 50 lines."
        ),
    )
    translation_context_lookback_lines: int = Field(
        default=10,
        ge=0,
        le=50,
        description="Lines BEFORE each batch included as context (LLM-only).",
    )
    translation_context_lookahead_lines: int = Field(
        default=5,
        ge=0,
        le=50,
        description="Lines AFTER each batch included as context (LLM-only).",
    )

    # Extended Security (Step 46)
    session_timeout_minutes: int = 0  # 0 = no timeout
    max_login_attempts: int = 20
    lockout_duration_minutes: int = 60
    allowed_ip_ranges: str = ""  # Comma-separated CIDR ranges; empty = allow all

    # Subtitle Health
    subtitle_health_enabled: bool = True
    subtitle_health_sweep_enabled: bool = True
    subtitle_health_auto_fix: bool = False

    @field_validator("cleanup_signs_removal_level")
    @classmethod
    def _validate_signs_level(cls, v: str) -> str:
        allowed = {"off", "signs", "signs_forced", "signs_forced_songs"}
        if v not in allowed:
            raise ValueError(f"cleanup_signs_removal_level must be one of {sorted(allowed)}")
        return v

    model_config = {"extra": "ignore"}


def _split_kwargs_by_membership(
    kwargs: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any]]:
    """Route caller-supplied kwargs into the boot vs. ui buckets by field name."""
    boot_kwargs: dict[str, Any] = {}
    ui_kwargs: dict[str, Any] = {}
    unknown: list[str] = []
    for key, value in kwargs.items():
        if key in BootSettings.model_fields:
            boot_kwargs[key] = value
        elif key in UISettings.model_fields:
            ui_kwargs[key] = value
        else:
            unknown.append(key)
    if unknown:
        raise TypeError(
            f"Settings(): unknown field(s) {sorted(unknown)!r}. "
            "Add them to BootSettings (env-loadable) or UISettings (DB-only)."
        )
    return boot_kwargs, ui_kwargs


class Settings:
    """Composite Settings — boot fields from ENV, UI fields from DB.

    Attribute access is forwarded to the right side automatically:
        settings.api_key                  → boot
        settings.opensubtitles_api_key    → ui
    so the ~370 existing call-sites keep working unchanged.

    Construction:
        Settings()                        — defaults for everything
        Settings(boot=..., ui=...)        — explicit composition
        Settings(api_key="x", port=80, …) — auto-routed by field membership
                                            (used by tests and reload_settings)
    """

    def __init__(
        self,
        boot: BootSettings | None = None,
        ui: UISettings | None = None,
        **kwargs: Any,
    ) -> None:
        if kwargs and (boot is not None or ui is not None):
            raise TypeError("Settings(): pass either kwargs OR boot/ui, not both.")
        if kwargs:
            boot_kwargs, ui_kwargs = _split_kwargs_by_membership(kwargs)
            self._boot = BootSettings(**boot_kwargs)
            self._ui = UISettings(**ui_kwargs)
        else:
            self._boot = boot if boot is not None else BootSettings()
            self._ui = ui if ui is not None else UISettings()

    @property
    def boot(self) -> BootSettings:
        return self._boot

    @property
    def ui(self) -> UISettings:
        return self._ui

    def __getattr__(self, name: str) -> Any:
        # __getattr__ is only invoked when normal attribute lookup fails,
        # so this never recurses into _boot / _ui / boot / ui / methods.
        if name.startswith("_"):
            raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
        if name in BootSettings.model_fields:
            return getattr(self._boot, name)
        if name in UISettings.model_fields:
            return getattr(self._ui, name)
        raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")

    def model_dump(self) -> dict[str, Any]:
        """Flat dump merging boot + ui fields (backwards-compat surface)."""
        return {**self._boot.model_dump(), **self._ui.model_dump()}

    def model_copy(self, *, update: dict[str, Any] | None = None) -> "Settings":
        """Return a copy. ``update`` is split across boot + ui by field membership."""
        if not update:
            return Settings(boot=self._boot.model_copy(), ui=self._ui.model_copy())
        boot_update, ui_update = _split_kwargs_by_membership(update)
        return Settings(
            boot=self._boot.model_copy(update=boot_update)
            if boot_update
            else self._boot.model_copy(),
            ui=self._ui.model_copy(update=ui_update) if ui_update else self._ui.model_copy(),
        )

    # --- Instance methods (preserved from pre-split Settings) ---

    def get_database_url(self) -> str:
        """Get the SQLAlchemy database URL.

        Returns database_url if set, otherwise constructs a SQLite URL from db_path.
        """
        if self._boot.database_url:
            return self._boot.database_url
        return f"sqlite:///{self._boot.db_path}"

    def get_prompt_template(
        self, source_lang: str | None = None, target_lang: str | None = None
    ) -> str:
        """Get the translation prompt template.

        Priority:
        1. Default prompt preset from database (if exists)
        2. prompt_template setting (if set)
        3. Auto-generated template

        ``source_lang``/``target_lang`` are the direction of the request being
        built. Without them the global languages apply (settings UI, config
        hash). Until 2026-09-16 the request's direction was never passed, so
        every Ollama batch asked for English to German — a de->en request came
        back as reworded German and was saved as an English subtitle.
        """
        from config_language_data import language_name

        source_name = language_name(source_lang) if source_lang else self._ui.source_language_name
        target_name = language_name(target_lang) if target_lang else self._ui.target_language_name
        try:
            from db.translation import get_default_prompt_preset

            preset = get_default_prompt_preset()
            if preset and preset.get("prompt_template"):
                template = preset["prompt_template"]
                template = template.replace("{source_language}", source_name)
                template = template.replace("{target_language}", target_name)
                return template
        except Exception as exc:
            logger.debug("Could not load default prompt preset: %s", exc)

        if self._ui.prompt_template:
            return self._ui.prompt_template.replace("{source_language}", source_name).replace(
                "{target_language}", target_name
            )

        # The input lines are numbered, and the output must carry the same
        # numbers back. Measured over 65 real batches on gemma3:12b: asking
        # for numbering instead of forbidding it took the batches that die
        # even after the strict retry from 10 to 1, and cut invented hard
        # breaks from 319 to 129. A blind third-model judge scored the two
        # templates level, so the mapping costs nothing in German.
        #
        # The numbers are not themselves a mapping — the model renumbers
        # contiguously once it merges two lines — they mark where one output
        # line ends, which is what llm_utils.repair_line_mapping needs to undo
        # a translation the model wrapped across two lines.
        return (
            f"Translate these anime subtitle lines from {source_name} to {target_name}.\n"
            f"Each input line is numbered. Return EXACTLY one output line per input line,\n"
            f"prefixed with the same number, in the form 'N: translation'.\n"
            f"Never renumber and never skip a number.\n"
            f"Never merge two input lines into one output line: an input line that is only\n"
            f"part of a sentence stays its own line, because the lines are timed subtitle\n"
            f"events, not prose.\n"
            f"Preserve \\N exactly as \\N (hard line break).\n"
            f"Return ONLY the numbered translations — no commentary.\n\n"
        )

    def get_target_patterns(self, fmt: str = "ass") -> list[str]:
        """Get file patterns for detecting existing target language subtitles."""
        lang_tags = _get_language_tags(self._ui.target_language)
        return [f".{tag}.{fmt}" for tag in lang_tags]

    def get_source_patterns(self, fmt: str = "ass") -> list[str]:
        """Get file patterns for detecting existing source language subtitles."""
        lang_tags = _get_language_tags(self._ui.source_language)
        return [f".{tag}.{fmt}" for tag in lang_tags]

    def get_target_lang_tags(self) -> set[str]:
        """Get all language tags for the target language."""
        return _get_language_tags(self._ui.target_language)

    def get_source_lang_tags(self) -> set[str]:
        """Get all language tags for the source language."""
        return _get_language_tags(self._ui.source_language)

    def get_translation_config_hash(self, backend_name: str = "ollama") -> str:
        """SHA256 hash of backend+model+prompt+target_language (first 12 chars)."""
        if backend_name == "ollama":
            content = f"{backend_name}|{self._ui.ollama_model}|{self.get_prompt_template()[:50]}|{self._ui.target_language}"
        else:
            content = f"{backend_name}||{self._ui.target_language}"
        return hashlib.sha256(content.encode()).hexdigest()[:12]

    def get_safe_config(self) -> dict:
        """Get config dict without sensitive values (API keys, passwords, tokens)."""
        import json as _json

        _JSON_BLOB_FIELDS = {
            "sonarr_instances_json",
            "radarr_instances_json",
            "media_servers_json",
        }
        _CREDENTIAL_SUBKEYS = {"api_key", "apiKey", "password", "token", "secret", "pin"}

        data = self.model_dump()
        for key in list(data.keys()):
            if is_sensitive_config_key(key):
                if data[key]:
                    data[key] = "***configured***"
                else:
                    data[key] = ""
            elif key == "notification_urls_json" and data[key]:
                data[key] = "***configured***"
            elif key in _JSON_BLOB_FIELDS and data[key]:
                try:
                    parsed = _json.loads(data[key])
                    if isinstance(parsed, list):
                        for item in parsed:
                            if isinstance(item, dict):
                                for sub in _CREDENTIAL_SUBKEYS:
                                    if sub in item and item[sub]:
                                        item[sub] = "***configured***"
                    data[key] = _json.dumps(parsed)
                except Exception:
                    data[key] = "***configured***"
        return data

    # --- Grouped settings views (read-only delegation) ---

    @property
    def general(self) -> "GeneralSettings":
        """View into general/infrastructure settings."""
        return GeneralSettings(self)

    @property
    def translation(self) -> "TranslationSettings":
        """View into LLM and translation settings."""
        return TranslationSettings(self)

    @property
    def providers(self) -> "ProviderSettings":
        """View into subtitle provider and credential settings."""
        return ProviderSettings(self)

    @property
    def media_servers(self) -> "MediaServerSettings":
        """View into *arr, media server, and ffmpeg settings."""
        return MediaServerSettings(self)

    @property
    def scanning(self) -> "ScanningSettings":
        """View into scanning, wanted, upgrade, and automation settings."""
        return ScanningSettings(self)


def warn_on_ignored_env_vars() -> list[str]:
    """Scan os.environ for SUBLARR_<ui_field>=... and log a per-key warning.

    UI-only fields silently dropped any env value before this function existed;
    the warning makes the migration loud so users notice they need to move the
    setting into the UI (Settings → ...). Boot fields are unaffected.

    Returns the list of ignored env-var names (for tests / startup metrics).
    """
    ignored: list[str] = []
    ui_fields = UISettings.model_fields
    for env_name in os.environ:
        if not env_name.startswith("SUBLARR_"):
            continue
        field_name = env_name.removeprefix("SUBLARR_").lower()
        if field_name in ui_fields:
            ignored.append(env_name)
            logger.warning(
                "%s is no longer ENV-configurable as of v0.88.0-beta. "
                "Sublarr is now UI-first — set this in the Settings page. "
                "The env value has been ignored.",
                env_name,
            )
    return ignored


__all__ = [
    "BootSettings",
    "Settings",
    "UISettings",
    "_ALLOWED_BOOT_FIELDS",
    "warn_on_ignored_env_vars",
]