Add standalone FastAPI server backend (Phase 3)
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
158
packages/server/src/immich_watcher_server/api/servers.py
Normal file
158
packages/server/src/immich_watcher_server/api/servers.py
Normal 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
|
||||
Reference in New Issue
Block a user