"""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, }