backend/config_settings.py
Rama master · réplica de lectura del repositorio original.
"""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",
]