This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
GallerySubscriber/backend/app/api/settings.py
T
bvandeusen 6d875662fa feat(maintenance): add library-validation sweep task and API
Walks the downloads tree and reports files that fail magic-byte
validation — the "how many pre-existing files in my library are
silently truncated?" question, answered. Skips the _quarantine
subtree (already known-bad) and caps suspect_paths at 500 with a
truncated flag so the persisted report can't grow unbounded.

On-demand only: a full filesystem walk on a NAS-mounted library is
expensive and the user should choose when to pay for it. Pre-existing
files are reported, not quarantined — they may be the only copy and
the user decides what to do.

Adds GET /api/settings/library-validation (most recent report) and
POST /api/settings/library-validation/run (queue a fresh sweep).
Cached under cache.library_validation_report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:56:01 -04:00

439 lines
16 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,
LIBRARY_VALIDATION_REPORT_SETTING,
generate_api_key,
)
from app.config import get_settings
from app.services.gallery_dl import GalleryDLService
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()
from app.tasks.maintenance import _disk_usage_stats
disk_path = Path(get_settings().download_path)
if setting and setting.value:
cached = dict(setting.value)
# Older cached rollups predate the disk_* fields; top them up live so
# the capacity bar works without waiting for the next Celery run.
if "disk_percent_used" not in cached:
cached.update(_disk_usage_stats(disk_path))
return cached
# No cached stats yet - return placeholder plus live disk usage.
return {
"total_size": 0,
"total_size_formatted": "Calculating...",
"file_count": 0,
"calculated_at": None,
**_disk_usage_stats(disk_path),
}
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:
async with AsyncSession(bind=conn) as session:
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:
async with AsyncSession(bind=conn) as session:
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:
async with AsyncSession(bind=conn) as session:
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:
async with AsyncSession(bind=conn) as session:
# 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():
defaults = GalleryDLService()._get_default_config()
return jsonify({
"config": defaults,
"is_default": True,
"message": "No configuration file found — showing computed defaults. Save to persist.",
})
try:
with open(config_path) as f:
gallery_dl_config = json.load(f)
return jsonify({"config": gallery_dl_config, "is_default": False})
except json.JSONDecodeError as e:
return jsonify({"error": f"Invalid JSON in config file: {e}"}), 500
@bp.route("/gallery-dl/reset", methods=["POST"])
async def reset_gallery_dl_config():
"""Write computed defaults to gallery-dl.conf, replacing any existing file.
Users can use this to recover after misconfiguring the conf via the
Settings view. The defaults come from GalleryDLService._get_default_config,
which includes per-platform sections (extractor.discord, extractor.patreon,
etc.) with sane gallery-dl-compatible values.
"""
config = get_settings()
config_path = Path(config.config_path) / "gallery-dl.conf"
config_path.parent.mkdir(parents=True, exist_ok=True)
defaults = GalleryDLService()._get_default_config()
try:
with open(config_path, "w") as f:
json.dump(defaults, f, indent=4)
current_app.logger.info("Reset gallery-dl configuration to defaults")
return jsonify({"message": "Configuration reset to defaults", "config": defaults})
except Exception as e:
current_app.logger.error(f"Failed to write gallery-dl defaults: {e}")
return jsonify({"error": f"Failed to write defaults: {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("/library-validation", methods=["GET"])
async def get_library_validation_report():
"""Return the most recent library-validation sweep report (or null)."""
async with current_app.db_engine.connect() as conn:
async with AsyncSession(bind=conn) as session:
result = await session.execute(
select(Setting).where(Setting.key == LIBRARY_VALIDATION_REPORT_SETTING)
)
setting = result.scalar_one_or_none()
return jsonify(setting.value if setting and setting.value else None)
@bp.route("/library-validation/run", methods=["POST"])
async def run_library_validation():
"""Queue a library-validation sweep (walks the library, reports truncated files)."""
from app.tasks.maintenance import validate_library
try:
validate_library.delay()
current_app.logger.info("Queued library validation sweep")
return jsonify({"message": "Library validation queued"})
except Exception as e:
current_app.logger.error(f"Failed to queue library validation: {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:
async with AsyncSession(bind=conn) as session:
# 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:
async with AsyncSession(bind=conn) as session:
# 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,
})