Fix runtime issues found during live testing
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -33,10 +33,20 @@ _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:
|
||||
return await check_tracker_with_session(tracker_id, session)
|
||||
|
||||
|
||||
async def check_tracker_with_session(
|
||||
tracker_id: int, session: AsyncSession
|
||||
) -> dict[str, Any]:
|
||||
"""Check a single tracker using a provided session.
|
||||
|
||||
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"}
|
||||
@@ -45,31 +55,45 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
if not server:
|
||||
return {"error": "Server not found"}
|
||||
|
||||
# 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)
|
||||
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 tracker.album_ids:
|
||||
for album_id in album_ids:
|
||||
result = await _check_album(
|
||||
session, http_session, client, tracker, album_id, users_cache
|
||||
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(tracker.album_ids), "results": results}
|
||||
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
BIN
test-data/immich_watcher.db
Normal file
Binary file not shown.
Reference in New Issue
Block a user