Add HAOS-Server sync for optional centralized management (Phase 5)
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:
2026-03-19 14:10:29 +03:00
parent 2b487707ce
commit ab1c7ac0db
11 changed files with 441 additions and 20 deletions

View 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}

View File

@@ -21,6 +21,7 @@ from .api.templates import router as templates_router
from .api.targets import router as targets_router
from .api.users import router as users_router
from .api.status import router as status_router
from .api.sync import router as sync_router
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
@@ -69,6 +70,7 @@ app.include_router(templates_router)
app.include_router(targets_router)
app.include_router(users_router)
app.include_router(status_router)
app.include_router(sync_router)
# Serve frontend static files if available
_frontend_dist = Path(__file__).parent / "frontend"