Add standalone FastAPI server backend (Phase 3)
Some checks failed
Validate / Hassfest (push) Has been cancelled

Build a complete standalone web server for Immich album change
notifications, independent of Home Assistant. Uses the shared
core library from Phase 1.

Server features:
- FastAPI with async SQLite (SQLModel + aiosqlite)
- Multi-user auth with JWT (admin/user roles, setup wizard)
- CRUD APIs: Immich servers, album trackers, message templates,
  notification targets (Telegram + webhook), user management
- APScheduler background polling per tracker
- Jinja2 template rendering with live preview
- Album browser proxied from Immich API
- Event logging and dashboard status endpoint
- Docker deployment (single container, SQLite in volume)

39 API routes, 14 integration tests passing.

Also adds Phase 6 (Claude AI Telegram bot enhancement) to the
primary plan as an optional future phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:56:22 +03:00
parent b107cfe67f
commit 58b2281dc6
28 changed files with 1982 additions and 1 deletions

View File

@@ -0,0 +1,158 @@
"""Immich server management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import aiohttp
from immich_watcher_core.immich_client import ImmichClient
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ImmichServer, User
router = APIRouter(prefix="/api/servers", tags=["servers"])
class ServerCreate(BaseModel):
name: str = "Immich"
url: str
api_key: str
class ServerUpdate(BaseModel):
name: str | None = None
url: str | None = None
api_key: str | None = None
class ServerResponse(BaseModel):
id: int
name: str
url: str
created_at: str
@router.get("")
async def list_servers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all Immich servers for the current user."""
result = await session.exec(
select(ImmichServer).where(ImmichServer.user_id == user.id)
)
servers = result.all()
return [
{"id": s.id, "name": s.name, "url": s.url, "created_at": s.created_at.isoformat()}
for s in servers
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_server(
body: ServerCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Add a new Immich server (validates connection)."""
# Validate connection
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, body.url, body.api_key)
if not await client.ping():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot connect to Immich server at {body.url}",
)
# Fetch external domain
external_domain = await client.get_server_config()
server = ImmichServer(
user_id=user.id,
name=body.name,
url=body.url,
api_key=body.api_key,
external_domain=external_domain,
)
session.add(server)
await session.commit()
await session.refresh(server)
return {"id": server.id, "name": server.name, "url": server.url}
@router.get("/{server_id}")
async def get_server(
server_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get a specific Immich server."""
server = await _get_user_server(session, server_id, user.id)
return {"id": server.id, "name": server.name, "url": server.url, "created_at": server.created_at.isoformat()}
@router.put("/{server_id}")
async def update_server(
server_id: int,
body: ServerUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update an Immich server."""
server = await _get_user_server(session, server_id, user.id)
if body.name is not None:
server.name = body.name
if body.url is not None:
server.url = body.url
if body.api_key is not None:
server.api_key = body.api_key
session.add(server)
await session.commit()
await session.refresh(server)
return {"id": server.id, "name": server.name, "url": server.url}
@router.delete("/{server_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_server(
server_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete an Immich server."""
server = await _get_user_server(session, server_id, user.id)
await session.delete(server)
await session.commit()
@router.get("/{server_id}/albums")
async def list_albums(
server_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Fetch albums from an Immich server."""
server = await _get_user_server(session, server_id, user.id)
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
albums = await client.get_albums()
return [
{
"id": a.get("id"),
"albumName": a.get("albumName"),
"assetCount": a.get("assetCount", 0),
"shared": a.get("shared", False),
}
for a in albums
]
async def _get_user_server(
session: AsyncSession, server_id: int, user_id: int
) -> ImmichServer:
"""Get a server owned by the user, or raise 404."""
server = await session.get(ImmichServer, server_id)
if not server or server.user_id != user_id:
raise HTTPException(status_code=404, detail="Server not found")
return server