Fix runtime issues found during live testing
Some checks failed
Validate / Hassfest (push) Has been cancelled

- Fix jinja2.sandbox import: use `from jinja2.sandbox import
  SandboxedEnvironment` (dotted attribute access doesn't work)
  in templates.py, sync.py, and notifier.py
- Fix greenlet crash in tracker trigger: SQLAlchemy async sessions
  can't survive across aiohttp.ClientSession context managers.
  Eagerly load all tracker/server data before entering HTTP context.
  Split check_tracker into check_tracker (scheduler, own session)
  and check_tracker_with_session (API, reuses route session).
- Fix _check_album to accept pre-loaded params instead of tracker
  object (avoids lazy-load access after greenlet context break)

Tested end-to-end against live Immich server:
- Server connection + album browsing: OK (39 albums)
- Template creation + preview: OK
- Webhook target creation: OK
- Tracker creation + trigger: OK (initialized 4 assets)
- Second trigger: OK (no_changes detected)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:17:12 +03:00
parent 62bf15dce3
commit 3a516d6d58
6 changed files with 60 additions and 34 deletions

View File

@@ -6,6 +6,7 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import jinja2
from jinja2.sandbox import SandboxedEnvironment
from ..database.engine import get_session
from ..database.models import (
@@ -136,7 +137,7 @@ async def render_template(
raise HTTPException(status_code=404, detail="Template not found")
try:
env = jinja2.sandbox.SandboxedEnvironment(autoescape=False)
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template.body)
rendered = tmpl.render(**body.context)
return {"rendered": rendered}

View File

@@ -6,6 +6,7 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import jinja2
from jinja2.sandbox import SandboxedEnvironment
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
@@ -124,7 +125,7 @@ async def preview_template(
"""Render a template with sample data."""
template = await _get_user_template(session, template_id, user.id)
try:
env = jinja2.sandbox.SandboxedEnvironment(autoescape=False)
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template.body)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"rendered": rendered}

View File

@@ -128,9 +128,8 @@ async def trigger_tracker(
):
"""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)
from ..services.watcher import check_tracker_with_session
result = await check_tracker_with_session(tracker.id, session)
return {"triggered": True, "result": result}

View File

@@ -7,6 +7,7 @@ from typing import Any
import aiohttp
import jinja2
from jinja2.sandbox import SandboxedEnvironment
from immich_watcher_core.telegram.client import TelegramClient
@@ -24,7 +25,7 @@ DEFAULT_TEMPLATE = (
def render_template(template_body: str, context: dict[str, Any]) -> str:
"""Render a Jinja2 template with the given context."""
env = jinja2.sandbox.SandboxedEnvironment(autoescape=False)
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_body)
return tmpl.render(**context)

View File

@@ -33,43 +33,67 @@ _LOGGER = logging.getLogger(__name__)
async def check_tracker(tracker_id: int) -> dict[str, Any]:
"""Check a single tracker for album changes.
Called by the scheduler or manually via API trigger.
Called by the scheduler (creates its own session).
"""
engine = get_engine()
async with AsyncSession(engine) as session:
tracker = await session.get(AlbumTracker, tracker_id)
if not tracker or not tracker.enabled:
return {"skipped": True, "reason": "disabled or not found"}
return await check_tracker_with_session(tracker_id, session)
server = await session.get(ImmichServer, tracker.server_id)
if not server:
return {"error": "Server not found"}
results = []
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
async def check_tracker_with_session(
tracker_id: int, session: AsyncSession
) -> dict[str, Any]:
"""Check a single tracker using a provided session.
# Fetch server config for external domain
await client.get_server_config()
users_cache = await client.get_users()
Called by API trigger (reuses route session) or by check_tracker.
"""
tracker = await session.get(AlbumTracker, tracker_id)
if not tracker or not tracker.enabled:
return {"skipped": True, "reason": "disabled or not found"}
for album_id in tracker.album_ids:
result = await _check_album(
session, http_session, client, tracker, album_id, users_cache
)
results.append(result)
server = await session.get(ImmichServer, tracker.server_id)
if not server:
return {"error": "Server not found"}
await session.commit()
return {"albums_checked": len(tracker.album_ids), "results": results}
# Eagerly read all needed data before entering aiohttp context
# (SQLAlchemy async greenlet context doesn't survive across other async CMs)
album_ids = list(tracker.album_ids)
event_types = list(tracker.event_types)
target_ids = list(tracker.target_ids)
template_id = tracker.template_id
tracker_db_id = tracker_id
server_url = server.url
server_api_key = server.api_key
results = []
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server_url, server_api_key)
# Fetch server config for external domain
await client.get_server_config()
users_cache = await client.get_users()
for album_id in album_ids:
result = await _check_album(
session, http_session, client, tracker_db_id,
album_id, users_cache, event_types, target_ids, template_id,
)
results.append(result)
await session.commit()
return {"albums_checked": len(album_ids), "results": results}
async def _check_album(
session: AsyncSession,
http_session: aiohttp.ClientSession,
client: ImmichClient,
tracker: AlbumTracker,
tracker_id: int,
album_id: str,
users_cache: dict[str, str],
event_types: list[str],
target_ids: list[int],
template_id: int | None,
) -> dict[str, Any]:
"""Check a single album for changes."""
try:
@@ -84,7 +108,7 @@ async def _check_album(
# Load previous state
result = await session.exec(
select(AlbumState).where(
AlbumState.tracker_id == tracker.id,
AlbumState.tracker_id == tracker_id,
AlbumState.album_id == album_id,
)
)
@@ -93,7 +117,7 @@ async def _check_album(
if state is None:
# First check - save state, no change detection
state = AlbumState(
tracker_id=tracker.id,
tracker_id=tracker_id,
album_id=album_id,
asset_ids=list(album.asset_ids),
pending_asset_ids=[],
@@ -134,7 +158,7 @@ async def _check_album(
return {"album_id": album_id, "status": "no_changes"}
# Check if this event type is tracked
if change.change_type not in tracker.event_types and "changed" not in tracker.event_types:
if change.change_type not in event_types and "changed" not in event_types:
return {"album_id": album_id, "status": "filtered", "change_type": change.change_type}
# Log the event
@@ -142,7 +166,7 @@ async def _check_album(
event_data = _build_event_data(change, album, client.external_url, shared_links)
event_log = EventLog(
tracker_id=tracker.id,
tracker_id=tracker_id,
event_type=change.change_type,
album_id=album_id,
album_name=album.name,
@@ -151,14 +175,14 @@ async def _check_album(
session.add(event_log)
# Send notifications to all configured targets
for target_id in tracker.target_ids:
for target_id in target_ids:
target = await session.get(NotificationTarget, target_id)
if not target:
continue
template = None
if tracker.template_id:
template = await session.get(MessageTemplate, tracker.template_id)
if template_id:
template = await session.get(MessageTemplate, template_id)
try:
use_ai = target.config.get("ai_captions", False)

BIN
test-data/immich_watcher.db Normal file

Binary file not shown.