From 90b4713d5c1c78235f7abb3f8ceff4af1c9f8ba1 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Mar 2026 16:57:19 +0300 Subject: [PATCH] Restructure data model: TrackingConfig + TemplateConfig entities Major model restructuring for clean separation of concerns: New entities: - TrackingConfig: What to react to (event types, asset filters, periodic/scheduled/memory mode config) - reusable across targets - TemplateConfig: All ~15 template slots from blueprint (event messages, asset formatting, date/location, scheduled messages) with full defaults - separate entities per locale Changed entities: - AlbumTracker: Simplified to album selection + polling + target_ids (removed event_types, template_id, all filter fields) - NotificationTarget: Extended with tracking_config_id and template_config_id FKs (many-to-one, reusable configs) Removed entities: - MessageTemplate (replaced by TemplateConfig) - ScheduledJob (absorbed into TrackingConfig) Updated services: - watcher.py: Each target checked against its own tracking_config for event filtering before sending notification - notifier.py: Uses target's template_config to select the right template slot based on event type New API routes: - /api/tracking-configs/* (CRUD) - /api/template-configs/* (CRUD + per-slot preview) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/immich_watcher_server/api/sync.py | 22 +-- .../src/immich_watcher_server/api/targets.py | 10 +- .../api/template_configs.py | 160 +++++++++++++++ .../src/immich_watcher_server/api/trackers.py | 55 +----- .../api/tracking_configs.py | 157 +++++++++++++++ .../database/__init__.py | 7 +- .../immich_watcher_server/database/models.py | 182 ++++++++++++------ .../server/src/immich_watcher_server/main.py | 8 +- .../services/notifier.py | 19 +- .../immich_watcher_server/services/watcher.py | 46 +++-- 10 files changed, 508 insertions(+), 158 deletions(-) create mode 100644 packages/server/src/immich_watcher_server/api/template_configs.py create mode 100644 packages/server/src/immich_watcher_server/api/tracking_configs.py diff --git a/packages/server/src/immich_watcher_server/api/sync.py b/packages/server/src/immich_watcher_server/api/sync.py index 0ac2688..b856cf4 100644 --- a/packages/server/src/immich_watcher_server/api/sync.py +++ b/packages/server/src/immich_watcher_server/api/sync.py @@ -13,8 +13,9 @@ from ..database.models import ( AlbumTracker, EventLog, ImmichServer, - MessageTemplate, NotificationTarget, + TemplateConfig, + TrackingConfig, User, ) @@ -90,13 +91,6 @@ async def get_sync_trackers( if not server: continue - # Fetch template body if assigned - template_body = None - if tracker.template_id: - template = await session.get(MessageTemplate, tracker.template_id) - if template: - template_body = template.body - # Fetch target configs targets = [] for target_id in tracker.target_ids: @@ -114,10 +108,10 @@ async def get_sync_trackers( server_url=server.url, server_api_key=server.api_key, album_ids=tracker.album_ids, - event_types=tracker.event_types, + event_types=[], # Event types now on tracking configs scan_interval=tracker.scan_interval, enabled=tracker.enabled, - template_body=template_body, + template_body=None, targets=targets, )) @@ -131,14 +125,14 @@ async def render_template( user: User = Depends(_get_user_by_api_key), session: AsyncSession = Depends(get_session), ): - """Render a template with provided context (for HA to use server-managed templates).""" - template = await session.get(MessageTemplate, template_id) + """Render a template config slot with provided context.""" + template = await session.get(TemplateConfig, template_id) if not template or template.user_id != user.id: - raise HTTPException(status_code=404, detail="Template not found") + raise HTTPException(status_code=404, detail="Template config not found") try: env = SandboxedEnvironment(autoescape=False) - tmpl = env.from_string(template.body) + tmpl = env.from_string(template.message_assets_added) rendered = tmpl.render(**body.context) return {"rendered": rendered} except jinja2.TemplateError as e: diff --git a/packages/server/src/immich_watcher_server/api/targets.py b/packages/server/src/immich_watcher_server/api/targets.py index 4648bfb..264e915 100644 --- a/packages/server/src/immich_watcher_server/api/targets.py +++ b/packages/server/src/immich_watcher_server/api/targets.py @@ -16,11 +16,15 @@ class TargetCreate(BaseModel): type: str # "telegram" or "webhook" name: str config: dict # telegram: {bot_token, chat_id}, webhook: {url, headers?} + tracking_config_id: int | None = None + template_config_id: int | None = None class TargetUpdate(BaseModel): name: str | None = None config: dict | None = None + tracking_config_id: int | None = None + template_config_id: int | None = None @router.get("") @@ -33,7 +37,7 @@ async def list_targets( select(NotificationTarget).where(NotificationTarget.user_id == user.id) ) return [ - {"id": t.id, "type": t.type, "name": t.name, "config": _safe_config(t), "created_at": t.created_at.isoformat()} + {"id": t.id, "type": t.type, "name": t.name, "config": _safe_config(t), "tracking_config_id": t.tracking_config_id, "template_config_id": t.template_config_id, "created_at": t.created_at.isoformat()} for t in result.all() ] @@ -55,6 +59,8 @@ async def create_target( type=body.type, name=body.name, config=body.config, + tracking_config_id=body.tracking_config_id, + template_config_id=body.template_config_id, ) session.add(target) await session.commit() @@ -70,7 +76,7 @@ async def get_target( ): """Get a specific notification target.""" target = await _get_user_target(session, target_id, user.id) - return {"id": target.id, "type": target.type, "name": target.name, "config": _safe_config(target)} + return {"id": target.id, "type": target.type, "name": target.name, "config": _safe_config(target), "tracking_config_id": target.tracking_config_id, "template_config_id": target.template_config_id} @router.put("/{target_id}") diff --git a/packages/server/src/immich_watcher_server/api/template_configs.py b/packages/server/src/immich_watcher_server/api/template_configs.py new file mode 100644 index 0000000..7f8b1a1 --- /dev/null +++ b/packages/server/src/immich_watcher_server/api/template_configs.py @@ -0,0 +1,160 @@ +"""Template configuration CRUD API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from jinja2.sandbox import SandboxedEnvironment + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import TemplateConfig, User + +router = APIRouter(prefix="/api/template-configs", tags=["template-configs"]) + +# Sample data for template preview +_SAMPLE_CONTEXT = { + "album_name": "Family Photos", + "album_url": "https://immich.example.com/share/abc123", + "album_created": "01.01.2024", + "album_updated": "19.03.2026", + "added_count": 3, + "removed_count": 0, + "change_type": "assets_added", + "people": "Alice, Bob", + "assets": "\n • šŸ–¼ļø IMG_001.jpg\n • šŸ–¼ļø IMG_002.jpg\n • šŸŽ¬ VID_003.mp4", + "common_date": " from 19.03.2026", + "common_location": " in Paris, France", + "video_warning": "", + "old_name": "Old Album", + "new_name": "New Album", + "album_count": 5, + "albums": "\n • Family Photos: https://example.com/share/abc", + "asset_count": 10, + "more_count": 7, +} + + +class TemplateConfigCreate(BaseModel): + name: str + message_assets_added: str | None = None + message_assets_removed: str | None = None + message_album_renamed: str | None = None + message_album_deleted: str | None = None + message_asset_image: str | None = None + message_asset_video: str | None = None + message_assets_format: str | None = None + message_assets_more: str | None = None + message_people_format: str | None = None + date_format: str | None = None + common_date_template: str | None = None + date_if_unique_template: str | None = None + location_format: str | None = None + common_location_template: str | None = None + location_if_unique_template: str | None = None + favorite_indicator: str | None = None + periodic_summary_message: str | None = None + periodic_album_template: str | None = None + scheduled_assets_message: str | None = None + memory_mode_message: str | None = None + video_warning: str | None = None + + +TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional + + +@router.get("") +async def list_configs( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + result = await session.exec( + select(TemplateConfig).where(TemplateConfig.user_id == user.id) + ) + return [_response(c) for c in result.all()] + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_config( + body: TemplateConfigCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + data = {k: v for k, v in body.model_dump().items() if v is not None} + config = TemplateConfig(user_id=user.id, **data) + session.add(config) + await session.commit() + await session.refresh(config) + return _response(config) + + +@router.get("/{config_id}") +async def get_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + return _response(await _get(session, config_id, user.id)) + + +@router.put("/{config_id}") +async def update_config( + config_id: int, + body: TemplateConfigUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await _get(session, config_id, user.id) + for field, value in body.model_dump(exclude_unset=True).items(): + if value is not None: + setattr(config, field, value) + session.add(config) + await session.commit() + await session.refresh(config) + return _response(config) + + +@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await _get(session, config_id, user.id) + await session.delete(config) + await session.commit() + + +@router.post("/{config_id}/preview") +async def preview_config( + config_id: int, + slot: str = "message_assets_added", + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """Render a specific template slot with sample data.""" + config = await _get(session, config_id, user.id) + template_body = getattr(config, slot, None) + if template_body is None: + raise HTTPException(status_code=400, detail=f"Unknown slot: {slot}") + try: + env = SandboxedEnvironment(autoescape=False) + tmpl = env.from_string(template_body) + rendered = tmpl.render(**_SAMPLE_CONTEXT) + return {"slot": slot, "rendered": rendered} + except Exception as e: + raise HTTPException(status_code=400, detail=f"Template error: {e}") + + +def _response(c: TemplateConfig) -> dict: + return {k: getattr(c, k) for k in TemplateConfig.model_fields if k != "user_id"} | { + "created_at": c.created_at.isoformat() + } + + +async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig: + config = await session.get(TemplateConfig, config_id) + if not config or config.user_id != user_id: + raise HTTPException(status_code=404, detail="Template config not found") + return config diff --git a/packages/server/src/immich_watcher_server/api/trackers.py b/packages/server/src/immich_watcher_server/api/trackers.py index 5ece775..dd8b349 100644 --- a/packages/server/src/immich_watcher_server/api/trackers.py +++ b/packages/server/src/immich_watcher_server/api/trackers.py @@ -16,41 +16,21 @@ class TrackerCreate(BaseModel): server_id: int name: str album_ids: list[str] - event_types: list[str] = ["assets_added"] target_ids: list[int] = [] - template_id: int | None = None scan_interval: int = 60 enabled: bool = True quiet_hours_start: str | None = None quiet_hours_end: str | None = None - track_images: bool = True - track_videos: bool = True - notify_favorites_only: bool = False - include_people: bool = True - include_asset_details: bool = False - max_assets_to_show: int = 5 - assets_order_by: str = "none" - assets_order: str = "descending" class TrackerUpdate(BaseModel): name: str | None = None album_ids: list[str] | None = None - event_types: list[str] | None = None target_ids: list[int] | None = None - template_id: int | None = None scan_interval: int | None = None enabled: bool | None = None quiet_hours_start: str | None = None quiet_hours_end: str | None = None - track_images: bool | None = None - track_videos: bool | None = None - notify_favorites_only: bool | None = None - include_people: bool | None = None - include_asset_details: bool | None = None - max_assets_to_show: int | None = None - assets_order_by: str | None = None - assets_order: str | None = None @router.get("") @@ -58,7 +38,6 @@ async def list_trackers( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """List all trackers for the current user.""" result = await session.exec( select(AlbumTracker).where(AlbumTracker.user_id == user.id) ) @@ -71,25 +50,11 @@ async def create_tracker( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Create a new album tracker.""" - # Verify server ownership server = await session.get(ImmichServer, body.server_id) if not server or server.user_id != user.id: raise HTTPException(status_code=404, detail="Server not found") - tracker = AlbumTracker( - user_id=user.id, - server_id=body.server_id, - name=body.name, - album_ids=body.album_ids, - event_types=body.event_types, - target_ids=body.target_ids, - template_id=body.template_id, - scan_interval=body.scan_interval, - enabled=body.enabled, - quiet_hours_start=body.quiet_hours_start, - quiet_hours_end=body.quiet_hours_end, - ) + tracker = AlbumTracker(user_id=user.id, **body.model_dump()) session.add(tracker) await session.commit() await session.refresh(tracker) @@ -102,9 +67,7 @@ async def get_tracker( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Get a specific tracker.""" - tracker = await _get_user_tracker(session, tracker_id, user.id) - return _tracker_response(tracker) + return _tracker_response(await _get_user_tracker(session, tracker_id, user.id)) @router.put("/{tracker_id}") @@ -114,7 +77,6 @@ async def update_tracker( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Update a tracker.""" tracker = await _get_user_tracker(session, tracker_id, user.id) for field, value in body.model_dump(exclude_unset=True).items(): setattr(tracker, field, value) @@ -130,7 +92,6 @@ async def delete_tracker( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Delete a tracker.""" tracker = await _get_user_tracker(session, tracker_id, user.id) await session.delete(tracker) await session.commit() @@ -142,7 +103,6 @@ async def trigger_tracker( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Force an immediate check for a tracker.""" tracker = await _get_user_tracker(session, tracker_id, user.id) from ..services.watcher import check_tracker_with_session result = await check_tracker_with_session(tracker.id, session) @@ -156,7 +116,6 @@ async def tracker_history( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Get recent events for a tracker.""" await _get_user_tracker(session, tracker_id, user.id) result = await session.exec( select(EventLog) @@ -183,21 +142,11 @@ def _tracker_response(t: AlbumTracker) -> dict: "name": t.name, "server_id": t.server_id, "album_ids": t.album_ids, - "event_types": t.event_types, "target_ids": t.target_ids, - "template_id": t.template_id, "scan_interval": t.scan_interval, "enabled": t.enabled, "quiet_hours_start": t.quiet_hours_start, "quiet_hours_end": t.quiet_hours_end, - "track_images": t.track_images, - "track_videos": t.track_videos, - "notify_favorites_only": t.notify_favorites_only, - "include_people": t.include_people, - "include_asset_details": t.include_asset_details, - "max_assets_to_show": t.max_assets_to_show, - "assets_order_by": t.assets_order_by, - "assets_order": t.assets_order, "created_at": t.created_at.isoformat(), } diff --git a/packages/server/src/immich_watcher_server/api/tracking_configs.py b/packages/server/src/immich_watcher_server/api/tracking_configs.py new file mode 100644 index 0000000..a1e446f --- /dev/null +++ b/packages/server/src/immich_watcher_server/api/tracking_configs.py @@ -0,0 +1,157 @@ +"""Tracking configuration CRUD API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import TrackingConfig, User + +router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"]) + + +class TrackingConfigCreate(BaseModel): + name: str + track_assets_added: bool = True + track_assets_removed: bool = False + track_album_renamed: bool = True + track_album_deleted: bool = True + track_images: bool = True + track_videos: bool = True + notify_favorites_only: bool = False + include_people: bool = True + include_asset_details: bool = False + max_assets_to_show: int = 5 + assets_order_by: str = "none" + assets_order: str = "descending" + periodic_enabled: bool = False + periodic_interval_days: int = 1 + periodic_start_date: str = "2025-01-01" + periodic_times: str = "12:00" + scheduled_enabled: bool = False + scheduled_times: str = "09:00" + scheduled_album_mode: str = "per_album" + scheduled_limit: int = 10 + scheduled_favorite_only: bool = False + scheduled_asset_type: str = "all" + scheduled_min_rating: int = 0 + scheduled_order_by: str = "random" + scheduled_order: str = "descending" + memory_enabled: bool = False + memory_times: str = "09:00" + memory_album_mode: str = "combined" + memory_limit: int = 10 + memory_favorite_only: bool = False + memory_asset_type: str = "all" + memory_min_rating: int = 0 + + +class TrackingConfigUpdate(BaseModel): + name: str | None = None + track_assets_added: bool | None = None + track_assets_removed: bool | None = None + track_album_renamed: bool | None = None + track_album_deleted: bool | None = None + track_images: bool | None = None + track_videos: bool | None = None + notify_favorites_only: bool | None = None + include_people: bool | None = None + include_asset_details: bool | None = None + max_assets_to_show: int | None = None + assets_order_by: str | None = None + assets_order: str | None = None + periodic_enabled: bool | None = None + periodic_interval_days: int | None = None + periodic_start_date: str | None = None + periodic_times: str | None = None + scheduled_enabled: bool | None = None + scheduled_times: str | None = None + scheduled_album_mode: str | None = None + scheduled_limit: int | None = None + scheduled_favorite_only: bool | None = None + scheduled_asset_type: str | None = None + scheduled_min_rating: int | None = None + scheduled_order_by: str | None = None + scheduled_order: str | None = None + memory_enabled: bool | None = None + memory_times: str | None = None + memory_album_mode: str | None = None + memory_limit: int | None = None + memory_favorite_only: bool | None = None + memory_asset_type: str | None = None + memory_min_rating: int | None = None + + +@router.get("") +async def list_configs( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + result = await session.exec( + select(TrackingConfig).where(TrackingConfig.user_id == user.id) + ) + return [_response(c) for c in result.all()] + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_config( + body: TrackingConfigCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = TrackingConfig(user_id=user.id, **body.model_dump()) + session.add(config) + await session.commit() + await session.refresh(config) + return _response(config) + + +@router.get("/{config_id}") +async def get_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + return _response(await _get(session, config_id, user.id)) + + +@router.put("/{config_id}") +async def update_config( + config_id: int, + body: TrackingConfigUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await _get(session, config_id, user.id) + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(config, field, value) + session.add(config) + await session.commit() + await session.refresh(config) + return _response(config) + + +@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await _get(session, config_id, user.id) + await session.delete(config) + await session.commit() + + +def _response(c: TrackingConfig) -> dict: + return {k: getattr(c, k) for k in TrackingConfig.model_fields if k != "user_id"} | { + "created_at": c.created_at.isoformat() + } + + +async def _get(session: AsyncSession, config_id: int, user_id: int) -> TrackingConfig: + config = await session.get(TrackingConfig, config_id) + if not config or config.user_id != user_id: + raise HTTPException(status_code=404, detail="Tracking config not found") + return config diff --git a/packages/server/src/immich_watcher_server/database/__init__.py b/packages/server/src/immich_watcher_server/database/__init__.py index 7b6d840..2fe3a3b 100644 --- a/packages/server/src/immich_watcher_server/database/__init__.py +++ b/packages/server/src/immich_watcher_server/database/__init__.py @@ -6,9 +6,9 @@ from .models import ( AlbumTracker, EventLog, ImmichServer, - MessageTemplate, NotificationTarget, - ScheduledJob, + TemplateConfig, + TrackingConfig, User, ) @@ -19,7 +19,8 @@ __all__ = [ "AlbumTracker", "EventLog", "ImmichServer", - "MessageTemplate", "NotificationTarget", + "TemplateConfig", + "TrackingConfig", "User", ] diff --git a/packages/server/src/immich_watcher_server/database/models.py b/packages/server/src/immich_watcher_server/database/models.py index 618925c..43f73b8 100644 --- a/packages/server/src/immich_watcher_server/database/models.py +++ b/packages/server/src/immich_watcher_server/database/models.py @@ -36,8 +36,121 @@ class ImmichServer(SQLModel, table=True): created_at: datetime = Field(default_factory=_utcnow) +class TrackingConfig(SQLModel, table=True): + """Tracking configuration: what events/assets to react to and scheduled modes.""" + + __tablename__ = "tracking_config" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + name: str + + # Event-driven tracking + track_assets_added: bool = Field(default=True) + track_assets_removed: bool = Field(default=False) + track_album_renamed: bool = Field(default=True) + track_album_deleted: bool = Field(default=True) + track_images: bool = Field(default=True) + track_videos: bool = Field(default=True) + notify_favorites_only: bool = Field(default=False) + + # Asset display in notifications + include_people: bool = Field(default=True) + include_asset_details: bool = Field(default=False) + max_assets_to_show: int = Field(default=5) + assets_order_by: str = Field(default="none") # none/date/rating/name + assets_order: str = Field(default="descending") + + # Periodic summary + periodic_enabled: bool = Field(default=False) + periodic_interval_days: int = Field(default=1) + periodic_start_date: str = Field(default="2025-01-01") + periodic_times: str = Field(default="12:00") + + # Scheduled assets + scheduled_enabled: bool = Field(default=False) + scheduled_times: str = Field(default="09:00") + scheduled_album_mode: str = Field(default="per_album") # per_album/combined/random + scheduled_limit: int = Field(default=10) + scheduled_favorite_only: bool = Field(default=False) + scheduled_asset_type: str = Field(default="all") # all/photo/video + scheduled_min_rating: int = Field(default=0) + scheduled_order_by: str = Field(default="random") + scheduled_order: str = Field(default="descending") + + # Memory mode (On This Day) + memory_enabled: bool = Field(default=False) + memory_times: str = Field(default="09:00") + memory_album_mode: str = Field(default="combined") + memory_limit: int = Field(default=10) + memory_favorite_only: bool = Field(default=False) + memory_asset_type: str = Field(default="all") + memory_min_rating: int = Field(default=0) + + created_at: datetime = Field(default_factory=_utcnow) + + +class TemplateConfig(SQLModel, table=True): + """Message template configuration: all template slots from the blueprint.""" + + __tablename__ = "template_config" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + name: str # e.g. "Default EN", "Default RU" + + # Event messages + message_assets_added: str = Field( + default='šŸ“· {added_count} new photo(s) added to album "{album_name}"{common_date}{common_location}.{people}{assets}{video_warning}' + ) + message_assets_removed: str = Field( + default='šŸ—‘ļø {removed_count} photo(s) removed from album "{album_name}".' + ) + message_album_renamed: str = Field( + default='āœļø Album "{old_name}" renamed to "{new_name}".' + ) + message_album_deleted: str = Field( + default='šŸ—‘ļø Album "{album_name}" was deleted.' + ) + + # Asset item formatting + message_asset_image: str = Field(default="\n • šŸ–¼ļø {filename}") + message_asset_video: str = Field(default="\n • šŸŽ¬ {filename}") + message_assets_format: str = Field(default="\nAssets:{assets}") + message_assets_more: str = Field(default="\n • ...and {more_count} more") + message_people_format: str = Field(default=" People: {people}.") + + # Date/location formatting + date_format: str = Field(default="%d.%m.%Y, %H:%M UTC") + common_date_template: str = Field(default=" from {date}") + date_if_unique_template: str = Field(default=" ({date})") + location_format: str = Field(default="{city}, {country}") + common_location_template: str = Field(default=" in {location}") + location_if_unique_template: str = Field(default=" šŸ“ {location}") + favorite_indicator: str = Field(default="ā¤ļø") + + # Scheduled notification templates + periodic_summary_message: str = Field( + default="šŸ“‹ Tracked Albums Summary ({album_count} albums):{albums}" + ) + periodic_album_template: str = Field( + default="\n • {album_name}: {album_url}" + ) + scheduled_assets_message: str = Field( + default='šŸ“ø Here are some photos from album "{album_name}":{assets}' + ) + memory_mode_message: str = Field(default="šŸ“… On this day:{assets}") + + # Telegram-specific + video_warning: str = Field( + default="\n\nāš ļø Note: Videos may not be sent due to Telegram's 50 MB file size limit." + ) + + created_at: datetime = Field(default_factory=_utcnow) + + class NotificationTarget(SQLModel, table=True): - """Notification destination (Telegram chat, webhook URL).""" + """Notification destination with tracking and template config references.""" __tablename__ = "notification_target" @@ -46,26 +159,13 @@ class NotificationTarget(SQLModel, table=True): 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="") - body_ru: str = Field(default="") # Russian locale variant (empty = use body) - event_type: str = Field(default="") # "" = all events, or specific type - is_default: bool = Field(default=False) + tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id") + template_config_id: int | None = Field(default=None, foreign_key="template_config.id") created_at: datetime = Field(default_factory=_utcnow) class AlbumTracker(SQLModel, table=True): - """Album change tracker configuration.""" + """Album change tracker: which albums to poll and who to notify.""" __tablename__ = "album_tracker" @@ -74,53 +174,11 @@ class AlbumTracker(SQLModel, table=True): 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 + scan_interval: int = Field(default=60) enabled: bool = Field(default=True) - quiet_hours_start: str | None = None # "HH:MM" - quiet_hours_end: str | None = None # "HH:MM" - # Enhanced filtering (matching HAOS blueprint) - track_images: bool = Field(default=True) - track_videos: bool = Field(default=True) - notify_favorites_only: bool = Field(default=False) - include_people: bool = Field(default=True) - include_asset_details: bool = Field(default=False) - max_assets_to_show: int = Field(default=5) - assets_order_by: str = Field(default="none") # none/date/rating/name/random - assets_order: str = Field(default="descending") - created_at: datetime = Field(default_factory=_utcnow) - - -class ScheduledJob(SQLModel, table=True): - """Scheduled notification job (periodic summary, scheduled assets, memory mode).""" - - __tablename__ = "scheduled_job" - - id: int | None = Field(default=None, primary_key=True) - tracker_id: int = Field(foreign_key="album_tracker.id") - job_type: str # "periodic_summary", "scheduled_assets", "memory" - enabled: bool = Field(default=True) - # Timing - times: str = Field(default="09:00") # "HH:MM, HH:MM" - interval_days: int = Field(default=1) # For periodic: 1=daily, 7=weekly - start_date: str = Field(default="2025-01-01") # For periodic interval anchor - # Asset fetching config (scheduled_assets + memory modes) - album_mode: str = Field(default="per_album") # per_album/combined/random - limit: int = Field(default=10) - favorite_only: bool = Field(default=False) - asset_type: str = Field(default="all") # all/photo/video - min_rating: int = Field(default=0) # 0=no filter, 1-5 - order_by: str = Field(default="random") - order: str = Field(default="descending") - min_date: str | None = None - max_date: str | None = None - # Template - message_template: str = Field(default="") # Custom Jinja2 template for this job + quiet_hours_start: str | None = None + quiet_hours_end: str | None = None created_at: datetime = Field(default_factory=_utcnow) diff --git a/packages/server/src/immich_watcher_server/main.py b/packages/server/src/immich_watcher_server/main.py index afe3819..df0700a 100644 --- a/packages/server/src/immich_watcher_server/main.py +++ b/packages/server/src/immich_watcher_server/main.py @@ -17,12 +17,12 @@ from .services.scheduler import start_scheduler, stop_scheduler from .auth.routes import router as auth_router from .api.servers import router as servers_router from .api.trackers import router as trackers_router -from .api.templates import router as templates_router +from .api.tracking_configs import router as tracking_configs_router +from .api.template_configs import router as template_configs_router from .api.targets import router as targets_router from .api.users import router as users_router from .api.status import router as status_router from .api.sync import router as sync_router -from .api.scheduled import router as scheduled_router from .ai.telegram_webhook import router as telegram_ai_router logging.basicConfig( @@ -68,12 +68,12 @@ app.add_middleware( app.include_router(auth_router) app.include_router(servers_router) app.include_router(trackers_router) -app.include_router(templates_router) +app.include_router(tracking_configs_router) +app.include_router(template_configs_router) app.include_router(targets_router) app.include_router(users_router) app.include_router(status_router) app.include_router(sync_router) -app.include_router(scheduled_router) app.include_router(telegram_ai_router) # Serve frontend static files if available diff --git a/packages/server/src/immich_watcher_server/services/notifier.py b/packages/server/src/immich_watcher_server/services/notifier.py index 510456d..fd63158 100644 --- a/packages/server/src/immich_watcher_server/services/notifier.py +++ b/packages/server/src/immich_watcher_server/services/notifier.py @@ -11,7 +11,7 @@ from jinja2.sandbox import SandboxedEnvironment from immich_watcher_core.telegram.client import TelegramClient -from ..database.models import MessageTemplate, NotificationTarget +from ..database.models import NotificationTarget, TemplateConfig from ..webhook.client import WebhookClient _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ def render_template(template_body: str, context: dict[str, Any]) -> str: async def send_notification( target: NotificationTarget, event_data: dict[str, Any], - template: MessageTemplate | None = None, + template_config: TemplateConfig | None = None, use_ai_caption: bool = False, ) -> dict[str, Any]: """Send a notification to a target using event data. @@ -41,7 +41,7 @@ async def send_notification( Args: target: Notification destination (telegram or webhook) event_data: Album change event data (album_name, added_count, etc.) - template: Optional message template (uses default if None) + template_config: Optional template config with per-event templates use_ai_caption: If True, generate caption with Claude AI instead of template """ message = None @@ -54,7 +54,18 @@ async def send_notification( # Fall back to template rendering if message is None: - template_body = template.body if template else DEFAULT_TEMPLATE + template_body = DEFAULT_TEMPLATE + if template_config: + # Select the right template slot based on event type + change_type = event_data.get("change_type", "") + slot_map = { + "assets_added": "message_assets_added", + "assets_removed": "message_assets_removed", + "album_renamed": "message_album_renamed", + "album_deleted": "message_album_deleted", + } + slot = slot_map.get(change_type, "message_assets_added") + template_body = getattr(template_config, slot, DEFAULT_TEMPLATE) or DEFAULT_TEMPLATE try: message = render_template(template_body, event_data) except jinja2.TemplateError as e: diff --git a/packages/server/src/immich_watcher_server/services/watcher.py b/packages/server/src/immich_watcher_server/services/watcher.py index d3d3b21..88e0588 100644 --- a/packages/server/src/immich_watcher_server/services/watcher.py +++ b/packages/server/src/immich_watcher_server/services/watcher.py @@ -22,8 +22,9 @@ from ..database.models import ( AlbumTracker, EventLog, ImmichServer, - MessageTemplate, NotificationTarget, + TemplateConfig, + TrackingConfig, ) from .notifier import send_notification @@ -58,9 +59,7 @@ async def check_tracker_with_session( # 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 @@ -69,14 +68,13 @@ async def check_tracker_with_session( 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, + album_id, users_cache, target_ids, ) results.append(result) @@ -91,9 +89,7 @@ async def _check_album( 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: @@ -157,10 +153,6 @@ async def _check_album( if change is None: return {"album_id": album_id, "status": "no_changes"} - # Check if this event type is tracked - 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 shared_links = await client.get_shared_links(album_id) event_data = _build_event_data(change, album, client.external_url, shared_links) @@ -174,19 +166,41 @@ async def _check_album( ) session.add(event_log) - # Send notifications to all configured targets + # Send notifications to each target, filtered by its tracking config for target_id in target_ids: target = await session.get(NotificationTarget, target_id) if not target: continue - template = None - if template_id: - template = await session.get(MessageTemplate, template_id) + # Check target's tracking config for event filtering + tracking_config = None + if target.tracking_config_id: + tracking_config = await session.get(TrackingConfig, target.tracking_config_id) + + if tracking_config: + # Filter by event type + should_notify = False + if change.change_type == "assets_added" and tracking_config.track_assets_added: + should_notify = True + elif change.change_type == "assets_removed" and tracking_config.track_assets_removed: + should_notify = True + elif change.change_type == "album_renamed" and tracking_config.track_album_renamed: + should_notify = True + elif change.change_type == "album_deleted" and tracking_config.track_album_deleted: + should_notify = True + elif change.change_type == "changed": + should_notify = True # "changed" = mixed, always notify + if not should_notify: + continue + + # Get target's template config + template_config = None + if target.template_config_id: + template_config = await session.get(TemplateConfig, target.template_config_id) try: use_ai = target.config.get("ai_captions", False) - await send_notification(target, event_data, template, use_ai_caption=use_ai) + await send_notification(target, event_data, template_config, use_ai_caption=use_ai) except Exception: _LOGGER.exception("Failed to send notification to target %d", target_id)