feat: person excludes for auto-organize rules, backup & restore system

Add person exclude criteria to Immich auto-organize — assets containing
excluded persons are filtered out after candidate gathering. Also adds
full backup/restore system with export, import, scheduled backups, and
retention management.
This commit is contained in:
2026-04-02 14:13:42 +03:00
parent 6e51164f8e
commit 6b2211353d
13 changed files with 2191 additions and 2 deletions
@@ -0,0 +1,223 @@
"""Configuration backup/restore API (admin only)."""
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import JSONResponse
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import require_admin
from ..config import settings as app_config
from ..database.engine import get_session
from ..database.models import AppSetting, User
from ..services.backup_schema import (
ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
)
from ..services.backup_service import (
cleanup_old_backups, export_backup, import_backup, list_backup_files,
validate_backup,
)
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/backup", tags=["backup"])
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
def _backup_dir():
return app_config.data_dir / "backups"
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
@router.get("/export")
async def export_config(
secrets_mode: SecretsMode = Query(default=SecretsMode.EXCLUDE),
categories: str = Query(default=""),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Export configuration as a downloadable JSON file."""
cats = None
if categories:
try:
cats = [BackupCategory(c.strip()) for c in categories.split(",") if c.strip()]
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid category: {e}")
backup = await export_backup(session, user.id, cats, secrets_mode)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
filename = f"notify-bridge-backup-{ts}.json"
return JSONResponse(
content=backup.model_dump(),
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# Validate
# ---------------------------------------------------------------------------
@router.post("/validate")
async def validate_config(
file: UploadFile = File(...),
user: User = Depends(require_admin),
):
"""Validate a backup file without importing."""
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
try:
raw = json.loads(content)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
result = validate_backup(raw)
return result.model_dump()
# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------
@router.post("/import")
async def import_config(
file: UploadFile = File(...),
conflict_mode: ConflictMode = Query(default=ConflictMode.SKIP),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Import configuration from a backup file."""
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
try:
raw = json.loads(content)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Validate first
validation = validate_backup(raw)
if not validation.valid:
raise HTTPException(status_code=400, detail=f"Invalid backup: {'; '.join(validation.errors)}")
backup = BackupFile.model_validate(raw)
result = await import_backup(session, user.id, backup, conflict_mode)
return result.model_dump()
# ---------------------------------------------------------------------------
# Scheduled backup settings
# ---------------------------------------------------------------------------
_BACKUP_SETTING_KEYS = {
"backup_scheduled_enabled": "false",
"backup_scheduled_interval_hours": "24",
"backup_secrets_mode": "exclude",
"backup_retention_count": "5",
}
@router.get("/scheduled")
async def get_scheduled_settings(
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Get scheduled backup settings."""
result = {}
for key, default in _BACKUP_SETTING_KEYS.items():
row = await session.get(AppSetting, key)
result[key] = row.value if row and row.value else default
return result
@router.put("/scheduled")
async def update_scheduled_settings(
body: dict,
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Update scheduled backup settings and reschedule."""
for key, default in _BACKUP_SETTING_KEYS.items():
value = body.get(key)
if value is None:
continue
row = await session.get(AppSetting, key)
if row:
row.value = str(value)
else:
row = AppSetting(key=key, value=str(value))
session.add(row)
await session.commit()
# Reschedule backup job
from ..services.scheduler import schedule_backup
enabled = body.get("backup_scheduled_enabled", "false") == "true"
interval_hours = int(body.get("backup_scheduled_interval_hours", "24"))
if enabled:
await schedule_backup(interval_hours)
else:
from ..services.scheduler import unschedule_backup
await unschedule_backup()
# Return updated settings
result = {}
for key, default in _BACKUP_SETTING_KEYS.items():
row = await session.get(AppSetting, key)
result[key] = row.value if row and row.value else default
return result
# ---------------------------------------------------------------------------
# Backup file management
# ---------------------------------------------------------------------------
@router.get("/files")
async def get_backup_files(
user: User = Depends(require_admin),
):
"""List saved backup files."""
return list_backup_files(_backup_dir())
@router.get("/files/{filename}")
async def download_backup_file(
filename: str,
user: User = Depends(require_admin),
):
"""Download a specific backup file."""
filepath = _backup_dir() / filename
if not filepath.is_file() or not filename.startswith("backup-"):
raise HTTPException(status_code=404, detail="Backup file not found")
try:
content = json.loads(filepath.read_text(encoding="utf-8"))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read backup: {e}")
return JSONResponse(
content=content,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/files/{filename}")
async def delete_backup_file(
filename: str,
user: User = Depends(require_admin),
):
"""Delete a specific backup file."""
filepath = _backup_dir() / filename
if not filepath.is_file() or not filename.startswith("backup-"):
raise HTTPException(status_code=404, detail="Backup file not found")
filepath.unlink()
return {"deleted": filename}