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:
@@ -0,0 +1,24 @@
|
||||
"""Database package."""
|
||||
|
||||
from .engine import get_session, init_db
|
||||
from .models import (
|
||||
AlbumState,
|
||||
AlbumTracker,
|
||||
EventLog,
|
||||
ImmichServer,
|
||||
MessageTemplate,
|
||||
NotificationTarget,
|
||||
User,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_session",
|
||||
"init_db",
|
||||
"AlbumState",
|
||||
"AlbumTracker",
|
||||
"EventLog",
|
||||
"ImmichServer",
|
||||
"MessageTemplate",
|
||||
"NotificationTarget",
|
||||
"User",
|
||||
]
|
||||
36
packages/server/src/immich_watcher_server/database/engine.py
Normal file
36
packages/server/src/immich_watcher_server/database/engine.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Database engine and session management."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from ..config import settings
|
||||
|
||||
_engine: AsyncEngine | None = None
|
||||
|
||||
|
||||
def get_engine() -> AsyncEngine:
|
||||
"""Get or create the async database engine."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_async_engine(
|
||||
settings.effective_database_url,
|
||||
echo=settings.debug,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all database tables."""
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield an async database session (FastAPI dependency)."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
yield session
|
||||
112
packages/server/src/immich_watcher_server/database/models.py
Normal file
112
packages/server/src/immich_watcher_server/database/models.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""SQLModel database table definitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import JSON, Column, Field, SQLModel
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
"""Application user."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
username: str = Field(index=True, unique=True)
|
||||
hashed_password: str
|
||||
role: str = Field(default="user") # "admin" or "user"
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class ImmichServer(SQLModel, table=True):
|
||||
"""Immich server connection."""
|
||||
|
||||
__tablename__ = "immich_server"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
name: str = Field(default="Immich")
|
||||
url: str
|
||||
api_key: str
|
||||
external_domain: str | None = None
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class NotificationTarget(SQLModel, table=True):
|
||||
"""Notification destination (Telegram chat, webhook URL)."""
|
||||
|
||||
__tablename__ = "notification_target"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
type: str # "telegram" or "webhook"
|
||||
name: str
|
||||
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class MessageTemplate(SQLModel, table=True):
|
||||
"""Jinja2 message template."""
|
||||
|
||||
__tablename__ = "message_template"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
name: str
|
||||
body: str = Field(default="")
|
||||
is_default: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class AlbumTracker(SQLModel, table=True):
|
||||
"""Album change tracker configuration."""
|
||||
|
||||
__tablename__ = "album_tracker"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
server_id: int = Field(foreign_key="immich_server.id")
|
||||
name: str
|
||||
album_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
event_types: list[str] = Field(
|
||||
default_factory=lambda: ["assets_added"],
|
||||
sa_column=Column(JSON),
|
||||
)
|
||||
target_ids: list[int] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
template_id: int | None = Field(default=None, foreign_key="message_template.id")
|
||||
scan_interval: int = Field(default=60) # seconds
|
||||
enabled: bool = Field(default=True)
|
||||
quiet_hours_start: str | None = None # "HH:MM"
|
||||
quiet_hours_end: str | None = None # "HH:MM"
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class AlbumState(SQLModel, table=True):
|
||||
"""Persisted album state for change detection across restarts."""
|
||||
|
||||
__tablename__ = "album_state"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int = Field(foreign_key="album_tracker.id")
|
||||
album_id: str
|
||||
asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
pending_asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
last_updated: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class EventLog(SQLModel, table=True):
|
||||
"""Log of detected album change events."""
|
||||
|
||||
__tablename__ = "event_log"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int = Field(foreign_key="album_tracker.id")
|
||||
event_type: str
|
||||
album_id: str
|
||||
album_name: str
|
||||
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
Reference in New Issue
Block a user