backend/app.py
Rama master · réplica de lectura del repositorio original.
"""Application factory for Sublarr Flask API server.
Uses the Flask Application Factory pattern: create_app() builds and
configures the application, initializes extensions, registers blueprints,
and starts background schedulers.
The low-level machinery (logging, routes, shutdown handlers, scheduler
bootstrap) lives in sibling modules app_logging / app_routes_core /
app_shutdown / app_schedulers so this file stays focused on the factory
itself.
"""
import hmac
import logging
import os
from flask import Flask, g, request
from app_logging import ( # re-exported for back-compat (tests import from app)
LOG_FORMAT,
SocketIOLogHandler,
StructuredJSONFormatter,
_has_app_context,
_setup_logging,
stamp_log_fingerprint,
)
from app_routes_core import _register_app_routes
from app_schedulers import _start_schedulers
from app_shutdown import _register_shutdown_handler
from extensions import limiter, socketio
__all__ = [
"LOG_FORMAT",
"SocketIOLogHandler",
"StructuredJSONFormatter",
"_has_app_context",
"_setup_logging",
"_register_app_routes",
"_register_shutdown_handler",
"_start_schedulers",
"create_app",
"limiter",
"socketio",
]
def _patch_pre_alembic_columns(engine, inspect_fn) -> None:
"""Add columns missing from pre-Alembic databases.
create_all() is a no-op on existing tables, so DBs created before Alembic
was introduced may be missing columns added via later migrations.
"""
import logging as _logging
from sqlalchemy import text
_log = _logging.getLogger(__name__)
insp = inspect_fn(engine)
patches = []
if insp.has_table("subtitle_downloads"):
existing = {c["name"] for c in insp.get_columns("subtitle_downloads")}
if "source" not in existing:
patches.append(
"ALTER TABLE subtitle_downloads ADD COLUMN source TEXT DEFAULT 'provider'"
)
if "score_breakdown" not in existing:
patches.append("ALTER TABLE subtitle_downloads ADD COLUMN score_breakdown TEXT")
if "decision_log_json" not in existing:
patches.append("ALTER TABLE subtitle_downloads ADD COLUMN decision_log_json TEXT")
# Per-series ASS-only requirement (migration a4b5c6d7e8f9)
if insp.has_table("series_settings"):
existing = {c["name"] for c in insp.get_columns("series_settings")}
if "subtitle_format_requirement" not in existing:
patches.append(
"ALTER TABLE series_settings ADD COLUMN subtitle_format_requirement TEXT"
)
# Per-profile provider selection + scoring preset (migration f8b2c3d4e5a6)
if insp.has_table("language_profiles"):
existing = {c["name"] for c in insp.get_columns("language_profiles")}
if "enabled_providers_json" not in existing:
patches.append("ALTER TABLE language_profiles ADD COLUMN enabled_providers_json TEXT")
if "scoring_preset" not in existing:
patches.append("ALTER TABLE language_profiles ADD COLUMN scoring_preset TEXT")
# Quality-pass verdict on a memory row (migration tm5_quality_score).
if insp.has_table("translation_memory"):
existing = {c["name"] for c in insp.get_columns("translation_memory")}
if "quality_score" not in existing:
patches.append("ALTER TABLE translation_memory ADD COLUMN quality_score INTEGER")
# Split search/download success times (migration b7c8d9e0f1a2).
# A column added by a migration MUST be repeated here: an install whose
# alembic_version was stamped at head never replays the migration, and
# create_all() adds missing tables but never missing columns — so the model
# queries a column the database does not have and every worker dies at
# boot. That is exactly how the beta instance went down on 2026-08-09.
if insp.has_table("provider_stats"):
existing = {c["name"] for c in insp.get_columns("provider_stats")}
# The model declares DateTime(timezone=True). Spelling that as a bare
# TIMESTAMP on Postgres would create a column the ORM then compares
# against aware datetimes — the mixed-type trap that has produced 500s
# in this codebase before.
ts = "TIMESTAMP WITH TIME ZONE" if engine.dialect.name == "postgresql" else "TIMESTAMP"
for column in ("last_search_at", "last_download_at"):
if column not in existing:
patches.append(f"ALTER TABLE provider_stats ADD COLUMN {column} {ts}")
# Classified cause of the last failure (migration i201_failure_kind).
# Same rule as above: a migration-added column MUST be repeated here or
# a stamped-at-head install queries a column it does not have.
if "last_failure_kind" not in existing:
patches.append("ALTER TABLE provider_stats ADD COLUMN last_failure_kind VARCHAR(16)")
# Automation-queue task types (migrations f3a9c1e7b5d4, c7e1a9d4b6f3).
# The queue only held embedded extractions until sidecar translation and
# auto-sync moved onto it, each needing columns of its own. Missing them,
# every read of the table dies with "no such column" and the drain worker
# is dead at boot — which is what happened to the beta instance on
# 2026-08-28, the second outage of this exact shape.
#
# What this cannot restore is the unique key: the migrations widened it
# from (wanted_item_id) to (wanted_item_id, task_type, file_path), and
# SQLite can only do that by rebuilding the table. An untracked DB
# therefore still refuses a second task type for the same item. That is a
# narrower failure than the boot crash and is tracked separately.
if insp.has_table("subtitle_automation_queue"):
existing = {c["name"] for c in insp.get_columns("subtitle_automation_queue")}
if "task_type" not in existing:
# NOT NULL needs the default in the same statement — SQLite rejects
# adding a NOT NULL column without one, and existing rows are all
# extractions, which is what the migration backfills them to.
patches.append(
"ALTER TABLE subtitle_automation_queue ADD COLUMN task_type "
"VARCHAR(24) NOT NULL DEFAULT 'embedded_extract'"
)
if "source_language" not in existing:
patches.append(
"ALTER TABLE subtitle_automation_queue ADD COLUMN source_language VARCHAR(8)"
)
if "video_path" not in existing:
patches.append("ALTER TABLE subtitle_automation_queue ADD COLUMN video_path TEXT")
# Decision log snapshots (migration a7d3c9e1f5b2)
if insp.has_table("wanted_items"):
existing = {c["name"] for c in insp.get_columns("wanted_items")}
if "last_decision_log_json" not in existing:
patches.append("ALTER TABLE wanted_items ADD COLUMN last_decision_log_json TEXT")
# Track variant policy override columns + per-track verdicts (migration
# tvp1_track_variant_policy).
_TVP_COLUMNS = (
("cleanup_track_variant_mode", "VARCHAR(20)"),
("cleanup_keep_forced", "BOOLEAN"),
("cleanup_keep_sdh", "BOOLEAN"),
("cleanup_sidecar_policy", "VARCHAR(24)"),
)
for table in ("series_settings", "movie_settings"):
if insp.has_table(table):
existing = {c["name"] for c in insp.get_columns(table)}
for name, sql_type in _TVP_COLUMNS:
if name not in existing:
patches.append(f"ALTER TABLE {table} ADD COLUMN {name} {sql_type}")
if insp.has_table("foreign_track_scan"):
existing = {c["name"] for c in insp.get_columns("foreign_track_scan")}
if "track_verdicts" not in existing:
patches.append("ALTER TABLE foreign_track_scan ADD COLUMN track_verdicts TEXT")
# Fix round 1: radarr_movie_id on the automation queue (same migration,
# tvp1_track_variant_policy) — without it a movie's foreign_track_cleanup
# row has nowhere to carry the Radarr movie id, so the drain can never
# resolve a per-movie track-policy override.
if insp.has_table("subtitle_automation_queue"):
existing = {c["name"] for c in insp.get_columns("subtitle_automation_queue")}
if "radarr_movie_id" not in existing:
patches.append(
"ALTER TABLE subtitle_automation_queue ADD COLUMN radarr_movie_id INTEGER"
)
# series_key: the series owner key of an auto-sync row (migration
# sa3_automation_queue_series_key).
if "series_key" not in existing:
patches.append("ALTER TABLE subtitle_automation_queue ADD COLUMN series_key INTEGER")
if not patches:
return
with engine.connect() as conn:
for stmt in patches:
conn.execute(text(stmt))
conn.commit()
_log.info("Pre-Alembic DB: patched %d missing column(s)", len(patches))
_fallback_plugins_dir: str | None = None
def usable_plugins_dir(configured: str) -> str:
"""``configured``, or this process's private temp folder when it cannot be created.
The fallback used to be a fresh ``mkdtemp`` per app start that nobody
removed: every test run left thousands of them, and on 2026-10-30 they
used up the build host's /tmp inodes (103 251 folders). It stays a
``mkdtemp`` (unpredictable name, mode 0700) because plugins are code: a
fixed path in a shared /tmp could be planted by another local user. One
per process, removed at exit.
"""
global _fallback_plugins_dir
try:
os.makedirs(configured, exist_ok=True)
return configured
except PermissionError:
if _fallback_plugins_dir is None or not os.path.isdir(_fallback_plugins_dir):
import atexit
import shutil
import tempfile
_fallback_plugins_dir = tempfile.mkdtemp(prefix="sublarr_plugins_")
atexit.register(shutil.rmtree, _fallback_plugins_dir, True)
logging.getLogger(__name__).warning(
"Cannot create plugins_dir %s (permission denied), using %s",
configured,
_fallback_plugins_dir,
)
return _fallback_plugins_dir
def create_app(testing=False):
"""Create and configure the Flask application.
Args:
testing: If True, skip scheduler startup (for tests and verification).
Returns:
Configured Flask application instance.
"""
# static_folder=None disables Flask's built-in /<path:filename> route which
# would intercept SPA routes (e.g. /wanted) and return 404 before our
# catch-all serve_spa() can serve index.html. Static files are served by
# serve_spa() itself via send_from_directory("static", ...).
app = Flask(__name__, static_folder=None)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16 MB — prevent request body DoS
# Load config
from config import get_settings, reload_settings, warn_on_ignored_env_vars
settings = get_settings()
# Set up logging. Everything below needs a logger, so this runs before the
# config_entries overlay is read — which means `settings` here still holds
# only ENV and defaults. The instance fingerprint is therefore NOT stamped
# yet (stamp=False); it goes in once the overlay has been applied, further
# down, or it would describe a half-loaded config.
_setup_logging(settings, stamp=False)
logger = logging.getLogger(__name__)
# Warn (loudly, once per startup) about any SUBLARR_<ui_field> env vars
# the operator has set. Since v0.88.0-beta the env-loadable surface is
# the curated BootSettings allowlist; UI fields ignore env values
# silently otherwise — this loop turns that silence into a logged
# warning per ignored variable so the migration is visible.
warn_on_ignored_env_vars()
# Initialize SocketIO with the app. Same-origin is always allowed (matched
# against the Host header); SUBLARR_CORS_ORIGINS only adds foreign origins.
# See socketio_origins.py for why X-Forwarded-* is not trusted (#60).
from socketio_origins import build_origin_checker
socketio.init_app(
app,
cors_allowed_origins=build_origin_checker(getattr(settings, "cors_origins", "")),
async_mode="threading",
)
# Serve under the configured base_url as well as at the root. This has to
# come AFTER socketio.init_app: that call wraps app.wsgi_app in its own
# middleware, so whichever layer is applied last ends up outermost, and the
# prefix has to be off the path before Socket.IO looks at it. Applied
# first, Socket.IO saw "/<prefix>/socket.io/..." , did not recognise it and
# passed it on — the handshake then fell through to the SPA catch-all with
# status 200, which a websocket client cannot tell from a dead server.
# Reading the setting per request keeps it a normal setting (no restart),
# and leaving the un-prefixed paths alive means a wrong value cannot lock
# the user out of the page that fixes it.
from base_path import PrefixMiddleware
app.wsgi_app = PrefixMiddleware(app.wsgi_app)
# Register structured error handlers (SublarrError -> JSON, generic 500)
from error_handler import register_error_handlers
register_error_handlers(app)
# HTTP request observability. The metrics existed (metrics.py:
# HTTP_REQUEST_TOTAL with a status label, _IN_PROGRESS, and
# record_http_request) but nothing ever called them, so /api/v1/metrics
# only exposed in_progress and a rejected request left no trace at all —
# found 2026-09-03 stress-testing RC. Wiring the existing recorder here,
# plus --access-logfile on the gunicorn line, closes that blind spot.
import time as _time
try:
import metrics as _metrics
except Exception: # noqa: BLE001 - metrics are optional (prometheus_client)
_metrics = None
if _metrics is not None and getattr(_metrics, "METRICS_AVAILABLE", False):
@app.before_request
def _track_request_start():
g._req_start = _time.monotonic()
try:
_metrics.HTTP_REQUESTS_IN_PROGRESS.inc()
g._req_counted = True
except Exception: # noqa: BLE001 - never let telemetry break a request
pass
@app.teardown_request
def _track_request_end(exc):
# teardown always runs, even when a view raised, so the in-progress
# gauge cannot leak upward on an error path. But teardown can also
# fire for a request that never reached before_request, so only
# decrement what this request actually incremented — otherwise the
# gauge drifts negative.
if g.pop("_req_counted", False):
try:
_metrics.HTTP_REQUESTS_IN_PROGRESS.dec()
except Exception: # noqa: BLE001
pass
@app.after_request
def _record_request_metric(response):
try:
start = g.pop("_req_start", None)
duration = _time.monotonic() - start if start is not None else 0.0
# Label by the ROUTE TEMPLATE, never the concrete path — the
# raw path would mint a series per id and explode cardinality.
# A 404 has no matched rule, so it collapses to one bucket.
endpoint = request.url_rule.rule if request.url_rule else "<unmatched>"
_metrics.record_http_request(
request.method, endpoint, str(response.status_code), duration
)
except Exception: # noqa: BLE001 - telemetry must not alter the response
pass
return response
@app.after_request
def add_security_headers(response):
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
# CSP: allow self + inline styles + ws/wss for SocketIO.
# script-src is strict (no 'unsafe-inline') for the SPA — its only inline
# script (the pre-paint theme bootstrap) was moved to /theme-init.js so an
# injected inline <script> can no longer execute. The Swagger UI bundle at
# /api/docs* still ships inline bootstrap scripts, so that path keeps the
# relaxed directive scoped to itself.
# media-src needs blob: because the Waveform editor (WaveSurfer.js) fetches the
# extracted audio from /api/v1/tools/waveform-audio/, wraps it in URL.createObjectURL(),
# and feeds it into an internal <audio> element — which the browser charges against
# media-src, not connect-src.
if request.path.startswith("/api/docs"):
script_src = "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; "
else:
script_src = "script-src 'self' 'wasm-unsafe-eval'; "
response.headers.setdefault(
"Content-Security-Policy",
(
"default-src 'self'; " + script_src + "style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"media-src 'self' blob:; "
"connect-src 'self' ws: wss:; "
"font-src 'self' data:; "
"frame-ancestors 'none'"
),
)
response.headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()",
)
return response
# Initialize rate limiter. In-memory counters are per-process, so under a
# multi-worker Gunicorn deployment every worker keeps its own tally and the
# effective limit becomes N×. When a Redis URL is configured, use it as the
# shared storage backend so limits hold across workers (brute-force guard).
_redis_url = getattr(settings, "redis_url", None)
if _redis_url:
app.config["RATELIMIT_STORAGE_URI"] = _redis_url
limiter.init_app(app)
# Initialize authentication
from auth import init_auth
from routes.auth_ui import auth_ui_bp
from ui_auth import init_ui_auth
init_auth(app)
# ---- Flask-SQLAlchemy + Alembic initialization ----
app.config["SQLALCHEMY_DATABASE_URI"] = settings.get_database_url()
# Only set pool options for non-SQLite (SQLite uses StaticPool)
if settings.database_url and not settings.database_url.startswith("sqlite"):
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
"pool_size": settings.db_pool_size,
"max_overflow": settings.db_pool_max_overflow,
"pool_recycle": settings.db_pool_recycle,
"pool_pre_ping": True,
}
else:
# SQLite: use check_same_thread=False for thread safety
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
"connect_args": {"check_same_thread": False},
}
from extensions import db as sa_db
from extensions import migrate as sa_migrate
sa_db.init_app(app)
sa_migrate.init_app(app, sa_db, directory="db/migrations", render_as_batch=True)
with app.app_context():
# Import all models so they register with metadata
# For new databases: create all tables (skip if Alembic already tracks schema)
from sqlalchemy import inspect as _inspect
import db.models # noqa: F401
if not _inspect(sa_db.engine).has_table("alembic_version"):
sa_db.create_all()
# NB: "untracked", not necessarily "new" — any DB that was first
# created by create_all() never gets an alembic_version table, so
# it takes this branch on every start for its whole lifetime and
# relies on _patch_pre_alembic_columns() below for new columns.
logger.info("Alembic-untracked DB: ran create_all()")
# Pre-Alembic DB: patch any columns that were added via Alembic migrations
# but are missing because create_all() is a no-op on existing tables.
_patch_pre_alembic_columns(sa_db.engine, _inspect)
# Data migrations never reach this branch through Alembic; apply
# each one exactly once here (VM test of 1.14.3-rc.2).
try:
from db.untracked_data_repairs import apply_untracked_data_repairs
apply_untracked_data_repairs(sa_db.engine)
except Exception as _e:
logger.warning("Untracked DB data repairs skipped: %s", _e, exc_info=True)
else:
# Run pending Alembic migrations automatically so new columns are
# always present. A failure is not fatal for startup, but it is
# recorded so /health reports unhealthy instead of serving on a
# stale schema while claiming to be fine.
from db.migration_state import EXTENSION_KEY, run_startup_migrations
app.extensions[EXTENSION_KEY] = run_startup_migrations(
sa_db.engine, os.path.join(os.path.dirname(__file__), "db", "migrations")
)
# ai_quality_results is created idempotently rather than via a migration.
# This dates from the 2026-07-30 beta batch, when the chain still had
# diverged heads; they were unified in eeb79287c3b6 and `upgrade head`
# now succeeds, so the original reason no longer applies. The checkfirst
# create is kept because it is also what gives Alembic-untracked DBs
# (see the create_all branch above) this table at all.
# TODO: fold into a real migration and drop this once untracked DBs are
# stamped and migrated like every other install (ROADMAP).
try:
from db.models.quality import AIQualityResult
AIQualityResult.__table__.create(bind=sa_db.engine, checkfirst=True)
except Exception as _e:
logger.warning("Could not ensure ai_quality_results table: %s", _e)
# circuit_breaker_states is created by migration d5e6f7a8b9c0, but prod
# and RC are stamped *downstream* of it and still lack the table — their
# schema and alembic_version drifted apart at some point, so Alembic
# considers a migration applied that never ran. Repair it idempotently
# instead of hand-stamping revisions: checkfirst is a no-op wherever the
# table is already present.
try:
from db.models.circuit_breaker import CircuitBreakerState
CircuitBreakerState.__table__.create(bind=sa_db.engine, checkfirst=True)
except Exception as _e:
logger.warning("Could not ensure circuit_breaker_states table: %s", _e)
# Enable SQLite WAL mode if using SQLite (match existing behavior)
if not settings.database_url or settings.database_url.startswith("sqlite"):
from sqlalchemy import text
with sa_db.engine.connect() as conn:
conn.execute(text("PRAGMA journal_mode=WAL"))
conn.execute(text("PRAGMA busy_timeout=5000"))
conn.commit()
# Diagnostic: the #1 cause of "Sublarr is painfully slow" reports is
# a SQLite DB on a microSD card or network share. Time a few commits
# and warn if the /config volume is slow. Best-effort — never fatal.
try:
from db.storage_probe import warn_if_slow_storage
warn_if_slow_storage(sa_db.engine)
except Exception as _e:
logger.debug("Storage probe skipped: %s", _e)
# Initialize FTS5 search tables (virtual tables for global search)
from db.search import init_search_tables, rebuild_search_index
init_search_tables()
try:
rebuild_search_index()
logger.info("FTS5 search index rebuilt on startup")
except Exception as _e:
logger.warning("FTS5 search index rebuild failed (non-fatal): %s", _e)
# Initialize cache and queue backends
from cache import create_cache_backend
from job_queue import create_job_queue
app.cache_backend = create_cache_backend(
settings.redis_url if settings.redis_cache_enabled else ""
)
app.job_queue = create_job_queue(
settings.redis_url if settings.redis_queue_enabled else "",
max_workers=getattr(settings, "translation_max_workers", 4),
app=app,
)
# Publish the media IO gate limit on /metrics from the first scrape on;
# the module is otherwise imported lazily by the first heavy subprocess.
from services.media_io_gate import media_io_gate
media_io_gate.refresh_from_settings()
# Fail-loud on the silent-hang trap: an RQ backend with zero registered
# workers accepts jobs and never runs them (no crash, no log). Surface
# it at startup so a misconfigured deployment is obvious instead of
# translation quietly doing nothing.
try:
_q_info = app.job_queue.get_backend_info()
if _q_info.get("type") == "rq" and not _q_info.get("workers"):
logger.error(
"Job queue is RQ but NO rq workers are registered — queued jobs "
"(translation, batch search) will hang forever. Start `python "
"worker.py` (see docker-compose.redis.yml) or set "
"redis_queue_enabled=false to use the in-process queue."
)
except Exception as _e:
logger.debug("Could not inspect job queue backend info: %s", _e)
# Initialize database (legacy -- no-op now that SQLAlchemy handles lifecycle)
from db import init_db
init_db()
# Seed built-in cleanup rules that should exist on every fresh install.
# Non-fatal: a failure here must never prevent the app from starting.
try:
from db.repositories.cleanup import CleanupRepository
CleanupRepository().ensure_default_rules()
logger.debug("Cleanup default rules seeded")
except Exception as _e:
logger.warning("Cleanup default rules seed failed (non-fatal): %s", _e)
# Remove duplicate wanted_items rows that may exist in databases created
# before the uq_wanted_file_lang_type UNIQUE constraint was added.
# Keeps the row with the lowest rowid (earliest insert) for each
# (file_path, target_language, subtitle_type) combination.
try:
from sqlalchemy import text as _text
with sa_db.engine.connect() as _conn:
_dedup_result = _conn.execute(
_text(
"DELETE FROM wanted_items WHERE id NOT IN ("
" SELECT MIN(id) FROM wanted_items"
" GROUP BY file_path,"
" COALESCE(target_language, ''),"
" COALESCE(subtitle_type, 'full')"
")"
)
)
_conn.commit()
if _dedup_result.rowcount:
logger.warning(
"wanted_items dedup: removed %d duplicate row(s) on startup",
_dedup_result.rowcount,
)
else:
logger.debug("wanted_items dedup: no duplicates found")
except Exception as _e:
logger.warning("wanted_items dedup failed (non-fatal): %s", _e)
# Mark any jobs stuck in "running" state as failed (zombie cleanup after crash/restart)
if not testing:
try:
from db.jobs import get_jobs, update_job
zombie_page = get_jobs(status="running", per_page=200)
for zombie in zombie_page.get("data", []):
update_job(zombie["id"], "failed", error="Server restarted — job interrupted")
logger.info("Cleaned up zombie job %s", zombie["id"])
except Exception as e:
logger.warning("Zombie job cleanup failed: %s", e)
# Initialize event system (SocketIO bridge + hook/webhook subscribers)
from events import init_event_system
from events.hooks import HookEngine, init_hook_subscribers
from events.webhooks import WebhookDispatcher, init_webhook_subscribers
init_event_system(app)
hook_engine = HookEngine(max_workers=4, app=app)
init_hook_subscribers(hook_engine)
app.extensions["hook_engine"] = hook_engine
webhook_dispatcher = WebhookDispatcher(max_workers=4, app=app)
init_webhook_subscribers(webhook_dispatcher)
app.extensions["webhook_dispatcher"] = webhook_dispatcher
# Apply DB config overrides on startup (settings saved via UI take precedence)
from db.config import get_all_config_entries, save_config_entry
_db_overrides = get_all_config_entries()
if _db_overrides:
logger.info("Applying %d config overrides from database", len(_db_overrides))
settings = reload_settings(_db_overrides)
# Logging was set up above from ENV/defaults, before the DB was
# read. Re-apply now so any UI-persisted logging setting takes
# effect on startup (idempotent handler rebuild).
from app_logging import LOGGING_CONFIG_KEYS
if any(k in _db_overrides for k in LOGGING_CONFIG_KEYS):
_setup_logging(settings)
else:
logger.info("No config overrides in database, using env/defaults")
# Settings are complete now, so the log file can state which instance
# wrote it. A no-op if the re-apply above already stamped this path.
stamp_log_fingerprint()
# Pre-1.18 standalone rows are not moved: they are read where they are
# until a title's first change (services/standalone_owner_keys.py).
# Auto-generate API key on first start if not set via env or DB.
# Skip when SUBLARR_API_KEY is explicitly set (even to "") — that means
# the operator consciously chose to disable API-key auth (also covers tests).
if not settings.api_key and "SUBLARR_API_KEY" not in os.environ:
import secrets
_generated_key = secrets.token_hex(32)
save_config_entry("api_key", _generated_key)
settings = reload_settings(get_all_config_entries())
logger.info("API key auto-generated on first start (64 hex chars)")
# Webhooks get their own secret so Sonarr/Radarr/Jellyfin need not hold
# the API key (#71). Generated once; regenerated only on request. Not
# in test apps: like the API key above, a generated secret is a row the
# test did not write (it broke the encrypt-at-rest migration tests).
if not getattr(settings, "webhook_secret", "") and not testing:
import secrets
save_config_entry("webhook_secret", secrets.token_hex(32))
settings = reload_settings(get_all_config_entries())
logger.info("Webhook secret generated (64 hex chars)")
# Warn when both auth mechanisms are disabled — all API endpoints are public.
# This is intentional for trusted-LAN / reverse-proxy deployments, but operators
# should be aware of the exposure. See PENTEST_FINDINGS.md F-17.
import ui_auth as _ui_auth
_ui_auth_enabled = _ui_auth.is_ui_auth_enabled()
if not settings.api_key and not _ui_auth_enabled:
logger.warning(
"SECURITY WARNING: No authentication is configured (api_key is empty and "
"UI auth is disabled). All API endpoints are publicly accessible. "
"Set SUBLARR_API_KEY or enable UI auth if this is not intentional."
)
# Initialize plugin system
plugins_dir = getattr(settings, "plugins_dir", "")
if plugins_dir:
plugins_dir = usable_plugins_dir(plugins_dir)
from providers.plugins import init_plugin_manager
plugin_mgr = init_plugin_manager(plugins_dir)
loaded, plugin_errors = plugin_mgr.discover()
if loaded:
logger.info("Loaded %d plugins: %s", len(loaded), loaded)
if plugin_errors:
for err in plugin_errors:
logger.warning("Plugin load error: %s -- %s", err["file"], err["error"])
# Start hot-reload watcher if enabled (optional -- watchdog must be installed)
if not testing and getattr(settings, "plugin_hot_reload", False):
try:
from providers.plugins.watcher import start_plugin_watcher
start_plugin_watcher(plugin_mgr, plugins_dir)
logger.info("Plugin hot-reload watcher started on %s", plugins_dir)
except ImportError:
logger.warning("watchdog not installed, plugin hot-reload disabled")
# Initialize media server manager (loads configured instances)
try:
from mediaserver import get_media_server_manager
ms_manager = get_media_server_manager()
ms_manager.load_instances()
types = ms_manager.get_all_server_types()
logger.info("Media server manager initialized: %d types registered", len(types))
except Exception as e:
logger.warning("Media server manager initialization failed: %s", e)
# Initialize standalone manager (folder watching + scanning)
try:
from config import is_standalone_mode as _is_standalone_mode
if _is_standalone_mode():
from standalone import get_standalone_manager
get_standalone_manager()
logger.info("Standalone manager initialized")
except Exception as e:
logger.warning("Standalone manager initialization failed: %s", e)
# Bazarr deprecation warning
if os.environ.get("SUBLARR_BAZARR_URL") or os.environ.get("SUBLARR_BAZARR_API_KEY"):
logger.warning(
"DEPRECATION: SUBLARR_BAZARR_URL/SUBLARR_BAZARR_API_KEY are set but Bazarr "
"integration has been removed. Sublarr now has its own provider system."
)
# Register blueprints
from routes import register_blueprints
register_blueprints(app)
# Register OpenAPI spec (must be after register_blueprints)
from openapi import register_all_paths
register_all_paths(app)
# Register Swagger UI blueprint
from flask_swagger_ui import get_swaggerui_blueprint
swagger_bp = get_swaggerui_blueprint(
"/api/docs",
"/api/v1/openapi.json",
config={"app_name": "Sublarr API", "layout": "BaseLayout"},
)
app.register_blueprint(swagger_bp)
# Register app-level routes (metrics, SPA fallback)
_register_app_routes(app)
# Register SocketIO events
@socketio.on("connect")
def handle_connect(auth):
import ui_auth as _ui_auth_ws
from config import get_settings as _gs
_api_key = getattr(_gs(), "api_key", None)
if _api_key:
# Same contract as the HTTP API gate: a logged-in UI session
# counts. The Socket.IO handshake is an ordinary HTTP request
# carrying the session cookie, so a browser that authenticated
# via /auth/login but holds no key in localStorage (fresh
# profile, or stale key after a server-side key change) must
# not lose the WebSocket silently — that degraded log
# streaming and live updates without any visible error.
if _ui_auth_ws.has_valid_session():
logger.debug("WebSocket client connected (UI session)")
return None
provided = (auth or {}).get("apikey", "")
if not hmac.compare_digest(provided, _api_key):
logger.warning("WebSocket connection rejected: invalid API key")
return False
logger.debug("WebSocket client connected")
@socketio.on("disconnect")
def handle_disconnect():
logger.debug("WebSocket client disconnected")
# Accept (and ignore) any payload the client sends with the event.
# flask-socketio forwards the emitted message data as a positional
# argument, so a zero-arg handler raises "takes 0 positional arguments
# but 1 was given" and the room join silently fails — dropping the Logs
# page back to polling. `*_args` keeps the handler tolerant either way.
@socketio.on("subscribe_logs")
def _subscribe_logs(*_args):
from flask_socketio import join_room
join_room("logs")
@socketio.on("unsubscribe_logs")
def _unsubscribe_logs(*_args):
from flask_socketio import leave_room
leave_room("logs")
# Register UI auth blueprint
app.register_blueprint(auth_ui_bp)
# Start schedulers (skip during testing)
if not testing:
_start_schedulers(settings, app)
# Initialize UI auth (sets SECRET_KEY + before_request hook — must be LAST)
init_ui_auth(app)
# Register singletons in app.extensions for lifecycle visibility and test injection
from providers import get_provider_manager as _gpm
from services.wanted_scanner import get_scanner as _gs
app.extensions["wanted_scanner"] = _gs()
app.extensions["provider_manager"] = _gpm()
# Register graceful shutdown handler (SIGTERM from Docker/Gunicorn)
if not testing:
_register_shutdown_handler(app)
return app
if __name__ == "__main__":
import os as _os
from config import get_settings
app = create_app()
_debug = _os.environ.get("FLASK_DEBUG", "0") == "1"
socketio.run(
app, host="0.0.0.0", port=get_settings().port, debug=_debug, allow_unsafe_werkzeug=_debug
)