"""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 / 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_ 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 "//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 "" _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