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

@@ -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)