fixes and improvements

This commit is contained in:
2026-05-07 20:07:54 +02:00
parent dd7b8f133c
commit 420dc34272
3 changed files with 63 additions and 19 deletions
+30
View File
@@ -0,0 +1,30 @@
# AGENTS.md
## Layout
- Current app is `backend/` (FastAPI) plus `frontend/` (Vite React); ignore Streamlit-era commands in `CONTRIBUTING.md`.
- Backend entrypoint: `backend/src/media_library_viewer_api/main.py` (`media_library_viewer_api.main:app`).
- Frontend entrypoint: `frontend/src/main.tsx`.
- Backend uses a `src/` layout; tests live in `backend/tests/`.
## Commands
- Backend setup: `cd backend && python -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'`
- Backend run: `uvicorn media_library_viewer_api.main:app --reload --port 8000`; if not installed, use `PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000`.
- Backend tests: run `pytest` from `backend/`; focused checks can use `pytest tests/test_api.py` or `pytest -k <expr>`; if the package is not installed, use `PYTHONPATH=src pytest`.
- Frontend setup: `cd frontend && npm install`
- Frontend dev/build/lint: `npm run dev`, `npm run build`, `npm run lint`; `npm run build` already typechecks via `tsc -b`.
- Focused frontend typecheck: `npx tsc --noEmit`
- Local dev stack: `docker compose -f docker-compose.dev.yml up --build`
- Production stack: `docker compose up --build`
## Repo-Specific Gotchas
- Root compose files rely on environment-variable interpolation, not `env_file`; export required values before running them.
- Production compose needs the host/cert and OIDC variables from `docker-compose.yml` (`BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `CERT_RESOLVER`, and the frontend OIDC vars).
- Dev compose runs with auth off and does not need SSH key material unless you add remote SSH machines.
- `backend_cache` persists the media index and the managed `known_hosts` file.
- SSH host-key checking is strict, but the first successful connect records the host key into backend-managed `known_hosts`.
- Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and monitoring poller.
- Machine-level settings now own Jellyfin/Jellyseerr/SSH config; the backend seeds a local machine automatically.
- Remote job templates live in `backend/src/media_library_viewer_api/jobs.py`; keep shell quoting intact.
- Backend Ruff config is in `backend/pyproject.toml` and uses line length 120 with Python 3.11.
- Update `docs/REQUIREMENTS.md` whenever behavior, UX, or architecture changes.
- Do not commit `.env`, `.streamlit/secrets.toml`, private keys, or tokens.
@@ -20,8 +20,6 @@ from typing import Any
import paramiko
from media_library_viewer_api.services.known_hosts import ensure_known_host
logger = logging.getLogger(__name__)
@@ -66,26 +64,12 @@ class RemoteSSHClient:
def connect(self) -> paramiko.SSHClient:
"""Create or reuse the Paramiko connection.
Unknown host keys are recorded on first contact in the managed
known_hosts file when one is configured. After that, strict checking
remains in effect so host key changes are still rejected.
Host keys are expected to be managed ahead of time by startup synthesis
or explicit validation flows. Runtime connections only load the managed
known_hosts file and then let Paramiko enforce strict checking.
"""
if self._client:
return self._client
if self.known_hosts_path:
try:
ensure_known_host(self.host, self.port, Path(self.known_hosts_path), strict=True)
except Exception as exc:
message = str(exc).lower()
if "protocol banner" in message:
raise RuntimeError(
f"SSH banner not received from {self.host}:{self.port}. "
"The host key could not be recorded because the backend could not talk to SSH."
) from exc
raise RuntimeError(
f"SSH host key lookup failed for {self.host}:{self.port}. "
"Confirm the host and port are correct and that SSH is reachable."
) from exc
client = paramiko.SSHClient()
client.load_system_host_keys()
if self.known_hosts_path and Path(self.known_hosts_path).is_file():
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from media_library_viewer_api.clients.ssh import RemoteSSHClient
def test_connect_uses_existing_known_hosts_without_reprobing(tmp_path):
known_hosts_path = tmp_path / "known_hosts"
known_hosts_path.write_text("example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAAFAKE\n")
ssh_client = MagicMock()
ssh_client.connect.return_value = None
with (
patch("media_library_viewer_api.clients.ssh.paramiko.SSHClient", return_value=ssh_client),
patch("media_library_viewer_api.clients.ssh.paramiko.RejectPolicy", return_value=object()),
):
client = RemoteSSHClient(
host="example.com",
username="alex",
known_hosts_path=str(known_hosts_path),
)
client.connect()
ssh_client.load_system_host_keys.assert_called_once_with()
ssh_client.load_host_keys.assert_called_once_with(str(known_hosts_path))
ssh_client.set_missing_host_key_policy.assert_called_once()
ssh_client.connect.assert_called_once()