Add HAOS-Server sync for optional centralized management (Phase 5)
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
Enable the HAOS integration to optionally connect to the standalone Immich Watcher server for config sync and event reporting. Server-side: - New /api/sync/* endpoints: GET trackers, POST template render, POST event report - API key auth via X-API-Key header (accepts JWT access tokens) Integration-side: - New sync.py: ServerSyncClient with graceful error handling (all methods return defaults on connection failure) - Options flow: optional server_url and server_api_key fields with connection validation - Coordinator: fire-and-forget event reporting to server when album changes are detected - Translations: en.json and ru.json updated with new fields The connection is fully additive -- the integration works identically without a server URL configured. Server failures never break HA. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
172
packages/server/src/immich_watcher_server/api/sync.py
Normal file
172
packages/server/src/immich_watcher_server/api/sync.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Sync API endpoints for HAOS integration communication."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
import jinja2
|
||||
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
AlbumTracker,
|
||||
EventLog,
|
||||
ImmichServer,
|
||||
MessageTemplate,
|
||||
NotificationTarget,
|
||||
User,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
|
||||
async def _get_user_by_api_key(
|
||||
x_api_key: str = Header(..., alias="X-API-Key"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
"""Authenticate via API key header (simpler than JWT for machine-to-machine).
|
||||
|
||||
The API key is the user's JWT access token or a dedicated sync token.
|
||||
For simplicity, we accept the username:password base64 or look up by username.
|
||||
In this implementation, we use the user ID embedded in the key.
|
||||
"""
|
||||
# For now, accept a simple "user_id:secret" format or just validate JWT
|
||||
from ..auth.jwt import decode_token
|
||||
import jwt as pyjwt
|
||||
|
||||
try:
|
||||
payload = decode_token(x_api_key)
|
||||
user_id = int(payload["sub"])
|
||||
except (pyjwt.PyJWTError, KeyError, ValueError) as exc:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key") from exc
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
class SyncTrackerResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_url: str
|
||||
server_api_key: str
|
||||
album_ids: list[str]
|
||||
event_types: list[str]
|
||||
scan_interval: int
|
||||
enabled: bool
|
||||
template_body: str | None = None
|
||||
targets: list[dict] = []
|
||||
|
||||
|
||||
class EventReport(BaseModel):
|
||||
tracker_name: str
|
||||
event_type: str
|
||||
album_id: str
|
||||
album_name: str
|
||||
details: dict = {}
|
||||
|
||||
|
||||
class RenderRequest(BaseModel):
|
||||
context: dict
|
||||
|
||||
|
||||
@router.get("/trackers", response_model=list[SyncTrackerResponse])
|
||||
async def get_sync_trackers(
|
||||
user: User = Depends(_get_user_by_api_key),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get all tracker configurations for syncing to HAOS integration."""
|
||||
result = await session.exec(
|
||||
select(AlbumTracker).where(AlbumTracker.user_id == user.id)
|
||||
)
|
||||
trackers = result.all()
|
||||
|
||||
responses = []
|
||||
for tracker in trackers:
|
||||
# Fetch server details
|
||||
server = await session.get(ImmichServer, tracker.server_id)
|
||||
if not server:
|
||||
continue
|
||||
|
||||
# Fetch template body if assigned
|
||||
template_body = None
|
||||
if tracker.template_id:
|
||||
template = await session.get(MessageTemplate, tracker.template_id)
|
||||
if template:
|
||||
template_body = template.body
|
||||
|
||||
# Fetch target configs
|
||||
targets = []
|
||||
for target_id in tracker.target_ids:
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
if target:
|
||||
targets.append({
|
||||
"type": target.type,
|
||||
"name": target.name,
|
||||
"config": target.config,
|
||||
})
|
||||
|
||||
responses.append(SyncTrackerResponse(
|
||||
id=tracker.id,
|
||||
name=tracker.name,
|
||||
server_url=server.url,
|
||||
server_api_key=server.api_key,
|
||||
album_ids=tracker.album_ids,
|
||||
event_types=tracker.event_types,
|
||||
scan_interval=tracker.scan_interval,
|
||||
enabled=tracker.enabled,
|
||||
template_body=template_body,
|
||||
targets=targets,
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/render")
|
||||
async def render_template(
|
||||
template_id: int,
|
||||
body: RenderRequest,
|
||||
user: User = Depends(_get_user_by_api_key),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Render a template with provided context (for HA to use server-managed templates)."""
|
||||
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")
|
||||
|
||||
try:
|
||||
env = jinja2.Environment(autoescape=False)
|
||||
tmpl = env.from_string(template.body)
|
||||
rendered = tmpl.render(**body.context)
|
||||
return {"rendered": rendered}
|
||||
except jinja2.TemplateError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Template error: {e}")
|
||||
|
||||
|
||||
@router.post("/events")
|
||||
async def report_event(
|
||||
body: EventReport,
|
||||
user: User = Depends(_get_user_by_api_key),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Report an event from HAOS integration to the server for logging."""
|
||||
# Find tracker by name (best-effort match)
|
||||
result = await session.exec(
|
||||
select(AlbumTracker).where(
|
||||
AlbumTracker.user_id == user.id,
|
||||
AlbumTracker.name == body.tracker_name,
|
||||
)
|
||||
)
|
||||
tracker = result.first()
|
||||
|
||||
event = EventLog(
|
||||
tracker_id=tracker.id if tracker else 0,
|
||||
event_type=body.event_type,
|
||||
album_id=body.album_id,
|
||||
album_name=body.album_name,
|
||||
details={**body.details, "source": "haos"},
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
return {"logged": True}
|
||||
Reference in New Issue
Block a user