"""Flask app factory.""" from __future__ import annotations import json import logging import time from flask import Flask, jsonify, render_template from . import queries, scanner from .config import Config from .db import Database from .scoring import register_sqlite_functions log = logging.getLogger(__name__) # Shipped read-only so the first run has somewhere useful to start (§7.2). SEED_VIEWS = [ ("Confident reclaim", "Added while watch history was being recorded, and never played since. " "The list to act on first.", {"op": "and", "rules": [ {"field": "watch_count", "op": "eq", "value": 0}, {"field": "pre_history", "op": "eq", "value": False}]}, "reclaim_score:desc"), ("Uncertain reclaim", "Never played, but added before history began — it may have been watched " "and nobody can tell. Needs judgement, not a bulk action.", {"op": "and", "rules": [ {"field": "watch_count", "op": "eq", "value": 0}, {"field": "pre_history", "op": "eq", "value": True}]}, "size_bytes:desc"), ("Never watched, large", "Nothing has ever played it and it is over 8 GB.", {"op": "and", "rules": [ {"field": "watch_count", "op": "eq", "value": 0}, {"field": "size_bytes", "op": "gte", "value": 8 * 1024**3}]}, "size_bytes:desc"), ("Cold storage", "Not watched in over three years.", {"op": "and", "rules": [ {"field": "last_watched_at", "op": "older_than_days", "value": 1095}]}, "size_bytes:desc"), ("One-and-done movies", "Watched exactly once, over two years ago.", {"op": "and", "rules": [ {"field": "kind", "op": "eq", "value": "movie"}, {"field": "watch_count", "op": "eq", "value": 1}, {"field": "last_watched_at", "op": "older_than_days", "value": 730}]}, "size_bytes:desc"), ("Tried and rejected", "Two or more people started it and nobody finished it. Needs Tautulli.", {"op": "and", "rules": [ {"field": "abandoned_count", "op": "gte", "value": 2}, {"field": "watch_count", "op": "eq", "value": 0}]}, "size_bytes:desc"), ("Abandoned seasons", "Seasons barely watched through, added over a year ago.", {"op": "and", "rules": [ {"field": "kind", "op": "eq", "value": "season"}, {"field": "watch_ratio", "op": "lt", "value": 0.3}, {"field": "added_at", "op": "older_than_days", "value": 365}]}, "size_bytes:desc"), ("Biggest 100", "Simply the largest things on disk.", {}, "size_bytes:desc"), ("Recently added", "The sanity check, not a delete list.", {"op": "and", "rules": [ {"field": "added_at", "op": "newer_than_days", "value": 30}]}, "added_at:desc"), ("Kept", "What you have decided to keep, and what it costs.", {"op": "and", "rules": [{"field": "kept", "op": "eq", "value": True}]}, "size_bytes:desc"), ] def seed_views(db) -> None: now = int(time.time()) for name, desc, rules, sort in SEED_VIEWS: if db.one("SELECT id FROM saved_view WHERE name = ?", (name,)): continue db.execute( "INSERT INTO saved_view (name, description, rules, sort, columns, weights, " "builtin, created_at, updated_at) VALUES (?,?,?,?,'[]','{}',1,?,?)", (name, desc, json.dumps(rules), sort, now, now)) def create_app(config: Config | None = None, *, start_scheduler: bool = True) -> Flask: cfg = config or Config.from_env() logging.basicConfig( level=getattr(logging, cfg.log_level, logging.INFO), format="%(asctime)s %(levelname)-7s %(name)s: %(message)s") app = Flask(__name__) app.config["SECRET_KEY"] = cfg.secret_key app.config["SESSION_COOKIE_SAMESITE"] = "Strict" app.config["JSON_SORT_KEYS"] = False db = Database(cfg.database_path) db.migrate() register_sqlite_functions(db.conn) seed_views(db) app.extensions["mediashelf"] = { "config": cfg, "db": db, "degraded_reason": None, "started_at": int(time.time()), } @app.before_request def _ensure_functions(): # Each thread gets its own connection, so LOG() must be registered on it. register_sqlite_functions(db.conn) from .api import bp as api_bp app.register_blueprint(api_bp) from .web import bp as web_bp app.register_blueprint(web_bp) @app.get("/healthz") def healthz(): ext = app.extensions["mediashelf"] out = {"status": "ok", "db": "ok", "plex": "unknown", "tautulli": "disabled"} try: db.scalar("SELECT 1") except Exception as e: # noqa: BLE001 out["status"], out["db"] = "error", str(e) row = db.one("SELECT * FROM scan WHERE status='succeeded' ORDER BY id DESC LIMIT 1") out["last_scan_at"] = row["finished_at"] if row else None out["stale"] = bool(row is None or (time.time() - (row["finished_at"] or 0)) > 48 * 3600) cov = db.one("SELECT source FROM history_coverage ORDER BY event_count DESC LIMIT 1") out["history_source"] = cov["source"] if cov else None out["plex"] = "configured" if cfg.plex_configured else "unconfigured" out["tautulli"] = "configured" if cfg.tautulli_configured else "disabled" out["degraded"] = ext.get("degraded_reason") return jsonify(out), (200 if out["status"] == "ok" else 503) @app.errorhandler(404) def _404(e): return jsonify({"error": "not_found"}), 404 @app.errorhandler(500) def _500(e): log.exception("unhandled error") return jsonify({"error": "internal_error"}), 500 if start_scheduler: scanner.start_scheduler(app) return app