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,99 @@
"""Background job scheduler for album polling."""
from __future__ import annotations
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import AlbumTracker
from .watcher import check_tracker
_LOGGER = logging.getLogger(__name__)
_scheduler: AsyncIOScheduler | None = None
def get_scheduler() -> AsyncIOScheduler:
"""Get the global scheduler instance."""
global _scheduler
if _scheduler is None:
_scheduler = AsyncIOScheduler()
return _scheduler
async def start_scheduler() -> None:
"""Start the scheduler and load all enabled trackers."""
scheduler = get_scheduler()
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(AlbumTracker).where(AlbumTracker.enabled == True) # noqa: E712
)
trackers = result.all()
for tracker in trackers:
_add_tracker_job(scheduler, tracker.id, tracker.scan_interval)
scheduler.start()
_LOGGER.info("Scheduler started with %d tracker jobs", len(trackers))
async def stop_scheduler() -> None:
"""Stop the scheduler."""
scheduler = get_scheduler()
if scheduler.running:
scheduler.shutdown(wait=False)
_LOGGER.info("Scheduler stopped")
def add_tracker_job(tracker_id: int, scan_interval: int) -> None:
"""Add or update a scheduler job for a tracker."""
scheduler = get_scheduler()
_add_tracker_job(scheduler, tracker_id, scan_interval)
def remove_tracker_job(tracker_id: int) -> None:
"""Remove a scheduler job for a tracker."""
scheduler = get_scheduler()
job_id = f"tracker_{tracker_id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
_LOGGER.debug("Removed scheduler job for tracker %d", tracker_id)
def _add_tracker_job(
scheduler: AsyncIOScheduler, tracker_id: int, scan_interval: int
) -> None:
"""Add or replace a scheduler job."""
job_id = f"tracker_{tracker_id}"
# Remove existing job if present
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
scheduler.add_job(
_run_tracker_check,
trigger=IntervalTrigger(seconds=scan_interval),
id=job_id,
args=[tracker_id],
replace_existing=True,
max_instances=1,
)
_LOGGER.debug(
"Scheduled tracker %d every %d seconds", tracker_id, scan_interval
)
async def _run_tracker_check(tracker_id: int) -> None:
"""Run a single tracker check (called by scheduler)."""
try:
result = await check_tracker(tracker_id)
_LOGGER.debug("Tracker %d check result: %s", tracker_id, result)
except Exception:
_LOGGER.exception("Error checking tracker %d", tracker_id)