"""Settings API endpoints.""" import json from pathlib import Path from quart import Blueprint, request, jsonify, current_app from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.setting import Setting, DEFAULT_SETTINGS, EXTENSION_API_KEY_SETTING, STORAGE_STATS_SETTING, generate_api_key from app.config import get_settings bp = Blueprint("settings", __name__) async def get_cached_storage_stats(session: AsyncSession) -> dict: """Get cached storage statistics from the database. Returns cached stats if available, otherwise returns placeholder. Stats are calculated by a background Celery task. """ result = await session.execute( select(Setting).where(Setting.key == STORAGE_STATS_SETTING) ) setting = result.scalar_one_or_none() if setting and setting.value: return setting.value # No cached stats yet - return placeholder return { "total_size": 0, "total_size_formatted": "Calculating...", "file_count": 0, "calculated_at": None, } async def get_or_create_extension_api_key(session: AsyncSession) -> str: """Get the extension API key, creating one if it doesn't exist.""" result = await session.execute( select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING) ) setting = result.scalar() if setting: return setting.value # Generate new key new_key = generate_api_key() setting = Setting(key=EXTENSION_API_KEY_SETTING, value=new_key) session.add(setting) await session.commit() return new_key async def get_setting_value(session: AsyncSession, key: str, default=None): """Get a setting value from the database, falling back to defaults. Args: session: Database session key: Setting key (e.g., 'download.schedule_interval') default: Fallback if not in database or DEFAULT_SETTINGS Returns: The setting value """ result = await session.execute( select(Setting).where(Setting.key == key) ) setting = result.scalar() if setting: return setting.value # Fall back to DEFAULT_SETTINGS, then to provided default return DEFAULT_SETTINGS.get(key, default) @bp.route("", methods=["GET"]) async def get_all_settings(): """Get all application settings.""" async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) result = await session.execute(select(Setting)) settings = result.scalars().all() # Build settings dict with defaults settings_dict = dict(DEFAULT_SETTINGS) for s in settings: settings_dict[s.key] = s.value # Ensure extension API key exists api_key = await get_or_create_extension_api_key(session) settings_dict[EXTENSION_API_KEY_SETTING] = api_key # Get cached storage statistics (calculated by background task) storage_stats = await get_cached_storage_stats(session) return jsonify({"settings": settings_dict, "storage_stats": storage_stats}) @bp.route("", methods=["PATCH"]) async def update_settings(): """Update application settings.""" data = await request.get_json() if not data: return jsonify({"error": "No settings provided"}), 400 async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) for key, value in data.items(): # Check if setting exists result = await session.execute(select(Setting).where(Setting.key == key)) setting = result.scalar() if setting: current_app.logger.info(f"Updating setting '{key}': {setting.value} -> {value} (type: {type(value).__name__})") setting.value = value else: current_app.logger.info(f"Creating setting '{key}': {value} (type: {type(value).__name__})") setting = Setting(key=key, value=value) session.add(setting) await session.commit() # Return updated settings result = await session.execute(select(Setting)) settings = result.scalars().all() settings_dict = dict(DEFAULT_SETTINGS) for s in settings: settings_dict[s.key] = s.value current_app.logger.info(f"Updated settings: {list(data.keys())}") return jsonify({"settings": settings_dict}) @bp.route("/api-key", methods=["GET"]) async def get_extension_api_key(): """Get the extension API key.""" async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) api_key = await get_or_create_extension_api_key(session) return jsonify({"api_key": api_key}) @bp.route("/api-key/regenerate", methods=["POST"]) async def regenerate_extension_api_key(): """Regenerate the extension API key.""" async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) # Find existing key result = await session.execute( select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING) ) setting = result.scalar() # Generate new key new_key = generate_api_key() if setting: setting.value = new_key else: setting = Setting(key=EXTENSION_API_KEY_SETTING, value=new_key) session.add(setting) await session.commit() current_app.logger.info("Extension API key regenerated") return jsonify({"api_key": new_key, "message": "API key regenerated"}) @bp.route("/gallery-dl", methods=["GET"]) async def get_gallery_dl_config(): """Get gallery-dl configuration.""" config = get_settings() config_path = Path(config.config_path) / "gallery-dl.conf" if not config_path.exists(): return jsonify({"config": {}, "message": "No configuration file found"}) try: with open(config_path) as f: gallery_dl_config = json.load(f) return jsonify({"config": gallery_dl_config}) except json.JSONDecodeError as e: return jsonify({"error": f"Invalid JSON in config file: {e}"}), 500 @bp.route("/gallery-dl", methods=["PUT"]) async def update_gallery_dl_config(): """Update gallery-dl configuration.""" data = await request.get_json() if not data.get("config"): return jsonify({"error": "Config object is required"}), 400 config = get_settings() config_path = Path(config.config_path) / "gallery-dl.conf" # Ensure config directory exists config_path.parent.mkdir(parents=True, exist_ok=True) try: with open(config_path, "w") as f: json.dump(data["config"], f, indent=4) current_app.logger.info("Updated gallery-dl configuration") return jsonify({"message": "Configuration updated", "config": data["config"]}) except Exception as e: current_app.logger.error(f"Failed to write gallery-dl config: {e}") return jsonify({"error": f"Failed to write config: {e}"}), 500 @bp.route("/storage-stats/refresh", methods=["POST"]) async def refresh_storage_stats(): """Trigger a refresh of storage statistics. This queues a background task to recalculate storage stats. """ from app.tasks.maintenance import update_storage_stats try: # Queue the task update_storage_stats.delay() current_app.logger.info("Queued storage stats refresh") return jsonify({"message": "Storage stats refresh queued"}) except Exception as e: current_app.logger.error(f"Failed to queue storage stats refresh: {e}") return jsonify({"error": str(e)}), 500 @bp.route("/logs", methods=["GET"]) async def get_logs(): """Get recent download logs from download metadata. Returns the stdout/stderr from recent downloads for debugging. """ from app.models.download import Download from sqlalchemy import desc limit = min(int(request.args.get("limit", 50)), 200) async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) # Get recent downloads with output logs query = select(Download).order_by(desc(Download.created_at)).limit(limit) result = await session.execute(query) downloads = result.scalars().all() logs = [] for dl in downloads: metadata = dl.metadata_ or {} if metadata.get("stdout") or metadata.get("stderr") or dl.error_message: logs.append({ "id": dl.id, "url": dl.url, "status": dl.status, "created_at": dl.created_at.isoformat() + "Z" if dl.created_at else None, "stdout": metadata.get("stdout", ""), "stderr": metadata.get("stderr", ""), "error_message": dl.error_message, }) return jsonify({"logs": logs, "count": len(logs)}) @bp.route("/system-info", methods=["GET"]) async def get_system_info(): """Get system information including dependency versions.""" import sys import importlib.metadata config = get_settings() # Get all installed packages (preserve original names for display) all_packages = {} # Also build a normalized lookup (lowercase, underscores normalized to hyphens) normalized_lookup = {} for dist in importlib.metadata.distributions(): name = dist.metadata["Name"] if name: all_packages[name] = dist.version # Normalize: lowercase and replace underscores with hyphens normalized = name.lower().replace("_", "-") normalized_lookup[normalized] = dist.version def get_version(name): """Get version with case-insensitive and underscore/hyphen-insensitive lookup.""" normalized = name.lower().replace("_", "-") return normalized_lookup.get(normalized, "not installed") # Key packages to highlight (sorted alphabetically) key_package_names = [ "alembic", "asyncpg", "celery", "gallery-dl", "hypercorn", "pydantic", "quart", "redis", "sqlalchemy", ] key_packages = { name: get_version(name) for name in key_package_names } return jsonify({ "version": "1.0.0", "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", "python_full": sys.version, "gallery_dl_version": get_version("gallery-dl"), "key_packages": key_packages, "all_packages": all_packages, "download_path": config.download_path, "config_path": config.config_path, }) @bp.route("/debug/schedule-interval", methods=["GET"]) async def debug_schedule_interval(): """Debug endpoint to verify schedule_interval setting storage and retrieval. Returns detailed information about how the setting is stored and what value the worker would read. """ async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) # Query the raw setting from database result = await session.execute( select(Setting).where(Setting.key == "download.schedule_interval") ) setting = result.scalar() if setting: raw_value = setting.value db_info = { "found_in_database": True, "raw_value": raw_value, "raw_type": type(raw_value).__name__, "as_int": int(raw_value) if raw_value is not None else None, "updated_at": setting.updated_at.isoformat() if setting.updated_at else None, } else: db_info = { "found_in_database": False, "raw_value": None, "raw_type": None, "as_int": None, "updated_at": None, } # What the worker would use fallback_value = DEFAULT_SETTINGS.get("download.schedule_interval", 3600) worker_would_use = db_info["as_int"] if db_info["found_in_database"] else fallback_value return jsonify({ "database": db_info, "default_settings_value": fallback_value, "worker_would_use": worker_would_use, "worker_would_use_hours": worker_would_use / 3600 if worker_would_use else None, })