feat: telegram commands, app settings, bot polling, webhook handling, UI improvements
Adds telegram bot command system with 13 commands (search, latest, random, etc.), webhook/polling handlers, rate limiting, app settings page, and various UI/UX improvements across all entity pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,17 +12,13 @@ from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.templates.context import build_template_context
|
||||
from notify_bridge_core.templates.renderer import render_template
|
||||
|
||||
from .telegram.cache import TelegramFileCache
|
||||
from .telegram.client import TelegramClient
|
||||
from .webhook.client import WebhookClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TEMPLATE = (
|
||||
'{{ added_count }} new item(s) added to '
|
||||
'{% if public_url %}<a href="{{ public_url }}">{{ collection_name }}</a>'
|
||||
'{% else %}"{{ collection_name }}"{% endif %}.'
|
||||
'{% if people %}\nPeople: {{ people | join(", ") }}{% endif %}'
|
||||
)
|
||||
DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,6 +38,15 @@ class TargetConfig:
|
||||
class NotificationDispatcher:
|
||||
"""Dispatches ServiceEvent notifications to configured targets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url_cache: TelegramFileCache | None = None,
|
||||
asset_cache: TelegramFileCache | None = None,
|
||||
) -> None:
|
||||
self._url_cache = url_cache
|
||||
self._asset_cache = asset_cache
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
event: ServiceEvent,
|
||||
@@ -104,13 +109,17 @@ class NotificationDispatcher:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
client = TelegramClient(
|
||||
session, bot_token,
|
||||
url_cache=self._url_cache,
|
||||
asset_cache=self._asset_cache,
|
||||
)
|
||||
|
||||
# Step 1: Send the text message first
|
||||
text_result = await client.send_message(
|
||||
chat_id=str(chat_id),
|
||||
text=message,
|
||||
disable_web_page_preview=disable_preview or None,
|
||||
disable_web_page_preview=bool(disable_preview),
|
||||
)
|
||||
if not text_result.get("success"):
|
||||
return text_result
|
||||
|
||||
@@ -16,8 +16,10 @@ from .media import (
|
||||
TELEGRAM_API_BASE_URL,
|
||||
TELEGRAM_MAX_PHOTO_SIZE,
|
||||
TELEGRAM_MAX_VIDEO_SIZE,
|
||||
asset_id_from_cache_key,
|
||||
check_photo_limits,
|
||||
extract_asset_id_from_url,
|
||||
is_asset_cache_key,
|
||||
is_asset_id,
|
||||
split_media_by_upload_size,
|
||||
)
|
||||
@@ -61,16 +63,17 @@ class TelegramClient:
|
||||
if is_asset_id(url):
|
||||
thumbhash = self._thumbhash_resolver(url) if self._thumbhash_resolver else None
|
||||
return self._asset_cache, url, thumbhash
|
||||
asset_id = extract_asset_id_from_url(url)
|
||||
if asset_id:
|
||||
thumbhash = self._thumbhash_resolver(asset_id) if self._thumbhash_resolver else None
|
||||
return self._asset_cache, asset_id, thumbhash
|
||||
asset_cache_key = extract_asset_id_from_url(url)
|
||||
if asset_cache_key:
|
||||
bare_id = asset_id_from_cache_key(asset_cache_key)
|
||||
thumbhash = self._thumbhash_resolver(bare_id) if self._thumbhash_resolver else None
|
||||
return self._asset_cache, asset_cache_key, thumbhash
|
||||
return self._url_cache, url, None
|
||||
return None, None, None
|
||||
|
||||
def _get_cache_for_key(self, key: str, is_asset: bool | None = None) -> TelegramFileCache | None:
|
||||
if is_asset is None:
|
||||
is_asset = is_asset_id(key)
|
||||
is_asset = is_asset_cache_key(key)
|
||||
return self._asset_cache if is_asset else self._url_cache
|
||||
|
||||
async def send_notification(
|
||||
@@ -163,8 +166,8 @@ class TelegramClient:
|
||||
}
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
if disable_web_page_preview is not None:
|
||||
payload["disable_web_page_preview"] = disable_web_page_preview
|
||||
if disable_web_page_preview:
|
||||
payload["link_preview_options"] = {"is_disabled": True}
|
||||
|
||||
try:
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
@@ -429,9 +432,10 @@ class TelegramClient:
|
||||
|
||||
# Check cache
|
||||
ck = custom_cache_key or extract_asset_id_from_url(url) or url
|
||||
ck_is_asset = is_asset_id(ck)
|
||||
ck_is_asset = is_asset_cache_key(ck)
|
||||
item_cache = self._get_cache_for_key(ck, ck_is_asset)
|
||||
item_thumbhash = self._thumbhash_resolver(ck) if ck_is_asset and self._thumbhash_resolver else None
|
||||
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck
|
||||
item_thumbhash = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None
|
||||
cached = item_cache.get(ck, thumbhash=item_thumbhash) if item_cache else None
|
||||
|
||||
if cached and cached.get("file_id"):
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Telegram constants
|
||||
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
|
||||
@@ -13,6 +14,8 @@ TELEGRAM_MAX_DIMENSION_SUM: Final = 10000
|
||||
|
||||
# Generic UUID pattern for asset IDs
|
||||
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
|
||||
# Cache key: "host:uuid" or bare "uuid"
|
||||
_ASSET_CACHE_KEY_PATTERN = re.compile(r"^(?:[^:]+:)?[a-f0-9-]{36}$")
|
||||
|
||||
# URL patterns to extract asset IDs (generic enough for Immich-style URLs)
|
||||
_ASSET_ID_URL_PATTERNS = [
|
||||
@@ -26,14 +29,26 @@ def is_asset_id(value: str) -> bool:
|
||||
return bool(_ASSET_ID_PATTERN.match(value))
|
||||
|
||||
|
||||
def is_asset_cache_key(value: str) -> bool:
|
||||
"""Check if a string is an asset cache key (bare UUID or host:UUID)."""
|
||||
return bool(_ASSET_CACHE_KEY_PATTERN.match(value))
|
||||
|
||||
|
||||
def asset_id_from_cache_key(key: str) -> str:
|
||||
"""Extract bare asset ID from a cache key (strips host: prefix if present)."""
|
||||
idx = key.find(":")
|
||||
return key[idx + 1:] if idx != -1 else key
|
||||
|
||||
|
||||
def extract_asset_id_from_url(url: str) -> str | None:
|
||||
"""Extract asset ID from a URL if possible."""
|
||||
"""Extract host-qualified asset cache key (host:uuid) from a URL."""
|
||||
if not url:
|
||||
return None
|
||||
for pattern in _ASSET_ID_URL_PATTERNS:
|
||||
match = pattern.search(url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
host = urlparse(url).hostname or ""
|
||||
return f"{host}:{match.group(1)}" if host else match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -47,14 +47,27 @@ def _asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
||||
)
|
||||
|
||||
|
||||
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
|
||||
"""Build the common extra dict for album events."""
|
||||
return {
|
||||
"album_url": f"{external_url}/albums/{new_album.id}",
|
||||
"people": list(new_album.people),
|
||||
"shared": new_album.shared,
|
||||
"photo_count": new_album.photo_count,
|
||||
"video_count": new_album.video_count,
|
||||
"asset_count": new_album.asset_count,
|
||||
"owner": new_album.owner,
|
||||
}
|
||||
|
||||
|
||||
def detect_album_changes(
|
||||
old_album: ImmichAlbumData,
|
||||
new_album: ImmichAlbumData,
|
||||
pending_asset_ids: set[str],
|
||||
provider_name: str,
|
||||
external_url: str,
|
||||
) -> tuple[ServiceEvent | None, set[str]]:
|
||||
"""Detect changes between two album states, producing a generic ServiceEvent.
|
||||
) -> tuple[list[ServiceEvent], set[str]]:
|
||||
"""Detect changes between two album states, producing generic ServiceEvents.
|
||||
|
||||
Args:
|
||||
old_album: Previous album data
|
||||
@@ -64,7 +77,7 @@ def detect_album_changes(
|
||||
external_url: External URL for building asset URLs
|
||||
|
||||
Returns:
|
||||
Tuple of (ServiceEvent or None, updated pending_asset_ids)
|
||||
Tuple of (list of ServiceEvents, updated pending_asset_ids)
|
||||
"""
|
||||
added_ids = new_album.asset_ids - old_album.asset_ids
|
||||
removed_ids = old_album.asset_ids - new_album.asset_ids
|
||||
@@ -97,47 +110,76 @@ def detect_album_changes(
|
||||
sharing_changed = old_album.shared != new_album.shared
|
||||
|
||||
if not added_assets and not removed_ids and not name_changed and not sharing_changed:
|
||||
return None, pending
|
||||
return [], pending
|
||||
|
||||
# Determine event type
|
||||
if name_changed and not added_assets and not removed_ids and not sharing_changed:
|
||||
event_type = EventType.COLLECTION_RENAMED
|
||||
elif sharing_changed and not added_assets and not removed_ids and not name_changed:
|
||||
event_type = EventType.SHARING_CHANGED
|
||||
elif added_assets and not removed_ids and not name_changed and not sharing_changed:
|
||||
event_type = EventType.ASSETS_ADDED
|
||||
elif removed_ids and not added_assets and not name_changed and not sharing_changed:
|
||||
event_type = EventType.ASSETS_REMOVED
|
||||
else:
|
||||
event_type = EventType.ASSETS_ADDED # default for mixed changes
|
||||
now = datetime.now(timezone.utc)
|
||||
extra = _make_base_extra(new_album, external_url)
|
||||
events: list[ServiceEvent] = []
|
||||
|
||||
# Convert to generic MediaAssets
|
||||
media_assets = [_asset_to_media(a, external_url) for a in added_assets]
|
||||
# Emit one event per change type detected
|
||||
if added_assets:
|
||||
media_assets = [_asset_to_media(a, external_url) for a in added_assets]
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_ADDED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=media_assets,
|
||||
removed_asset_ids=[],
|
||||
added_count=len(added_assets),
|
||||
removed_count=0,
|
||||
extra=dict(extra),
|
||||
))
|
||||
|
||||
event = ServiceEvent(
|
||||
event_type=event_type,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
added_assets=media_assets,
|
||||
removed_asset_ids=list(removed_ids),
|
||||
added_count=len(added_assets),
|
||||
removed_count=len(removed_ids),
|
||||
old_name=old_album.name if name_changed else None,
|
||||
new_name=new_album.name if name_changed else None,
|
||||
old_shared=old_album.shared if sharing_changed else None,
|
||||
new_shared=new_album.shared if sharing_changed else None,
|
||||
extra={
|
||||
"album_url": f"{external_url}/albums/{new_album.id}",
|
||||
"people": list(new_album.people),
|
||||
"shared": new_album.shared,
|
||||
"photo_count": new_album.photo_count,
|
||||
"video_count": new_album.video_count,
|
||||
"asset_count": new_album.asset_count,
|
||||
"owner": new_album.owner,
|
||||
},
|
||||
)
|
||||
if removed_ids:
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_REMOVED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=list(removed_ids),
|
||||
added_count=0,
|
||||
removed_count=len(removed_ids),
|
||||
extra=dict(extra),
|
||||
))
|
||||
|
||||
return event, pending
|
||||
if name_changed:
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.COLLECTION_RENAMED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=[],
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
old_name=old_album.name,
|
||||
new_name=new_album.name,
|
||||
extra=dict(extra),
|
||||
))
|
||||
|
||||
if sharing_changed:
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.SHARING_CHANGED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=[],
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
old_shared=old_album.shared,
|
||||
new_shared=new_album.shared,
|
||||
extra=dict(extra),
|
||||
))
|
||||
|
||||
return events, pending
|
||||
|
||||
@@ -30,6 +30,14 @@ class ImmichClient:
|
||||
def url(self) -> str:
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def external_domain(self) -> str | None:
|
||||
return self._external_domain
|
||||
|
||||
@external_domain.setter
|
||||
def external_domain(self, value: str | None) -> None:
|
||||
self._external_domain = value
|
||||
|
||||
@property
|
||||
def external_url(self) -> str:
|
||||
if self._external_domain:
|
||||
@@ -252,6 +260,79 @@ class ImmichClient:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def search_metadata(
|
||||
self,
|
||||
query: str,
|
||||
album_ids: list[str] | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit}
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
items = data.get("assets", {}).get("items", [])
|
||||
if album_ids:
|
||||
tracked = set(album_ids)
|
||||
items = [
|
||||
a for a in items
|
||||
if any(alb.get("id") in tracked for alb in a.get("albums", []))
|
||||
]
|
||||
return items[:limit]
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def search_by_person(
|
||||
self, person_id: str, limit: int = 10
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/people/{person_id}/assets",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data[:limit] if isinstance(data, list) else []
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def get_memories(
|
||||
self,
|
||||
date: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch native Immich memories (On This Day).
|
||||
|
||||
Args:
|
||||
date: ISO date string (e.g. "2026-03-20") to fetch memories for.
|
||||
If None, Immich returns memories for today.
|
||||
|
||||
Returns a list of memory objects, each containing an ``assets`` list
|
||||
with full ``AssetResponseDto`` items.
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
if date:
|
||||
params["for"] = date
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/memories",
|
||||
headers=self._headers,
|
||||
params=params,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch memories: HTTP %s", response.status
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch memories: %s", err)
|
||||
return []
|
||||
|
||||
async def get_asset_thumbnail(self, asset_id: str, size: str = "preview") -> bytes | None:
|
||||
try:
|
||||
async with self._session.get(
|
||||
|
||||
@@ -134,7 +134,7 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
if ok:
|
||||
await self._client.get_server_config()
|
||||
if self._external_domain:
|
||||
self._client._external_domain = self._external_domain
|
||||
self._client.external_domain = self._external_domain
|
||||
self._users_cache = await self._client.get_users()
|
||||
return ok
|
||||
|
||||
@@ -179,12 +179,12 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
old_album = _deserialize_album_state(album_id, prev)
|
||||
pending = set(prev.get("pending_asset_ids", []))
|
||||
|
||||
event, updated_pending = detect_album_changes(
|
||||
detected_events, updated_pending = detect_album_changes(
|
||||
old_album, album, pending, self._name, external_url
|
||||
)
|
||||
|
||||
if event:
|
||||
# Fetch shared links to enrich event with public_url
|
||||
if detected_events:
|
||||
# Fetch shared links to enrich events with public_url
|
||||
shared_links = await self._client.get_shared_links(album_id)
|
||||
public_link = None
|
||||
protected_link = None
|
||||
@@ -197,13 +197,13 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
break # prefer non-password link
|
||||
|
||||
ext_domain = self._external_domain or self._client.external_url
|
||||
if public_link:
|
||||
event.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
event.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
# If no links, public_url stays absent — templates handle gracefully
|
||||
for evt in detected_events:
|
||||
if public_link:
|
||||
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
|
||||
events.append(event)
|
||||
events.extend(detected_events)
|
||||
|
||||
# Update state
|
||||
state = _serialize_album_state(album)
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
{%- if people %}
|
||||
👤 {{ people | join(", ") }}
|
||||
{%- endif %}
|
||||
{%- if public_url %}
|
||||
🔗 <a href="{{ public_url }}">Album URL</a>
|
||||
{%- endif %}
|
||||
{%- if added_assets %}
|
||||
{%- for asset in added_assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
📅 On this day:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
• {%- 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 %}
|
||||
@@ -1,5 +1,4 @@
|
||||
📋 Tracked Albums Summary ({{ albums | length }} albums):
|
||||
{%- for album in albums %}
|
||||
• {{ album.name }}: {{ album.asset_count }} assets
|
||||
{%- if album.url %} — {{ album.url }}{% endif %}
|
||||
{%- endfor %}
|
||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
|
||||
{%- endfor %}
|
||||
@@ -1,4 +1,4 @@
|
||||
📸 Photos from "{{ album_name }}":
|
||||
📸 Photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- endfor %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -4,6 +4,9 @@
|
||||
{%- if people %}
|
||||
👤 {{ people | join(", ") }}
|
||||
{%- endif %}
|
||||
{%- if public_url %}
|
||||
🔗 <a href="{{ public_url }}">Ссылка на альбом</a>
|
||||
{%- endif %}
|
||||
{%- if added_assets %}
|
||||
{%- for asset in added_assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
📅 В этот день:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
• {%- 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 %}
|
||||
@@ -1,5 +1,4 @@
|
||||
📋 Сводка альбомов ({{ albums | length }}):
|
||||
{%- for album in albums %}
|
||||
• {{ album.name }}: {{ album.asset_count }} файлов
|
||||
{%- if album.url %} — {{ album.url }}{% endif %}
|
||||
{%- endfor %}
|
||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
|
||||
{%- endfor %}
|
||||
@@ -1,4 +1,4 @@
|
||||
📸 Фото из "{{ album_name }}":
|
||||
📸 Фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- endfor %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- endfor %}
|
||||
Reference in New Issue
Block a user