372 lines
12 KiB
Python
372 lines
12 KiB
Python
"""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,
|
|
})
|