feat(immich): per-album scheduled/memory dispatch + template tooling
Dispatch: honor {kind}_collection_mode on TrackingConfig — "per_collection"
fans out one event per album; "combined" pools assets as before. Extract
build_immich_dispatch_events shared by cron and test paths.
Assets: collect_scheduled_assets attaches album_name/album_url/album_public_url
to each asset so combined-mode templates can attribute rows to their source
album. Default scheduled_assets templates render a multi-album header with
inline album list and per-row album link; memory_mode follows the same pattern.
UI: "Reset to default" buttons on notification and command template slots
(per-slot and whole-template), backed by new GET /*-template-configs/defaults
endpoints. tracking-configs "Preview template" now opens an inline preview
modal with locale tabs instead of navigating away; Edit button deep-links
with ?edit_slot=<name> so the destination auto-opens the config and scrolls
to the slot. Reset confirmations use ConfirmModal instead of window.confirm.
Fixes:
* NotificationDispatcher._session_ctx infinite recursion when no shared
aiohttp.ClientSession was passed — broke test dispatch for periodic/
scheduled/memory (cron path was unaffected).
* telegram-bots /chats/{id}/test now resolves chat.language_override /
language_code instead of using the raw ?locale query param, matching
the resolution the tracker-target test endpoint already used.
* scheduled_assets default template no longer emits a blank line between
header and the first asset when the multi-album branch is taken.
This commit is contained in:
@@ -105,7 +105,7 @@ class NotificationDispatcher:
|
||||
if self._shared_session is not None and not self._shared_session.closed:
|
||||
yield self._shared_session
|
||||
return
|
||||
async with self._session_ctx() as session:
|
||||
async with _new_session() as session:
|
||||
yield session
|
||||
|
||||
async def dispatch(
|
||||
|
||||
@@ -333,8 +333,11 @@ def collect_scheduled_assets(
|
||||
memory_date = now.isoformat() if is_memory else None
|
||||
|
||||
all_eligible: list[ImmichAssetInfo] = []
|
||||
# Track which album each asset belongs to for public URL construction
|
||||
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
|
||||
# Track which album each asset belongs to. Public URL is used to construct
|
||||
# a per-asset share link; name/internal-url are surfaced to templates so
|
||||
# combined-mode sends can attribute each row to its source album.
|
||||
asset_album_map: dict[str, tuple[str, str, str, str]] = {}
|
||||
# asset_id → (album_id, album_public_url, album_name, album_internal_url)
|
||||
collections_extra: list[dict[str, Any]] = []
|
||||
|
||||
# limit=0 is the periodic-summary test path — the caller only needs
|
||||
@@ -346,10 +349,11 @@ def collect_scheduled_assets(
|
||||
for album_id, album in albums.items():
|
||||
links = shared_links.get(album_id, [])
|
||||
album_public_url = get_public_url(external_url, links) or ""
|
||||
album_internal_url = f"{external_url}/albums/{album_id}"
|
||||
|
||||
collections_extra.append({
|
||||
"name": album.name,
|
||||
"url": album_public_url or f"{external_url}/albums/{album_id}",
|
||||
"url": album_public_url or album_internal_url,
|
||||
"public_url": album_public_url,
|
||||
"asset_count": album.asset_count,
|
||||
"shared": album.shared,
|
||||
@@ -370,7 +374,9 @@ def collect_scheduled_assets(
|
||||
)
|
||||
for asset in filtered:
|
||||
if asset.id not in asset_album_map:
|
||||
asset_album_map[asset.id] = (album_id, album_public_url)
|
||||
asset_album_map[asset.id] = (
|
||||
album_id, album_public_url, album.name, album_internal_url,
|
||||
)
|
||||
all_eligible.append(asset)
|
||||
|
||||
if stats_only:
|
||||
@@ -383,15 +389,25 @@ def collect_scheduled_assets(
|
||||
random.shuffle(all_eligible)
|
||||
selected = all_eligible
|
||||
|
||||
# Convert to MediaAsset with public URLs
|
||||
# Convert to MediaAsset with public URLs. Per-asset album_name/album_url
|
||||
# let combined-mode templates attribute each row to its source album —
|
||||
# critical when a tracker spans multiple albums, where the event-level
|
||||
# ``album_name`` (first album only) would be misleading.
|
||||
result: list[MediaAsset] = []
|
||||
for asset in selected:
|
||||
media = asset_to_media(asset, external_url)
|
||||
_, album_pub_url = asset_album_map.get(asset.id, ("", ""))
|
||||
mapped = asset_album_map.get(asset.id)
|
||||
if mapped:
|
||||
_, album_pub_url, album_name, album_internal_url = mapped
|
||||
else:
|
||||
album_pub_url = album_name = album_internal_url = ""
|
||||
if album_pub_url:
|
||||
media.extra["public_url"] = f"{album_pub_url}/photos/{asset.id}"
|
||||
else:
|
||||
media.extra.setdefault("public_url", "")
|
||||
media.extra["album_name"] = album_name
|
||||
media.extra["album_url"] = album_pub_url or album_internal_url
|
||||
media.extra["album_public_url"] = album_pub_url
|
||||
result.append(media)
|
||||
|
||||
return result, collections_extra
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
⭐ Favorites:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} ❤️
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ❤️
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -1,6 +1,6 @@
|
||||
📸 Latest:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -1,5 +1,6 @@
|
||||
📅 On this day:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -1,6 +1,6 @@
|
||||
🎲 Random:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -4,7 +4,7 @@
|
||||
{%- else %}🔍 Results for "{{ query }}":
|
||||
{%- endif %}
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -1,4 +1,6 @@
|
||||
📋 Album summary ({{ albums | length }}):
|
||||
{%- for album in albums %}
|
||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
|
||||
{%- endfor %}
|
||||
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
|
||||
{%- if album.shared %} 🔗{% endif %}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
⭐ Избранное:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} ❤️
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ❤️
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -1,6 +1,6 @@
|
||||
📸 Последние:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -1,5 +1,6 @@
|
||||
📅 В этот день:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -1,6 +1,6 @@
|
||||
🎲 Случайные:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -4,7 +4,7 @@
|
||||
{%- else %}🔍 Результаты по "{{ query }}":
|
||||
{%- endif %}
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -1,4 +1,6 @@
|
||||
📋 Сводка альбомов ({{ albums | length }}):
|
||||
{%- for album in albums %}
|
||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
|
||||
{%- endfor %}
|
||||
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
|
||||
{%- if album.shared %} 🔗{% endif %}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
📅 On this day:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
📋 Tracked Albums Summary ({{ albums | length }} albums):
|
||||
{%- for album in albums %}
|
||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
|
||||
{%- endfor %}
|
||||
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
|
||||
{%- if album.shared %} 🔗{% endif %}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{%- if albums and albums|length > 1 -%}
|
||||
🗓️ Scheduled delivery — random photos from {% for album in albums %}{% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}:
|
||||
{%- else -%}
|
||||
🗓️ Scheduled delivery — random photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||
{%- endif %}
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
📅 В этот день:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
📋 Сводка альбомов ({{ albums | length }}):
|
||||
{%- for album in albums %}
|
||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
|
||||
{%- endfor %}
|
||||
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
|
||||
{%- if album.shared %} 🔗{% endif %}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{%- if albums and albums|length > 1 -%}
|
||||
🗓️ Доставка по расписанию — случайные фото из {% for album in albums %}{% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}:
|
||||
{%- else -%}
|
||||
🗓️ Доставка по расписанию — случайные фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||
{%- endif %}
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -74,6 +74,36 @@ async def _get(session: AsyncSession, config_id: int, user_id: int) -> CommandTe
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/defaults")
|
||||
async def get_default_command_templates(
|
||||
provider_type: str,
|
||||
slot_name: str | None = None,
|
||||
locale: str | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the shipped Jinja2 default command templates for a provider type.
|
||||
|
||||
Used by the UI's "Reset to default" actions. Filtering works the same way
|
||||
as the notification-template equivalent: omit ``slot_name`` for the whole
|
||||
set, omit ``locale`` for every locale.
|
||||
|
||||
Response shape: ``{slot_name: {locale: template_text}}``
|
||||
"""
|
||||
from notify_bridge_core.templates.command_defaults.loader import (
|
||||
load_default_command_templates,
|
||||
)
|
||||
from notify_bridge_core.templates.defaults.loader import get_available_locales
|
||||
locales = [locale] if locale else get_available_locales()
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for loc in locales:
|
||||
defaults = load_default_command_templates(loc, provider_type)
|
||||
for name, text in defaults.items():
|
||||
if slot_name and name != slot_name:
|
||||
continue
|
||||
result.setdefault(name, {})[loc] = text
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/variables")
|
||||
async def get_command_variables(
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -84,15 +114,26 @@ async def get_command_variables(
|
||||
}
|
||||
asset_fields = {
|
||||
"id": "Asset ID (UUID)",
|
||||
"originalFileName": "Original filename",
|
||||
"filename": "Original filename (preferred; same as originalFileName)",
|
||||
"originalFileName": "Original filename (alias of filename, kept for backward-compat with older templates)",
|
||||
"type": "IMAGE or VIDEO",
|
||||
"createdAt": "Creation date/time (ISO 8601)",
|
||||
"created_at": "Creation date/time (ISO 8601)",
|
||||
"createdAt": "Creation date/time (alias of created_at)",
|
||||
"year": "Year of the memory (memory command only)",
|
||||
"public_url": "Per-asset public share URL (empty if no album link)",
|
||||
"city": "City name (empty if unknown)",
|
||||
"country": "Country name (empty if unknown)",
|
||||
"is_favorite": "Whether asset is favorited (boolean)",
|
||||
}
|
||||
album_fields = {
|
||||
"name": "Album name",
|
||||
"asset_count": "Number of assets in the album",
|
||||
"id": "Album ID (UUID)",
|
||||
"public_url": "Public share link URL (empty if none)",
|
||||
"asset_count": "Number of assets in the album",
|
||||
"photo_count": "Number of photos in the album",
|
||||
"video_count": "Number of videos in the album",
|
||||
"shared": "Whether the album is shared (boolean)",
|
||||
"owner": "Album owner display name",
|
||||
}
|
||||
command_fields = {
|
||||
"name": "Command name (e.g. status, albums)",
|
||||
@@ -492,10 +533,11 @@ async def preview_raw(
|
||||
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
|
||||
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
|
||||
],
|
||||
# /albums, /summary
|
||||
# /albums, /summary — provide photo/video split, sharing, owner so the
|
||||
# enriched summary template previews fully.
|
||||
"albums": [
|
||||
{"name": "Family Photos", "asset_count": 142, "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
|
||||
{"name": "Vacation 2025", "asset_count": 87, "id": "def-456", "public_url": ""},
|
||||
{"name": "Family Photos", "asset_count": 142, "photo_count": 120, "video_count": 22, "shared": True, "owner": "Alice", "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
|
||||
{"name": "Vacation 2025", "asset_count": 87, "photo_count": 80, "video_count": 7, "shared": False, "owner": "Bob", "id": "def-456", "public_url": ""},
|
||||
],
|
||||
# /events
|
||||
"events": [
|
||||
@@ -505,9 +547,12 @@ async def preview_raw(
|
||||
# /people
|
||||
"people": ["Alice", "Bob", "Charlie"],
|
||||
# /search, /find, /person, /place, /latest, /favorites, /random, /memory
|
||||
# ``filename`` is the canonical key (matches notification context and
|
||||
# build_asset_dict output); ``originalFileName`` is kept as an alias
|
||||
# so templates still using the old key render in preview.
|
||||
"assets": [
|
||||
{"id": "a1", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
|
||||
{"id": "a2", "originalFileName": "VID_002.mp4", "type": "VIDEO", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
|
||||
{"id": "a1", "filename": "IMG_001.jpg", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "created_at": "2026-03-19T14:30:00", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
|
||||
{"id": "a2", "filename": "VID_002.mp4", "originalFileName": "VID_002.mp4", "type": "VIDEO", "created_at": "2026-03-19T15:00:00", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
|
||||
],
|
||||
"query": "sunset",
|
||||
"command": "search",
|
||||
|
||||
@@ -426,19 +426,45 @@ async def get_album_shared_links(
|
||||
return []
|
||||
|
||||
|
||||
class CreateSharedLinkRequest(BaseModel):
|
||||
"""Options for POST /shared-links.
|
||||
|
||||
``replace=True`` deletes every existing link for the album before creating
|
||||
the new one, which is the only way to repair an expired or password-
|
||||
protected link in the Immich API (there is no in-place "reset" endpoint).
|
||||
Default ``False`` preserves the original additive behaviour used by the
|
||||
"auto-create missing links" flow.
|
||||
"""
|
||||
|
||||
replace: bool = False
|
||||
|
||||
|
||||
@router.post("/{provider_id}/albums/{album_id}/shared-links")
|
||||
async def create_album_shared_link(
|
||||
provider_id: int,
|
||||
album_id: str,
|
||||
body: CreateSharedLinkRequest | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Auto-create a public shared link for an album."""
|
||||
"""Auto-create a public shared link for an album.
|
||||
|
||||
With ``replace=True`` existing links for the album are deleted first, so
|
||||
expired/password-protected links are effectively recycled into a fresh
|
||||
public one.
|
||||
"""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
http_session = await get_http_session()
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
if body and body.replace:
|
||||
# Best-effort delete; if any delete fails we still try to create —
|
||||
# the user will see the new link co-exist alongside the old one,
|
||||
# which is better than a hard failure that leaves them stuck.
|
||||
existing = await immich.client.get_shared_links(album_id)
|
||||
for link in existing:
|
||||
await immich.client.delete_shared_link(link.id)
|
||||
success = await immich.client.create_shared_link(album_id)
|
||||
if success:
|
||||
return {"success": True}
|
||||
|
||||
@@ -298,10 +298,30 @@ async def test_chat(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test message to a chat via the bot."""
|
||||
"""Send a test message to a chat via the bot.
|
||||
|
||||
Locale resolution: prefer the chat row's ``language_override`` (explicit
|
||||
user choice in the UI), fall back to Telegram's ``language_code`` sent
|
||||
with the chat, and only use the ``?locale=`` query param if neither is
|
||||
set. Otherwise users who set RU on a chat would still see an EN test.
|
||||
"""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
chat_row = (await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)).first()
|
||||
effective_locale = locale
|
||||
if chat_row:
|
||||
chat_locale = (
|
||||
getattr(chat_row, 'language_override', '') or
|
||||
getattr(chat_row, 'language_code', '') or ''
|
||||
)
|
||||
if chat_locale:
|
||||
effective_locale = chat_locale[:2].lower()
|
||||
from ..services.http_session import get_http_session
|
||||
message = _get_test_message(locale, "telegram")
|
||||
message = _get_test_message(effective_locale, "telegram")
|
||||
http = await get_http_session()
|
||||
client = TelegramClient(http, bot.token)
|
||||
return await client.send_message(chat_id, message)
|
||||
|
||||
@@ -102,6 +102,37 @@ async def list_configs(
|
||||
return [await _response(session, c) for c in result.all()]
|
||||
|
||||
|
||||
@router.get("/defaults")
|
||||
async def get_default_slot_templates(
|
||||
provider_type: str,
|
||||
slot_name: str | None = None,
|
||||
locale: str | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the shipped Jinja2 default templates for a provider type.
|
||||
|
||||
Used by the UI's "Reset to default" actions. Filtering is optional —
|
||||
omit ``slot_name`` to get every slot, omit ``locale`` to get every locale.
|
||||
Registered before ``/{config_id}`` so the literal path wins over the
|
||||
path-parameter route in FastAPI's matcher.
|
||||
|
||||
Response shape: ``{slot_name: {locale: template_text}}``
|
||||
"""
|
||||
from notify_bridge_core.templates.defaults.loader import (
|
||||
get_available_locales,
|
||||
load_default_templates,
|
||||
)
|
||||
locales = [locale] if locale else get_available_locales()
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for loc in locales:
|
||||
defaults = load_default_templates(loc, provider_type)
|
||||
for name, text in defaults.items():
|
||||
if slot_name and name != slot_name:
|
||||
continue
|
||||
result.setdefault(name, {})[loc] = text
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/variables")
|
||||
async def get_template_variables(
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -170,13 +201,20 @@ async def get_template_variables(
|
||||
"download_url": "Direct download URL (if shared)",
|
||||
"photo_url": "Preview image URL (images only, if shared)",
|
||||
"playback_url": "Video playback URL (videos only, if shared)",
|
||||
# Per-asset album attribution (scheduled/memory templates in combined mode).
|
||||
"album_name": "Source album name (combined-mode scheduled/memory only)",
|
||||
"album_url": "Source album URL — public share link if available, else internal album URL",
|
||||
"album_public_url": "Source album public share URL (empty if no public link)",
|
||||
}
|
||||
album_fields = {
|
||||
"name": "Collection/album name",
|
||||
"url": "Share URL",
|
||||
"public_url": "Public share link URL",
|
||||
"asset_count": "Total assets in collection",
|
||||
"shared": "Whether collection is shared",
|
||||
"photo_count": "Number of photos in the album",
|
||||
"video_count": "Number of videos in the album",
|
||||
"shared": "Whether collection is shared (boolean)",
|
||||
"owner": "Album owner display name",
|
||||
}
|
||||
scheduled_vars = {
|
||||
"date": "Current date string",
|
||||
@@ -217,12 +255,26 @@ async def get_template_variables(
|
||||
},
|
||||
"scheduled_assets_message": {
|
||||
"description": "Scheduled asset delivery (daily photo picks)",
|
||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
||||
"variables": {
|
||||
**scheduled_vars,
|
||||
"assets": "List of asset dicts (use {% for asset in assets %})",
|
||||
"album_name": "Source album name",
|
||||
"public_url": "Public share link URL for the source album (empty if none)",
|
||||
"asset_count": "Total assets in the source album",
|
||||
"photo_count": "Photos in the source album",
|
||||
"video_count": "Videos in the source album",
|
||||
"owner": "Source album owner",
|
||||
},
|
||||
"asset_fields": asset_fields,
|
||||
},
|
||||
"memory_mode_message": {
|
||||
"description": "\"On This Day\" memories from previous years",
|
||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
||||
"variables": {
|
||||
**scheduled_vars,
|
||||
"assets": "List of asset dicts (use {% for asset in assets %})",
|
||||
"album_name": "Source album name (when rendered per-album)",
|
||||
"public_url": "Public share link URL for the source album (empty if none)",
|
||||
},
|
||||
"asset_fields": asset_fields,
|
||||
},
|
||||
# --- Generic Webhook slots ---
|
||||
|
||||
@@ -76,16 +76,28 @@ def build_asset_dict(
|
||||
public_url: str = "",
|
||||
year: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict."""
|
||||
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict.
|
||||
|
||||
Asset-dict contract (shared with notification templates — see
|
||||
``notify_bridge_core.templates.context``): templates may read either
|
||||
``filename`` (the canonical field, used by notification defaults) or
|
||||
``originalFileName`` (the historical command-default field); both are
|
||||
populated so a custom template authored against either key keeps working.
|
||||
Same story for ``created_at`` / ``createdAt``.
|
||||
"""
|
||||
if isinstance(asset, dict):
|
||||
# Immich raw search responses nest geo under exifInfo — pull it out so
|
||||
# templates can use flat asset.city / asset.country.
|
||||
exif = asset.get("exifInfo") or {}
|
||||
fname = asset.get("originalFileName") or asset.get("filename") or ""
|
||||
created = asset.get("createdAt") or asset.get("created_at") or asset.get("fileCreatedAt") or ""
|
||||
d = {
|
||||
"id": asset.get("id", ""),
|
||||
"originalFileName": asset.get("originalFileName", asset.get("filename", "")),
|
||||
"filename": fname,
|
||||
"originalFileName": fname,
|
||||
"type": asset.get("type", "IMAGE"),
|
||||
"createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))),
|
||||
"created_at": created,
|
||||
"createdAt": created,
|
||||
"city": asset.get("city") or exif.get("city") or "",
|
||||
"country": asset.get("country") or exif.get("country") or "",
|
||||
"is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)),
|
||||
@@ -97,8 +109,10 @@ def build_asset_dict(
|
||||
# ImmichAssetInfo dataclass
|
||||
return {
|
||||
"id": asset.id,
|
||||
"filename": asset.filename,
|
||||
"originalFileName": asset.filename,
|
||||
"type": asset.type,
|
||||
"created_at": asset.created_at,
|
||||
"createdAt": asset.created_at,
|
||||
"city": getattr(asset, "city", "") or "",
|
||||
"country": getattr(asset, "country", "") or "",
|
||||
|
||||
@@ -120,22 +120,43 @@ async def dispatch_test_notification(
|
||||
),
|
||||
}
|
||||
|
||||
# Fetch assets and build event
|
||||
# Build events (single or per-album) via the shared helper so test and
|
||||
# cron dispatch stay in lockstep on the mode decision.
|
||||
try:
|
||||
event = await _build_event(
|
||||
provider_type=provider.type,
|
||||
provider_config=provider_config,
|
||||
provider_name=provider.name or provider.type,
|
||||
tracker_name=tracker.name or "",
|
||||
tracker_filters=dict(tracker.filters) if tracker.filters else {},
|
||||
collection_ids=collection_ids,
|
||||
test_type=test_type,
|
||||
tracking_config=tracking_config,
|
||||
)
|
||||
if provider.type == "immich" and test_type in ("periodic", "scheduled", "memory"):
|
||||
events = await build_immich_dispatch_events(
|
||||
provider_config=provider_config,
|
||||
provider_name=provider.name or provider.type,
|
||||
tracker_name=tracker.name or "",
|
||||
collection_ids=collection_ids,
|
||||
kind=test_type,
|
||||
tracking_config=tracking_config,
|
||||
)
|
||||
else:
|
||||
ev = await _build_event(
|
||||
provider_type=provider.type,
|
||||
provider_config=provider_config,
|
||||
provider_name=provider.name or provider.type,
|
||||
tracker_name=tracker.name or "",
|
||||
tracker_filters=dict(tracker.filters) if tracker.filters else {},
|
||||
collection_ids=collection_ids,
|
||||
test_type=test_type,
|
||||
tracking_config=tracking_config,
|
||||
)
|
||||
events = [ev] if ev is not None else []
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.exception("Test dispatch event build failed")
|
||||
return {"success": False, "error": f"Provider connection failed: {err}"}
|
||||
if event is None:
|
||||
|
||||
if not events:
|
||||
if test_type in ("scheduled", "memory"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"No matching assets found. Verify the tracker's albums contain assets "
|
||||
"that pass the tracking config filters (favorites only, rating, asset type)."
|
||||
) + (" for today" if test_type == "memory" else ""),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
@@ -143,24 +164,92 @@ async def dispatch_test_notification(
|
||||
"credentials are valid, and the tracker has collections configured."
|
||||
),
|
||||
}
|
||||
# Periodic summary only needs album stats (extra.albums), not assets — skip the asset check.
|
||||
if not event.added_assets and test_type in ("scheduled", "memory"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"No matching assets found. Verify the tracker's albums contain assets "
|
||||
"that pass the tracking config filters (favorites only, rating, asset type)."
|
||||
) + (" for today" if test_type == "memory" else ""),
|
||||
}
|
||||
|
||||
# Dispatch through the real NotificationDispatcher
|
||||
# Dispatch each event to the same target (per-album fan-out sends N messages).
|
||||
url_cache, asset_cache = await _get_telegram_caches()
|
||||
dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache)
|
||||
results = await dispatcher.dispatch(event, [target_cfg])
|
||||
all_results: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
results = await dispatcher.dispatch(event, [target_cfg])
|
||||
if results:
|
||||
all_results.append(results[0])
|
||||
|
||||
if not results:
|
||||
if not all_results:
|
||||
return {"success": False, "error": "No dispatch results"}
|
||||
return results[0]
|
||||
all_ok = all(r.get("success") for r in all_results)
|
||||
if all_ok:
|
||||
return {"success": True, "dispatched": len(all_results)}
|
||||
first_err = next(
|
||||
(r.get("error") for r in all_results if not r.get("success")),
|
||||
"Unknown error",
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": first_err,
|
||||
"dispatched": sum(1 for r in all_results if r.get("success")),
|
||||
"failed": sum(1 for r in all_results if not r.get("success")),
|
||||
}
|
||||
|
||||
|
||||
async def build_immich_dispatch_events(
|
||||
*,
|
||||
provider_config: dict,
|
||||
provider_name: str,
|
||||
tracker_name: str,
|
||||
collection_ids: list[str],
|
||||
kind: str,
|
||||
tracking_config: TrackingConfig | None,
|
||||
) -> list[ServiceEvent]:
|
||||
"""Build the list of ServiceEvents to dispatch for an Immich scheduled kind.
|
||||
|
||||
Single source of truth for the mode decision: ``periodic`` is always one
|
||||
summary event; ``scheduled``/``memory`` honour the ``{kind}_collection_mode``
|
||||
on the tracking config and fan out one event per album in ``per_collection``
|
||||
mode, or one combined event in ``combined`` mode.
|
||||
|
||||
Empty-payload filtering (no assets matched) is applied here so callers get
|
||||
back only events that should actually dispatch. ``periodic`` is exempt —
|
||||
a zero-asset summary is still meaningful (shows album stats only).
|
||||
"""
|
||||
if kind == "periodic":
|
||||
ev = await _build_immich_periodic_event(
|
||||
provider_config=provider_config,
|
||||
provider_name=provider_name,
|
||||
tracker_name=tracker_name,
|
||||
collection_ids=collection_ids,
|
||||
)
|
||||
return [ev] if ev is not None else []
|
||||
|
||||
mode = getattr(
|
||||
tracking_config, f"{kind}_collection_mode", "combined"
|
||||
) or "combined"
|
||||
|
||||
if mode == "per_collection" and len(collection_ids) > 1:
|
||||
events: list[ServiceEvent] = []
|
||||
for aid in collection_ids:
|
||||
ev = await _build_immich_event(
|
||||
provider_config=provider_config,
|
||||
provider_name=provider_name,
|
||||
tracker_name=tracker_name,
|
||||
collection_ids=[aid],
|
||||
test_type=kind,
|
||||
tracking_config=tracking_config,
|
||||
)
|
||||
if ev is not None and ev.added_assets:
|
||||
events.append(ev)
|
||||
return events
|
||||
|
||||
ev = await _build_immich_event(
|
||||
provider_config=provider_config,
|
||||
provider_name=provider_name,
|
||||
tracker_name=tracker_name,
|
||||
collection_ids=collection_ids,
|
||||
test_type=kind,
|
||||
tracking_config=tracking_config,
|
||||
)
|
||||
if ev is None or not ev.added_assets:
|
||||
return []
|
||||
return [ev]
|
||||
|
||||
|
||||
async def _build_event(
|
||||
|
||||
@@ -8,12 +8,18 @@ IMPORTANT: Keep sample assets and context in sync with:
|
||||
When adding new template variables, update all four locations.
|
||||
"""
|
||||
|
||||
# Sample asset matching what build_asset_detail() actually returns
|
||||
# Sample asset matching what build_asset_detail() / build_asset_dict() actually
|
||||
# return. Command-template defaults use ``asset.filename`` (the canonical key
|
||||
# shared with notification templates); ``originalFileName`` and ``createdAt``
|
||||
# are kept as aliases so user templates authored against the historical command
|
||||
# keys still preview correctly.
|
||||
_SAMPLE_ASSET = {
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"filename": "IMG_001.jpg",
|
||||
"originalFileName": "IMG_001.jpg",
|
||||
"type": "IMAGE",
|
||||
"created_at": "2026-03-19T10:30:00",
|
||||
"createdAt": "2026-03-19T10:30:00",
|
||||
"owner": "Alice",
|
||||
"owner_id": "user-uuid-1",
|
||||
"description": "Family picnic",
|
||||
@@ -32,12 +38,18 @@ _SAMPLE_ASSET = {
|
||||
"file_size": 3_500_000, # 3.5 MB — original asset bytes
|
||||
"playback_size": None, # photos are sent as-is, no transcoded variant
|
||||
"oversized": False,
|
||||
# Per-asset album attribution — populated by collect_scheduled_assets so
|
||||
# combined-mode templates can label each row with its source album.
|
||||
"album_name": "Family Photos",
|
||||
"album_url": "https://immich.example.com/share/abc123",
|
||||
"album_public_url": "https://immich.example.com/share/abc123",
|
||||
}
|
||||
|
||||
_SAMPLE_VIDEO_ASSET = {
|
||||
**_SAMPLE_ASSET,
|
||||
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"filename": "VID_002.mp4",
|
||||
"originalFileName": "VID_002.mp4",
|
||||
"type": "VIDEO",
|
||||
"is_favorite": False,
|
||||
"rating": None,
|
||||
@@ -54,7 +66,10 @@ _SAMPLE_COLLECTION = {
|
||||
"url": "https://immich.example.com/share/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"asset_count": 42,
|
||||
"photo_count": 37,
|
||||
"video_count": 5,
|
||||
"shared": True,
|
||||
"owner": "Alice",
|
||||
}
|
||||
|
||||
# Full context covering ALL possible template variables
|
||||
@@ -103,7 +118,9 @@ _SAMPLE_CONTEXT = {
|
||||
# Scheduled/periodic variables (for those templates)
|
||||
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
|
||||
# Second sample asset belongs to a different album so the preview exercises
|
||||
# the combined-mode branch (>1 distinct album → per-row "— Album" suffix).
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "originalFileName": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/def456/photos/x1y2z3", "album_name": "Vacation 2025", "album_url": "https://immich.example.com/share/def456", "album_public_url": "https://immich.example.com/share/def456"}],
|
||||
"date": "2026-03-19",
|
||||
"photo_count": 30,
|
||||
"video_count": 5,
|
||||
|
||||
@@ -46,12 +46,62 @@ from .dispatch_helpers import (
|
||||
get_app_timezone,
|
||||
load_link_data,
|
||||
)
|
||||
from .manual_dispatch import _build_immich_event, _build_immich_periodic_event
|
||||
from .manual_dispatch import build_immich_dispatch_events
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ScheduledKind = Literal["scheduled", "periodic", "memory"]
|
||||
|
||||
# Reasons a scheduled cron fire can end up producing no notification. We write
|
||||
# these to EventLog.details.skip_reason so users can see *why* a 09:00 memory
|
||||
# didn't arrive, rather than silently treating the fire as if it never happened.
|
||||
_SKIP_REASON_TRACKER_DISABLED = "tracker_disabled"
|
||||
_SKIP_REASON_NOT_IMMICH = "not_immich_provider"
|
||||
_SKIP_REASON_KIND_DISABLED = "kind_disabled_on_default_config"
|
||||
_SKIP_REASON_NO_LINKS = "no_enabled_links"
|
||||
_SKIP_REASON_NO_EVENT = "provider_returned_no_event"
|
||||
_SKIP_REASON_EMPTY_PAYLOAD = "zero_assets_matched"
|
||||
_SKIP_REASON_NO_TARGETS = "no_targets_after_filtering"
|
||||
|
||||
|
||||
async def _log_skip(
|
||||
tracker_id: int,
|
||||
kind: ScheduledKind,
|
||||
reason: str,
|
||||
*,
|
||||
tracker_user_id: int | None = None,
|
||||
tracker_name: str = "",
|
||||
provider_id: int | None = None,
|
||||
provider_name: str = "",
|
||||
) -> None:
|
||||
"""Persist an EventLog row for a skipped scheduled fire.
|
||||
|
||||
Separate from the success-path log (which records targets dispatched) so
|
||||
operators and users can filter "why didn't this fire" from "what was sent".
|
||||
``event_type`` mirrors the success path's value; the skip is disambiguated
|
||||
by ``details.status == "skipped"``.
|
||||
"""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
session.add(EventLog(
|
||||
user_id=tracker_user_id,
|
||||
tracker_id=tracker_id,
|
||||
tracker_name=tracker_name,
|
||||
provider_id=provider_id,
|
||||
provider_name=provider_name,
|
||||
event_type=EventType.SCHEDULED_MESSAGE.value,
|
||||
collection_id="",
|
||||
collection_name="",
|
||||
assets_count=0,
|
||||
details={
|
||||
"kind": kind,
|
||||
"trigger": "cron",
|
||||
"status": "skipped",
|
||||
"skip_reason": reason,
|
||||
},
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
# Maps the dispatch kind to the DB slot name that holds its template.
|
||||
# The dispatcher keys templates by ``event.event_type.value`` (always
|
||||
# ``scheduled_message`` here), so we read the right ``TemplateSlot`` row and
|
||||
@@ -76,9 +126,23 @@ async def dispatch_scheduled_for_tracker(
|
||||
async with AsyncSession(engine) as session:
|
||||
tracker = await session.get(NotificationTracker, tracker_id)
|
||||
if not tracker or not tracker.enabled:
|
||||
# No user context available (tracker missing/disabled); still log so
|
||||
# operators can correlate cron fires that went nowhere.
|
||||
await _log_skip(
|
||||
tracker_id, kind, _SKIP_REASON_TRACKER_DISABLED,
|
||||
tracker_user_id=(tracker.user_id if tracker else None),
|
||||
tracker_name=(tracker.name if tracker else ""),
|
||||
)
|
||||
return
|
||||
provider = await session.get(ServiceProvider, tracker.provider_id)
|
||||
if not provider or provider.type != "immich":
|
||||
await _log_skip(
|
||||
tracker_id, kind, _SKIP_REASON_NOT_IMMICH,
|
||||
tracker_user_id=tracker.user_id,
|
||||
tracker_name=tracker.name or "",
|
||||
provider_id=(provider.id if provider else None),
|
||||
provider_name=(provider.name if provider else ""),
|
||||
)
|
||||
return
|
||||
|
||||
default_tc: TrackingConfig | None = None
|
||||
@@ -94,6 +158,13 @@ async def dispatch_scheduled_for_tracker(
|
||||
"Scheduled %s skipped for tracker %d: kind disabled on default config",
|
||||
kind, tracker_id,
|
||||
)
|
||||
await _log_skip(
|
||||
tracker_id, kind, _SKIP_REASON_KIND_DISABLED,
|
||||
tracker_user_id=tracker.user_id,
|
||||
tracker_name=tracker.name or "",
|
||||
provider_id=provider.id,
|
||||
provider_name=provider.name or provider.type,
|
||||
)
|
||||
return
|
||||
|
||||
# Snapshot every field we need outside the session — after the
|
||||
@@ -115,90 +186,54 @@ async def dispatch_scheduled_for_tracker(
|
||||
"Scheduled %s for tracker %d: no enabled links, skipping",
|
||||
kind, tracker_id,
|
||||
)
|
||||
return
|
||||
|
||||
if kind == "periodic":
|
||||
event = await _build_immich_periodic_event(
|
||||
provider_config=provider_config,
|
||||
provider_name=provider_name,
|
||||
await _log_skip(
|
||||
tracker_id, kind, _SKIP_REASON_NO_LINKS,
|
||||
tracker_user_id=tracker_user_id,
|
||||
tracker_name=tracker_name,
|
||||
collection_ids=collection_ids,
|
||||
)
|
||||
else:
|
||||
event = await _build_immich_event(
|
||||
provider_config=provider_config,
|
||||
provider_id=provider_id,
|
||||
provider_name=provider_name,
|
||||
tracker_name=tracker_name,
|
||||
collection_ids=collection_ids,
|
||||
test_type=kind,
|
||||
tracking_config=default_tc,
|
||||
)
|
||||
if event is None:
|
||||
_LOGGER.warning(
|
||||
"Scheduled %s for tracker %d: provider returned no event",
|
||||
kind, tracker_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Skip empty payloads for asset-bearing kinds — sending the bare
|
||||
# "On this day:" / "Scheduled delivery —" header with no items below
|
||||
# spams chats with title-only messages every day. ``periodic`` is
|
||||
# different: it's a stats summary that's still meaningful with zero
|
||||
# assets, so we let it through.
|
||||
if kind in ("scheduled", "memory") and not event.added_assets:
|
||||
# Resolve mode + build events via the shared helper (same decision logic
|
||||
# the test-dispatch path uses). "per_collection" fans out one event per
|
||||
# album; "combined" pools assets into a single event. ``collection_mode``
|
||||
# is threaded through to EventLog.details so operators can see *which*
|
||||
# mode a fire used when auditing behaviour.
|
||||
collection_mode = (
|
||||
"combined" if kind == "periodic"
|
||||
else getattr(default_tc, f"{kind}_collection_mode", "combined") or "combined"
|
||||
)
|
||||
events = await build_immich_dispatch_events(
|
||||
provider_config=provider_config,
|
||||
provider_name=provider_name,
|
||||
tracker_name=tracker_name,
|
||||
collection_ids=collection_ids,
|
||||
kind=kind,
|
||||
tracking_config=default_tc,
|
||||
)
|
||||
|
||||
if not events:
|
||||
# All albums yielded 0 matching assets (per_collection), or the single
|
||||
# combined build produced nothing. Log the same skip reason used for
|
||||
# the legacy single-event path so operators see a consistent signal.
|
||||
reason = (
|
||||
_SKIP_REASON_NO_EVENT if kind == "periodic" else _SKIP_REASON_EMPTY_PAYLOAD
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Scheduled %s for tracker %d: 0 assets matched, skipping dispatch",
|
||||
kind, tracker_id,
|
||||
"Scheduled %s for tracker %d: no events to dispatch (mode=%s)",
|
||||
kind, tracker_id, collection_mode,
|
||||
)
|
||||
await _log_skip(
|
||||
tracker_id, kind, reason,
|
||||
tracker_user_id=tracker_user_id,
|
||||
tracker_name=tracker_name,
|
||||
provider_id=provider_id,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
return
|
||||
|
||||
slot_name = _SLOT_MAP[kind]
|
||||
target_configs: list[TargetConfig] = []
|
||||
async with AsyncSession(engine) as session:
|
||||
for ld in link_data:
|
||||
tc = ld["tracking_config"] or default_tc
|
||||
tmpl = ld["template_config"]
|
||||
if tc is not None:
|
||||
# Per-link override may disable this kind even when the
|
||||
# default has it on — honour that here.
|
||||
if not getattr(tc, f"{kind}_enabled", True):
|
||||
continue
|
||||
if not event_allowed_by_config(event, tc, app_tz):
|
||||
continue
|
||||
if tmpl is None:
|
||||
continue
|
||||
|
||||
slot_rows = (await session.exec(
|
||||
select(TemplateSlot).where(
|
||||
TemplateSlot.config_id == tmpl.id,
|
||||
TemplateSlot.slot_name == slot_name,
|
||||
)
|
||||
)).all()
|
||||
if not slot_rows:
|
||||
continue
|
||||
locale_map = {s.locale: s.template for s in slot_rows}
|
||||
template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map}
|
||||
|
||||
target_configs.append(TargetConfig(
|
||||
type=ld["target_type"],
|
||||
config=ld["target_config"],
|
||||
template_slots=template_slots,
|
||||
date_format=tmpl.date_format,
|
||||
date_only_format=(
|
||||
tmpl.date_only_format or "%d.%m.%Y"
|
||||
),
|
||||
provider_api_key=provider_config.get("api_key"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
provider_external_url=provider_config.get("external_domain", ""),
|
||||
receivers=ld["receivers"],
|
||||
))
|
||||
|
||||
if not target_configs:
|
||||
_LOGGER.info(
|
||||
"Scheduled %s for tracker %d: no targets after filtering",
|
||||
kind, tracker_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Lazy import to break the watcher↔scheduler↔scheduled_dispatch cycle.
|
||||
from .watcher import _get_telegram_caches
|
||||
@@ -209,34 +244,96 @@ async def dispatch_scheduled_for_tracker(
|
||||
dispatcher = NotificationDispatcher(
|
||||
url_cache=url_cache, asset_cache=asset_cache, session=http_session,
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Dispatching scheduled %s for tracker %d to %d link(s)",
|
||||
kind, tracker_id, len(target_configs),
|
||||
)
|
||||
results = await dispatcher.dispatch(event, target_configs)
|
||||
|
||||
# Mirror the watcher's audit trail: surface scheduled fires in EventLog so
|
||||
# the dashboard shows *why* a notification arrived (otherwise these would
|
||||
# be invisible to the activity feed).
|
||||
successes = sum(1 for r in results if isinstance(r, dict) and r.get("success"))
|
||||
async with AsyncSession(engine) as session:
|
||||
session.add(EventLog(
|
||||
user_id=tracker_user_id,
|
||||
tracker_id=tracker_id,
|
||||
any_sent = False
|
||||
for event in events:
|
||||
# Target config assembly depends on the event for quiet-hours /
|
||||
# event_allowed_by_config, which inspects event timestamp. Per-event
|
||||
# rebuilding also lets a per-link override disable one kind while
|
||||
# keeping others live.
|
||||
target_configs: list[TargetConfig] = []
|
||||
async with AsyncSession(engine) as session:
|
||||
for ld in link_data:
|
||||
tc = ld["tracking_config"] or default_tc
|
||||
tmpl = ld["template_config"]
|
||||
if tc is not None:
|
||||
if not getattr(tc, f"{kind}_enabled", True):
|
||||
continue
|
||||
if not event_allowed_by_config(event, tc, app_tz):
|
||||
continue
|
||||
if tmpl is None:
|
||||
continue
|
||||
|
||||
slot_rows = (await session.exec(
|
||||
select(TemplateSlot).where(
|
||||
TemplateSlot.config_id == tmpl.id,
|
||||
TemplateSlot.slot_name == slot_name,
|
||||
)
|
||||
)).all()
|
||||
if not slot_rows:
|
||||
continue
|
||||
locale_map = {s.locale: s.template for s in slot_rows}
|
||||
template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map}
|
||||
|
||||
target_configs.append(TargetConfig(
|
||||
type=ld["target_type"],
|
||||
config=ld["target_config"],
|
||||
template_slots=template_slots,
|
||||
date_format=tmpl.date_format,
|
||||
date_only_format=(
|
||||
tmpl.date_only_format or "%d.%m.%Y"
|
||||
),
|
||||
provider_api_key=provider_config.get("api_key"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
provider_external_url=provider_config.get("external_domain", ""),
|
||||
receivers=ld["receivers"],
|
||||
))
|
||||
|
||||
if not target_configs:
|
||||
_LOGGER.info(
|
||||
"Scheduled %s for tracker %d (collection=%r): no targets after filtering",
|
||||
kind, tracker_id, event.collection_name,
|
||||
)
|
||||
continue
|
||||
|
||||
_LOGGER.info(
|
||||
"Dispatching scheduled %s for tracker %d (collection=%r) to %d link(s)",
|
||||
kind, tracker_id, event.collection_name, len(target_configs),
|
||||
)
|
||||
results = await dispatcher.dispatch(event, target_configs)
|
||||
any_sent = True
|
||||
|
||||
successes = sum(1 for r in results if isinstance(r, dict) and r.get("success"))
|
||||
async with AsyncSession(engine) as session:
|
||||
session.add(EventLog(
|
||||
user_id=tracker_user_id,
|
||||
tracker_id=tracker_id,
|
||||
tracker_name=tracker_name,
|
||||
provider_id=provider_id,
|
||||
provider_name=provider_name,
|
||||
event_type=event.event_type.value,
|
||||
collection_id=event.collection_id,
|
||||
collection_name=event.collection_name,
|
||||
assets_count=event.added_count or 0,
|
||||
details={
|
||||
"kind": kind,
|
||||
"slot": slot_name,
|
||||
"trigger": "cron",
|
||||
"timezone": app_tz,
|
||||
"collection_mode": collection_mode,
|
||||
"status": "sent",
|
||||
"targets_dispatched": len(target_configs),
|
||||
"targets_succeeded": successes,
|
||||
},
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
if not any_sent:
|
||||
# All events produced zero targets after filtering (quiet hours, etc.).
|
||||
await _log_skip(
|
||||
tracker_id, kind, _SKIP_REASON_NO_TARGETS,
|
||||
tracker_user_id=tracker_user_id,
|
||||
tracker_name=tracker_name,
|
||||
provider_id=provider_id,
|
||||
provider_name=provider_name,
|
||||
event_type=event.event_type.value,
|
||||
collection_id=event.collection_id,
|
||||
collection_name=event.collection_name,
|
||||
assets_count=event.added_count or 0,
|
||||
details={
|
||||
"kind": kind,
|
||||
"slot": slot_name,
|
||||
"trigger": "cron",
|
||||
"timezone": app_tz,
|
||||
"targets_dispatched": len(target_configs),
|
||||
"targets_succeeded": successes,
|
||||
},
|
||||
))
|
||||
await session.commit()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user