"""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 # _; 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 . 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_=... 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", ]