Restructure data model: TrackingConfig + TemplateConfig entities
Some checks failed
Validate / Hassfest (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 16:57:19 +03:00
parent fd1ad91fbe
commit 90b4713d5c
10 changed files with 508 additions and 158 deletions

View File

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

View File

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

View File

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

View File

@@ -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(),
}

View File

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

View File

@@ -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",
]

View File

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

View File

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

View File

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

View File

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