Files
manage/backend/src/media_library_viewer_api/routers/settings.py
T
2026-05-07 12:34:16 +02:00

180 lines
6.0 KiB
Python

"""Settings router for persistent machine definitions, app credentials, and data reset."""
from __future__ import annotations
from io import StringIO
from typing import Any
import paramiko
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from media_library_viewer_api.dependencies import get_monitoring_poller, get_settings_store
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
from media_library_viewer_api.services.media_index import MediaIndex
from media_library_viewer_api.services.settings_store import SettingsStore
router = APIRouter(prefix="/api/settings", tags=["settings"])
class MonitoringMachineInput(BaseModel):
"""Payload for creating or updating a machine."""
id: str | None = None
name: str = Field(default="")
mode: str = Field(default="local", description="local or ssh")
enabled: bool = True
services: list[str] = Field(default_factory=list)
host: str = ""
port: int = 22
username: str = ""
key_directory: str = ""
key_name: str = ""
ssh_key_id: str = ""
ssh_private_key: str = ""
ssh_private_key_passphrase: str = ""
password: str = ""
media_root: str = ""
path_prefix: str = ""
jellyfin_url: str = ""
jellyfin_user_id: str = ""
jellyfin_api_key: str = ""
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
notes: str = ""
@router.get("/machines")
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
return store.list_machines()
@router.post("/machines", status_code=status.HTTP_201_CREATED)
def post_machine(
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
get_monitoring_poller().start()
return saved
@router.put("/machines/{machine_id}")
def put_machine(
machine_id: str,
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
if not store.get_machine(machine_id):
raise HTTPException(status_code=404, detail="Machine not found")
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
get_monitoring_poller().start()
return saved
@router.delete("/machines/{machine_id}")
def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
if not store.get_machine(machine_id):
raise HTTPException(status_code=404, detail="Machine not found")
store.delete_machine(machine_id)
return {"status": "deleted"}
class SSHKeyInput(BaseModel):
id: str | None = None
name: str = Field(default="")
private_key: str = Field(default="")
passphrase: str = Field(default="")
notes: str = Field(default="")
class SSHKeyGenerateInput(BaseModel):
name: str = Field(default="")
passphrase: str = Field(default="")
notes: str = Field(default="")
bits: int = Field(default=4096, ge=2048, le=8192)
@router.post("/ssh-keys/generate")
def generate_ssh_key(
payload: SSHKeyGenerateInput,
) -> dict[str, Any]:
key = paramiko.RSAKey.generate(bits=payload.bits)
private_buffer = StringIO()
key.write_private_key(private_buffer, password=payload.passphrase or None)
private_key = private_buffer.getvalue()
public_key = f"{key.get_name()} {key.get_base64()}"
return {
"name": payload.name,
"private_key": private_key,
"passphrase": payload.passphrase,
"notes": payload.notes,
"public_key": public_key,
}
@router.get("/ssh-keys")
def get_ssh_keys(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
return store.list_ssh_keys()
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
def post_ssh_key(
key: SSHKeyInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
return store.upsert_ssh_key(key.model_dump(exclude_none=True), key.id)
@router.put("/ssh-keys/{key_id}")
def put_ssh_key(
key_id: str,
key: SSHKeyInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
if not store.get_ssh_key(key_id):
raise HTTPException(status_code=404, detail="SSH key not found")
return store.upsert_ssh_key(key.model_dump(exclude_none=True), key_id)
@router.delete("/ssh-keys/{key_id}")
def delete_ssh_key(key_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
if not store.get_ssh_key(key_id):
raise HTTPException(status_code=404, detail="SSH key not found")
store.delete_ssh_key(key_id)
return {"status": "deleted"}
class ResetLocalDatabaseInput(BaseModel):
confirm_phrase: str = Field(..., description="Must be RESET LOCAL DATABASE")
acknowledge_settings_loss: bool = False
acknowledge_media_index_loss: bool = False
acknowledge_irreversible: bool = False
@router.post("/reset-local-database")
def reset_local_database(
payload: ResetLocalDatabaseInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
expected = "RESET LOCAL DATABASE"
if payload.confirm_phrase.strip().upper() != expected:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Confirmation phrase does not match")
if not (payload.acknowledge_settings_loss and payload.acknowledge_media_index_loss and payload.acknowledge_irreversible):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="All confirmation checkboxes must be selected")
settings_removed = remove_sqlite_database(store.db_path)
media_index = MediaIndex()
media_removed = remove_sqlite_database(media_index.db_path)
# Recreate the default local machine immediately so the UI remains usable.
store.ensure_defaults()
return {
"status": "reset",
"settings_db_removed": bool(settings_removed),
"media_index_removed": bool(media_removed),
"settings_files": settings_removed,
"media_index_files": media_removed,
}