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 @@
"""API routes package."""

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

View File

@@ -0,0 +1,55 @@
"""Status/dashboard API route."""
from fastapi import APIRouter, Depends
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import AlbumTracker, EventLog, ImmichServer, NotificationTarget, User
router = APIRouter(prefix="/api/status", tags=["status"])
@router.get("")
async def get_status(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get dashboard status data."""
servers_count = (await session.exec(
select(func.count()).select_from(ImmichServer).where(ImmichServer.user_id == user.id)
)).one()
trackers_result = await session.exec(
select(AlbumTracker).where(AlbumTracker.user_id == user.id)
)
trackers = trackers_result.all()
active_count = sum(1 for t in trackers if t.enabled)
targets_count = (await session.exec(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one()
recent_events = await session.exec(
select(EventLog)
.join(AlbumTracker, EventLog.tracker_id == AlbumTracker.id)
.where(AlbumTracker.user_id == user.id)
.order_by(EventLog.created_at.desc())
.limit(10)
)
return {
"servers": servers_count,
"trackers": {"total": len(trackers), "active": active_count},
"targets": targets_count,
"recent_events": [
{
"id": e.id,
"event_type": e.event_type,
"album_name": e.album_name,
"created_at": e.created_at.isoformat(),
}
for e in recent_events.all()
],
}

View File

@@ -0,0 +1,137 @@
"""Notification target 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
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, User
router = APIRouter(prefix="/api/targets", tags=["targets"])
class TargetCreate(BaseModel):
type: str # "telegram" or "webhook"
name: str
config: dict # telegram: {bot_token, chat_id}, webhook: {url, headers?}
class TargetUpdate(BaseModel):
name: str | None = None
config: dict | None = None
@router.get("")
async def list_targets(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all notification targets for the current user."""
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
)
return [
{"id": t.id, "type": t.type, "name": t.name, "config": _safe_config(t), "created_at": t.created_at.isoformat()}
for t in result.all()
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_target(
body: TargetCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Create a new notification target."""
if body.type not in ("telegram", "webhook"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Type must be 'telegram' or 'webhook'",
)
target = NotificationTarget(
user_id=user.id,
type=body.type,
name=body.name,
config=body.config,
)
session.add(target)
await session.commit()
await session.refresh(target)
return {"id": target.id, "type": target.type, "name": target.name}
@router.get("/{target_id}")
async def get_target(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get a specific notification target."""
target = await _get_user_target(session, target_id, user.id)
return {"id": target.id, "type": target.type, "name": target.name, "config": _safe_config(target)}
@router.put("/{target_id}")
async def update_target(
target_id: int,
body: TargetUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a notification target."""
target = await _get_user_target(session, target_id, user.id)
if body.name is not None:
target.name = body.name
if body.config is not None:
target.config = body.config
session.add(target)
await session.commit()
await session.refresh(target)
return {"id": target.id, "type": target.type, "name": target.name}
@router.delete("/{target_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_target(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a notification target."""
target = await _get_user_target(session, target_id, user.id)
await session.delete(target)
await session.commit()
@router.post("/{target_id}/test")
async def test_target(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a target."""
target = await _get_user_target(session, target_id, user.id)
from ..services.notifier import send_test_notification
result = await send_test_notification(target)
return result
def _safe_config(target: NotificationTarget) -> dict:
"""Return config with sensitive fields masked."""
config = dict(target.config)
if "bot_token" in config:
token = config["bot_token"]
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
if "api_key" in config:
config["api_key"] = "***"
return config
async def _get_user_target(
session: AsyncSession, target_id: int, user_id: int
) -> NotificationTarget:
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user_id:
raise HTTPException(status_code=404, detail="Target not found")
return target

View File

@@ -0,0 +1,144 @@
"""Message template 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 jinja2
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import MessageTemplate, User
router = APIRouter(prefix="/api/templates", tags=["templates"])
# Sample data for template preview
_SAMPLE_CONTEXT = {
"album_name": "Family Photos",
"album_url": "https://immich.example.com/share/abc123",
"added_count": 3,
"removed_count": 0,
"change_type": "assets_added",
"people": ["Alice", "Bob"],
"added_assets": [
{"filename": "IMG_001.jpg", "type": "IMAGE", "owner": "Alice", "created_at": "2024-03-19T10:30:00Z"},
{"filename": "IMG_002.jpg", "type": "IMAGE", "owner": "Bob", "created_at": "2024-03-19T11:00:00Z"},
{"filename": "VID_003.mp4", "type": "VIDEO", "owner": "Alice", "created_at": "2024-03-19T11:30:00Z"},
],
}
class TemplateCreate(BaseModel):
name: str
body: str
is_default: bool = False
class TemplateUpdate(BaseModel):
name: str | None = None
body: str | None = None
is_default: bool | None = None
@router.get("")
async def list_templates(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all templates for the current user."""
result = await session.exec(
select(MessageTemplate).where(MessageTemplate.user_id == user.id)
)
return [
{"id": t.id, "name": t.name, "body": t.body, "is_default": t.is_default, "created_at": t.created_at.isoformat()}
for t in result.all()
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_template(
body: TemplateCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Create a new message template."""
template = MessageTemplate(
user_id=user.id,
name=body.name,
body=body.body,
is_default=body.is_default,
)
session.add(template)
await session.commit()
await session.refresh(template)
return {"id": template.id, "name": template.name}
@router.get("/{template_id}")
async def get_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get a specific template."""
template = await _get_user_template(session, template_id, user.id)
return {"id": template.id, "name": template.name, "body": template.body, "is_default": template.is_default}
@router.put("/{template_id}")
async def update_template(
template_id: int,
body: TemplateUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a template."""
template = await _get_user_template(session, template_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(template, field, value)
session.add(template)
await session.commit()
await session.refresh(template)
return {"id": template.id, "name": template.name}
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a template."""
template = await _get_user_template(session, template_id, user.id)
await session.delete(template)
await session.commit()
@router.post("/{template_id}/preview")
async def preview_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Render a template with sample data."""
template = await _get_user_template(session, template_id, user.id)
try:
env = jinja2.Environment(autoescape=False)
tmpl = env.from_string(template.body)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"rendered": rendered}
except jinja2.TemplateError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Template error: {e}",
)
async def _get_user_template(
session: AsyncSession, template_id: int, user_id: int
) -> MessageTemplate:
template = await session.get(MessageTemplate, template_id)
if not template or template.user_id != user_id:
raise HTTPException(status_code=404, detail="Template not found")
return template

View File

@@ -0,0 +1,188 @@
"""Album tracker 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
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import AlbumTracker, EventLog, ImmichServer, User
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
class TrackerCreate(BaseModel):
server_id: int
name: str
album_ids: list[str]
event_types: list[str] = ["assets_added"]
target_ids: list[int] = []
template_id: int | None = None
scan_interval: int = 60
enabled: bool = True
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackerUpdate(BaseModel):
name: str | None = None
album_ids: list[str] | None = None
event_types: list[str] | None = None
target_ids: list[int] | None = None
template_id: int | None = None
scan_interval: int | None = None
enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("")
async def list_trackers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all trackers for the current user."""
result = await session.exec(
select(AlbumTracker).where(AlbumTracker.user_id == user.id)
)
return [_tracker_response(t) for t in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_tracker(
body: TrackerCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Create a new album tracker."""
# Verify server ownership
server = await session.get(ImmichServer, body.server_id)
if not server or server.user_id != user.id:
raise HTTPException(status_code=404, detail="Server not found")
tracker = AlbumTracker(
user_id=user.id,
server_id=body.server_id,
name=body.name,
album_ids=body.album_ids,
event_types=body.event_types,
target_ids=body.target_ids,
template_id=body.template_id,
scan_interval=body.scan_interval,
enabled=body.enabled,
quiet_hours_start=body.quiet_hours_start,
quiet_hours_end=body.quiet_hours_end,
)
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return _tracker_response(tracker)
@router.get("/{tracker_id}")
async def get_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get a specific tracker."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
return _tracker_response(tracker)
@router.put("/{tracker_id}")
async def update_tracker(
tracker_id: int,
body: TrackerUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a tracker."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(tracker, field, value)
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return _tracker_response(tracker)
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a tracker."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
await session.delete(tracker)
await session.commit()
@router.post("/{tracker_id}/trigger")
async def trigger_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Force an immediate check for a tracker."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
# Import here to avoid circular imports
from ..services.watcher import check_tracker
result = await check_tracker(tracker.id)
return {"triggered": True, "result": result}
@router.get("/{tracker_id}/history")
async def tracker_history(
tracker_id: int,
limit: int = 20,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get recent events for a tracker."""
await _get_user_tracker(session, tracker_id, user.id)
result = await session.exec(
select(EventLog)
.where(EventLog.tracker_id == tracker_id)
.order_by(EventLog.created_at.desc())
.limit(limit)
)
return [
{
"id": e.id,
"event_type": e.event_type,
"album_id": e.album_id,
"album_name": e.album_name,
"details": e.details,
"created_at": e.created_at.isoformat(),
}
for e in result.all()
]
def _tracker_response(t: AlbumTracker) -> dict:
return {
"id": t.id,
"name": t.name,
"server_id": t.server_id,
"album_ids": t.album_ids,
"event_types": t.event_types,
"target_ids": t.target_ids,
"template_id": t.template_id,
"scan_interval": t.scan_interval,
"enabled": t.enabled,
"quiet_hours_start": t.quiet_hours_start,
"quiet_hours_end": t.quiet_hours_end,
"created_at": t.created_at.isoformat(),
}
async def _get_user_tracker(
session: AsyncSession, tracker_id: int, user_id: int
) -> AlbumTracker:
tracker = await session.get(AlbumTracker, tracker_id)
if not tracker or tracker.user_id != user_id:
raise HTTPException(status_code=404, detail="Tracker not found")
return tracker

View File

@@ -0,0 +1,78 @@
"""User management API routes (admin only)."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import bcrypt
from ..auth.dependencies import require_admin
from ..database.engine import get_session
from ..database.models import User
router = APIRouter(prefix="/api/users", tags=["users"])
class UserCreate(BaseModel):
username: str
password: str
role: str = "user"
class UserUpdate(BaseModel):
username: str | None = None
password: str | None = None
role: str | None = None
@router.get("")
async def list_users(
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""List all users (admin only)."""
result = await session.exec(select(User))
return [
{"id": u.id, "username": u.username, "role": u.role, "created_at": u.created_at.isoformat()}
for u in result.all()
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Create a new user (admin only)."""
# Check for duplicate username
result = await session.exec(select(User).where(User.username == body.username))
if result.first():
raise HTTPException(status_code=409, detail="Username already exists")
user = User(
username=body.username,
hashed_password=bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode(),
role=body.role if body.role in ("admin", "user") else "user",
)
session.add(user)
await session.commit()
await session.refresh(user)
return {"id": user.id, "username": user.username, "role": user.role}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Delete a user (admin only, cannot delete self)."""
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
await session.delete(user)
await session.commit()