updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
+64 -92
View File
@@ -148,12 +148,6 @@ class AlbumSlideshowCamera(Camera):
self._framebuffer: bytes | None = None
# MJPEG subscribers. Each open stream owns an asyncio.Queue of JPEG
# byte payloads. The render loop pushes the latest still as soon
# as it's encoded; if a subscriber falls behind we drop frames
# for that subscriber rather than block the whole loop.
self._mjpeg_subscribers: set[asyncio.Queue[bytes]] = set()
# Monotonic counter incremented every time a new still is committed.
# Exposed as the ``frame_id`` state attribute so the Lovelace card
# has an unambiguous "new frame ready" signal even when other
@@ -244,6 +238,7 @@ class AlbumSlideshowCamera(Camera):
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"location": getattr(cur, "location", None),
"description": getattr(cur, "description", None),
# Structured per-image caption metadata. A single-element list for
# normal slides; two elements (top/left first) for paired slides,
# so the card can overlay an accurate date/location on each half.
@@ -256,6 +251,7 @@ class AlbumSlideshowCamera(Camera):
"portrait_mode": self.store.portrait_mode,
"order_mode": self.store.order_mode,
"date_filter": self.store.date_filter,
"missing_date_mode": self.store.missing_date_mode,
"paused": bool(self.store.paused),
"refresh_hours": int(self.store.refresh_hours),
"aspect_ratio": self.store.aspect_ratio,
@@ -282,6 +278,7 @@ class AlbumSlideshowCamera(Camera):
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
}
]
@@ -318,6 +315,7 @@ class AlbumSlideshowCamera(Camera):
cache_key = (
id(raw),
self.store.date_filter,
self.store.missing_date_mode,
self.store.order_mode,
)
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
@@ -326,6 +324,7 @@ class AlbumSlideshowCamera(Camera):
filtered = playlist.filter_items(
raw,
mode=self.store.date_filter,
missing_date=self.store.missing_date_mode,
)
ordered = playlist.order_items(filtered, self.store.order_mode)
self._effective_cache = (hash(cache_key), ordered)
@@ -343,67 +342,32 @@ class AlbumSlideshowCamera(Camera):
return self._framebuffer
async def handle_async_mjpeg_stream(self, request):
"""Stream the slideshow as multipart MJPEG.
"""Serve the current slide as MJPEG for Home Assistant core surfaces.
Each open client gets a bounded asyncio.Queue that the render loop
pushes JPEG payloads into when a new still is committed. Visible
transitions are now handled by the Lovelace card on the client
side, so this stream just emits the latest still per slide change.
This is what the more-info dialog and picture-glance live view use
(the camera advertises no live stream, so the frontend falls back to
``/api/camera_proxy_stream``). We delegate to HA's still-stream
helper, which polls ``async_camera_image`` at ``frame_interval`` and
writes a correct multipart response.
Crucially it emits frames *continuously* rather than only on slide
change. A browser parsing ``multipart/x-mixed-replace`` holds the
current part until the next boundary arrives, so a stream that sent
one frame and then went quiet until the next slide (potentially many
seconds away, or never while paused) left the more-info view blank.
Polling keeps a boundary coming right away, so the current frame
renders immediately.
"""
# Imported lazily so the module still loads in test environments
# that stub out homeassistant without installing aiohttp.
from aiohttp import web
# that stub out homeassistant.
from homeassistant.components.camera import async_get_still_stream
boundary = "frame"
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
"Cache-Control": "no-cache, private",
"Pragma": "no-cache",
},
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
await response.prepare(request)
# Bounded queue: a slow client should fall behind on slide commits
# rather than balloon memory.
queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4)
self._mjpeg_subscribers.add(queue)
# Push the held still immediately so the client renders something
# before the next slide change.
if self._framebuffer is not None:
try:
queue.put_nowait(self._framebuffer)
except asyncio.QueueFull:
pass
try:
while True:
payload = await queue.get()
try:
await response.write(
b"--" + boundary.encode() + b"\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(payload)).encode() + b"\r\n\r\n"
+ payload + b"\r\n"
)
except (ConnectionResetError, asyncio.CancelledError):
raise
except Exception as err:
_LOGGER.debug("Album Slideshow: mjpeg client write failed: %s", err)
break
except (ConnectionResetError, asyncio.CancelledError):
pass
finally:
self._mjpeg_subscribers.discard(queue)
try:
await response.write_eof()
except Exception:
pass
return response
# Older HA cores may dispatch via the alt name; alias for compatibility.
async def async_handle_async_mjpeg_stream(self, request):
@@ -412,12 +376,17 @@ class AlbumSlideshowCamera(Camera):
async def _wait_or_interrupt(self, timeout: float) -> bool:
"""Wait up to ``timeout`` seconds, returning True if interrupted.
Safe wrapper around clear() + wait_for() - callers don't have to
worry about the ordering of the two operations. The clear() runs
synchronously before the awaitable is created, so no interrupt can
be lost on the single-threaded event loop.
Does NOT clear ``_interrupt_event`` - the render loop clears it once
per cycle, before rendering, so a signal that arrives while we're
rendering (a "next slide" press, a coordinator/store change) survives
until we get here instead of being wiped.
A force-next that's already pending is honored immediately without
sleeping, so a press that landed during the last render (or right at
the loop boundary) isn't held until the full slide interval elapses.
"""
self._interrupt_event.clear()
if self._force_next:
return True
try:
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
return True
@@ -433,6 +402,11 @@ class AlbumSlideshowCamera(Camera):
raise
should_advance = False # Don't advance on the very first render
while True:
# Clear the wake signal before rendering. Anything that happens
# from here on (a "next slide" press, a coordinator/store change)
# re-sets it and is picked up after this frame commits, so no
# wake is lost while we're mid-render.
self._interrupt_event.clear()
try:
await self._render_cycle(advance=should_advance)
self._consecutive_failures = 0
@@ -453,9 +427,14 @@ class AlbumSlideshowCamera(Camera):
continue
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
if interrupted:
should_advance = self._force_next
if self._force_next:
# Explicit "next slide" request: always advance.
should_advance = True
self._force_next = False
elif interrupted:
# A coordinator/store change woke us: re-render the current
# frame (new data or settings) without skipping ahead.
should_advance = False
else:
# Paused slideshows hold the current frame until the user
# un-pauses or hits "next slide" explicitly.
@@ -510,7 +489,6 @@ class AlbumSlideshowCamera(Camera):
self._last_pair_frames = meta.get("pair_frames") if meta else None
self._last_pair_orientation = meta.get("pair_orientation") if meta else None
self._broadcast_frame(encoded)
self.async_write_ha_state()
@property
@@ -528,27 +506,6 @@ class AlbumSlideshowCamera(Camera):
domain_data["compose_semaphore"] = sem
return sem
def _broadcast_frame(self, payload: bytes) -> None:
"""Push a frame to every active MJPEG subscriber.
Slow subscribers get their frame dropped rather than backing up the
queue; the next still emission will catch them up.
"""
for queue in list(self._mjpeg_subscribers):
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
# Drain one and retry once so a wedged client still sees
# the latest frame eventually instead of forever stale.
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass
def _do_advance(self, count: int, items: list) -> None:
"""Advance _index to the next slide and commit random-order position."""
if count <= 0:
@@ -651,12 +608,14 @@ class AlbumSlideshowCamera(Camera):
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
},
{
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
"location": getattr(other_item, "location", None),
"latitude": getattr(other_item, "latitude", None),
"longitude": getattr(other_item, "longitude", None),
"description": getattr(other_item, "description", None),
},
]
pair_meta = [f["captured_at"] for f in pair_frames]
@@ -876,11 +835,24 @@ class AlbumSlideshowCamera(Camera):
self._download_cache.put(url, data)
return data
def _image_request_headers(self, url: str) -> dict[str, str] | None:
"""Auth headers required to fetch image bytes for some providers.
The Immich provider stores an ``x-api-key`` header on the coordinator;
it is sent server-side only, so the key never reaches the browser or
the camera's ``current_url`` attribute. Returns ``None`` when no extra
headers are needed (Google, local folder, media source).
"""
headers = getattr(self.coordinator, "image_request_headers", None)
if headers and isinstance(url, str) and url.startswith("http"):
return dict(headers)
return None
async def _http_get(self, url: str) -> bytes | None:
session = async_get_clientsession(self.hass)
try:
async with async_timeout.timeout(30):
async with session.get(url) as resp:
async with session.get(url, headers=self._image_request_headers(url)) as resp:
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import re
from typing import Any
@@ -7,6 +8,11 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
# Sentinel values for the "Select all" options in the multi-selects.
_ALL_PEOPLE = "__all_people__"
_ALL_ALBUMS = "__all_albums__"
from .const import (
DOMAIN,
@@ -14,11 +20,23 @@ from .const import (
CONF_ALBUM_NAME,
CONF_ALBUM_URL,
CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
IMMICH_IMAGE_SIZE_OPTIONS,
IMMICH_SELECTION_COMPOSITE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
DEFAULT_RECURSIVE,
)
@@ -52,6 +70,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
self._provider: str | None = None
# Immich flow state carried between steps.
self._immich_url: str | None = None
self._immich_key: str | None = None
# id -> name maps for the Albums and People multi-selects.
self._immich_albums: dict[str, str] = {}
self._immich_people: dict[str, str] = {}
@staticmethod
@callback
@@ -79,6 +103,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._provider = user_input[CONF_PROVIDER]
if self._provider == PROVIDER_LOCAL_FOLDER:
return await self.async_step_local_folder()
if self._provider == PROVIDER_MEDIA_SOURCE:
return await self.async_step_media_source()
if self._provider == PROVIDER_IMMICH:
return await self.async_step_immich()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -86,6 +114,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({
PROVIDER_GOOGLE_SHARED: "Google Photos",
PROVIDER_LOCAL_FOLDER: "Local Folder",
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
)
@@ -152,6 +182,193 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(step_id="local_folder", data_schema=schema, errors=errors)
async def async_step_media_source(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
content_id = user_input[CONF_MEDIA_CONTENT_ID].strip()
name = user_input[CONF_ALBUM_NAME].strip()
if not content_id.startswith("media-source://"):
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
else:
await self.async_set_unique_id(
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_MEDIA_SOURCE,
CONF_MEDIA_CONTENT_ID: content_id,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_MEDIA_CONTENT_ID): str,
}
)
return self.async_show_form(
step_id="media_source", data_schema=schema, errors=errors
)
async def async_step_immich(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect the Immich URL + API key and validate them."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_IMMICH_URL].strip()
key = user_input[CONF_IMMICH_API_KEY].strip()
from . import immich as immich_api
client = immich_api.ImmichClient(self.hass, url, key)
try:
await client.async_validate()
albums = await client.async_list_albums()
people = await client.async_list_people()
except Exception: # noqa: BLE001 - any failure means bad URL/key
errors["base"] = "immich_cannot_connect"
else:
self._immich_url = client.base_url
self._immich_key = key
# id -> name maps for the two multi-select pickers.
self._immich_albums = {
a["id"]: (a.get("albumName") or a["id"])
for a in albums
if a.get("id")
}
self._immich_people = {
p["id"]: p["name"]
for p in people
if p.get("id") and (p.get("name") or "").strip()
}
return await self.async_step_immich_select()
schema = vol.Schema(
{
vol.Required(CONF_IMMICH_URL): str,
vol.Required(CONF_IMMICH_API_KEY): str,
}
)
return self.async_show_form(
step_id="immich", data_schema=schema, errors=errors
)
async def async_step_immich_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Build a composite Immich selection and finish the entry.
The user ticks any mix of albums, people and favorites (and may add a
custom JSON filter); the coordinator unions them. Leaving everything
empty means "all photos".
"""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
raw_filter = (user_input.get(CONF_IMMICH_FILTER) or "").strip()
favorites = bool(user_input.get("favorites"))
chosen_albums = [a for a in user_input.get("albums", []) if a]
if _ALL_ALBUMS in chosen_albums:
chosen_albums = list(self._immich_albums.keys())
else:
chosen_albums = [a for a in chosen_albums if a in self._immich_albums]
chosen_people = [p for p in user_input.get("people", []) if p]
if _ALL_PEOPLE in chosen_people:
chosen_people = list(self._immich_people.keys())
else:
chosen_people = [p for p in chosen_people if p in self._immich_people]
if raw_filter:
try:
parsed = json.loads(raw_filter)
if not isinstance(parsed, dict):
raise ValueError
except ValueError:
errors[CONF_IMMICH_FILTER] = "immich_filter_invalid"
if not errors:
selection = {
"albums": chosen_albums,
"people": chosen_people,
"favorites": favorites,
}
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
f"composite:{sel_id}:{raw_filter}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
data = {
CONF_PROVIDER: PROVIDER_IMMICH,
CONF_IMMICH_URL: self._immich_url,
CONF_IMMICH_API_KEY: self._immich_key,
CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE,
CONF_IMMICH_SELECTION_ID: sel_id,
CONF_IMMICH_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
}
if raw_filter:
data[CONF_IMMICH_FILTER] = raw_filter
return self.async_create_entry(title=name, data=data)
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
if self._immich_albums:
album_options = [
selector.SelectOptionDict(
value=_ALL_ALBUMS, label="Select all albums"
)
] + [
selector.SelectOptionDict(value=aid, label=name)
for aid, name in self._immich_albums.items()
]
fields[vol.Optional("albums")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
if self._immich_people:
people_options = [
selector.SelectOptionDict(
value=_ALL_PEOPLE, label="Select all people"
)
] + [
selector.SelectOptionDict(value=pid, label=name)
for pid, name in self._immich_people.items()
]
fields[vol.Optional("people")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=people_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
fields[vol.Optional("favorites", default=False)] = selector.BooleanSelector()
fields[vol.Optional(CONF_IMMICH_FILTER)] = str
fields[
vol.Optional(CONF_IMMICH_IMAGE_SIZE, default=DEFAULT_IMMICH_IMAGE_SIZE)
] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS)
schema = vol.Schema(fields)
return self.async_show_form(
step_id="immich_select", data_schema=schema, errors=errors
)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
@@ -6,6 +6,43 @@ CONF_ALBUM_NAME = "album_name"
CONF_LOCAL_PATH = "local_path"
CONF_RECURSIVE = "recursive"
CONF_IMAGE_CACHE_MB = "image_cache_mb"
# Media Source provider: a ``media-source://...`` content id pointing at a
# folder-like node (e.g. an Immich people/album view, or local media). The
# coordinator browses it, collects the image children, and resolves each to
# a playable URL.
CONF_MEDIA_CONTENT_ID = "media_content_id"
# Immich (direct API) provider.
CONF_IMMICH_URL = "immich_url"
CONF_IMMICH_API_KEY = "immich_api_key"
CONF_IMMICH_SELECTION_TYPE = "immich_selection_type"
CONF_IMMICH_SELECTION_ID = "immich_selection_id"
CONF_IMMICH_IMAGE_SIZE = "immich_image_size"
CONF_IMMICH_FILTER = "immich_filter"
IMMICH_SELECTION_ALBUM = "album"
IMMICH_SELECTION_ALBUMS = "albums"
IMMICH_SELECTION_PERSON = "person"
IMMICH_SELECTION_PEOPLE = "people"
IMMICH_SELECTION_FAVORITES = "favorites"
IMMICH_SELECTION_ALL = "all"
IMMICH_SELECTION_RANDOM = "random"
IMMICH_SELECTION_SEARCH = "search"
# Composite: a client-side union of any mix of albums, people, favorites and a
# custom filter. Immich has no OR operator, so each member is queried on its
# own and the results are merged (see #19). The selection id is a JSON object
# ``{"albums": [...], "people": [...], "favorites": bool}``; an empty composite
# means "all photos".
IMMICH_SELECTION_COMPOSITE = "composite"
IMMICH_IMAGE_PREVIEW = "preview"
IMMICH_IMAGE_FULLSIZE = "fullsize"
IMMICH_IMAGE_ORIGINAL = "original"
IMMICH_IMAGE_SIZE_OPTIONS = [
IMMICH_IMAGE_PREVIEW,
IMMICH_IMAGE_FULLSIZE,
IMMICH_IMAGE_ORIGINAL,
]
DEFAULT_IMMICH_IMAGE_SIZE = IMMICH_IMAGE_PREVIEW
# Local-folder option: when True (default) the coordinator does best-effort
# reverse geocoding of EXIF GPS coordinates via the public Nominatim
# (OpenStreetMap) endpoint and exposes a human-readable ``location``
@@ -16,6 +53,8 @@ DEFAULT_REVERSE_GEOCODE = True
PROVIDER_GOOGLE_SHARED = "google_shared"
PROVIDER_LOCAL_FOLDER = "local_folder"
PROVIDER_MEDIA_SOURCE = "media_source"
PROVIDER_IMMICH = "immich"
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
@@ -60,6 +99,21 @@ DATE_FILTER_OPTIONS = [
]
DEFAULT_DATE_FILTER = DATE_FILTER_OFF
# How the date filter treats photos that have no EXIF capture date.
# use_uploaded_at - fall back to the upload date (keeps filters meaningful)
# include - keep undated photos (legacy behaviour for windows)
# exclude - drop undated photos entirely
MISSING_DATE_USE_UPLOADED = "use_uploaded_at"
MISSING_DATE_INCLUDE = "include"
MISSING_DATE_EXCLUDE = "exclude"
MISSING_DATE_OPTIONS = [
MISSING_DATE_USE_UPLOADED,
MISSING_DATE_INCLUDE,
MISSING_DATE_EXCLUDE,
]
DEFAULT_MISSING_DATE_MODE = MISSING_DATE_USE_UPLOADED
DEFAULT_SLIDE_INTERVAL = 60
DEFAULT_REFRESH_HOURS = 24
DEFAULT_FILL_MODE = FILL_BLUR
+595 -12
View File
@@ -6,6 +6,7 @@ from datetime import timedelta
import json
import logging
from pathlib import Path
import re
from typing import Any
import async_timeout
@@ -20,12 +21,22 @@ from .const import (
CONF_PROVIDER,
CONF_ALBUM_URL,
CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
)
from .store import SlideshowStore
@@ -53,6 +64,13 @@ class MediaItem:
latitude: float | None = None
longitude: float | None = None
location: str | None = None
# Free-text photo description / caption (local-folder provider only),
# read from EXIF ImageDescription, IPTC Caption-Abstract, or XMP
# dc:description. Used by the card's caption overlay when enabled.
description: str | None = None
# Provider-specific source identifier (e.g. the Immich asset id) used by
# background enrichment to fetch per-item metadata.
source_id: str | None = None
# True once the local-folder EXIF reader has visited this file.
# Prevents re-reading EXIF on every coordinator refresh and lets the
# background enrichment task skip already-processed files even after
@@ -63,6 +81,73 @@ class MediaItem:
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
_VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".3gp", ".mts", ".m2ts"}
# Media Source browsing limits: cap total collected images and recursion
# depth so a huge or self-referential tree can't hang the coordinator or
# exhaust memory.
_MEDIA_SOURCE_MAX_ITEMS = 5000
_MEDIA_SOURCE_MAX_DEPTH = 8
# System/metadata folders and files that some sources (notably Synology)
# expose but which are never user photos.
_SKIP_MEDIA_TITLES = {"@eadir", ".ds_store", "thumbs.db", "@syno", "#recycle"}
# Image formats a browser cannot render inline; skip so we don't queue
# guaranteed fetch failures.
_NON_WEB_IMAGE_EXTS = {
".psd", ".tif", ".tiff", ".heic", ".heif",
".cr2", ".nef", ".arw", ".dng", ".raw", ".orf", ".rw2",
}
def _is_junk_media_title(title: Any) -> bool:
"""Return True for system folders / non-renderable files to skip."""
if not isinstance(title, str):
return False
t = title.strip().lower()
if not t:
return False
if t in _SKIP_MEDIA_TITLES:
return True
if t.startswith("@") or t.startswith("."):
return True
dot = t.rfind(".")
if dot != -1 and t[dot:] in _NON_WEB_IMAGE_EXTS:
return True
return False
def _media_node_is_image(media_class: Any, media_content_type: Any) -> bool:
"""Return True if a browsed media node looks like a still image.
``media_class`` is Home Assistant's coarse category (e.g. ``image``,
``video``, ``directory``); ``media_content_type`` is the MIME type when
known. We accept a node when either signals an image, and explicitly
reject anything that declares a video type.
"""
mc = str(media_class or "").lower()
mime = str(media_content_type or "").lower()
if mc == "video" or mime.startswith("video/"):
return False
if mc == "image" or mime.startswith("image/"):
return True
return False
def _normalize_resolved_url(url: str, base_url: str) -> str:
"""Make a resolved media URL absolute so the fetch layer can load it.
``async_resolve_media`` returns either an absolute ``http(s)`` URL or a
site-relative path such as ``/media/local/...`` (often already signed via
an ``authSig`` query param). Relative paths are prefixed with the
instance's internal base URL; absolute URLs pass through unchanged.
"""
if not isinstance(url, str) or not url:
return url
if url.startswith("http://") or url.startswith("https://"):
return url
if url.startswith("/") and base_url:
return f"{base_url.rstrip('/')}{url}"
return url
_SKIP_DIR_PREFIXES = (".", "@", "#")
@@ -74,6 +159,66 @@ def _pick_url(item: dict[str, Any]) -> str | None:
return None
# Matches Google's ``=w1920-h1080`` (and variants) size suffix so two URLs for
# the same photo at different sizes collapse to one stable key.
_PHOTO_SIZE_SUFFIX_RE = re.compile(r"=[a-z0-9-]+$", re.IGNORECASE)
def _photo_base_key(url: str | None) -> str | None:
"""Return a stable per-photo key from a Google CDN URL.
Both album sources (``batchexecute`` and publicalbum.org) hand back
``lh3.googleusercontent.com/<id>=w...-h...`` URLs for the same photo, just
at different sizes and sometimes with query params. Dropping the query
string and the size suffix leaves the shared ``<id>`` portion, which lets
us match a publicalbum item to its dated batchexecute twin.
"""
if not isinstance(url, str) or not url:
return None
base = url.split("?", 1)[0]
base = _PHOTO_SIZE_SUFFIX_RE.sub("", base)
return base or None
def _enrich_missing_dates(
api_items: list["MediaItem"], scraped_items: list["MediaItem"]
) -> int:
"""Backfill missing dates on ``api_items`` from dated ``scraped_items``.
Matches photos across the two Google album sources by their stable
per-photo URL key and fills in any ``captured_at`` / ``uploaded_at`` that
the publicalbum item is missing. Mutates ``api_items`` in place and returns
how many items were touched. See issue #18.
"""
if not api_items or not scraped_items:
return 0
dates_by_key: dict[str, tuple[int | None, int | None]] = {}
for it in scraped_items:
key = _photo_base_key(it.url)
if key and (it.captured_at is not None or it.uploaded_at is not None):
dates_by_key.setdefault(key, (it.captured_at, it.uploaded_at))
if not dates_by_key:
return 0
enriched = 0
for it in api_items:
if it.captured_at is not None and it.uploaded_at is not None:
continue
twin = dates_by_key.get(_photo_base_key(it.url))
if not twin:
continue
cap, up = twin
touched = False
if it.captured_at is None and cap is not None:
it.captured_at = cap
touched = True
if it.uploaded_at is None and up is not None:
it.uploaded_at = up
touched = True
if touched:
enriched += 1
return enriched
def _pick_int(d: dict[str, Any], *path: str) -> int | None:
cur: Any = d
for p in path:
@@ -231,6 +376,7 @@ def _looks_like_video(raw: dict[str, Any]) -> bool:
_EXIF_TAG_DATETIME_ORIGINAL = 36867 # DateTimeOriginal
_EXIF_TAG_OFFSET_TIME_ORIGINAL = 36881 # OffsetTimeOriginal (e.g. "+02:00")
_EXIF_TAG_DATETIME = 306 # DateTime (modification time)
_EXIF_TAG_IMAGE_DESCRIPTION = 270 # ImageDescription (free text)
_EXIF_TAG_GPS_IFD = 34853 # Pointer to the GPS IFD
_EXIF_GPS_LAT_REF = 1 # "N" / "S"
_EXIF_GPS_LAT = 2 # rational tuple
@@ -351,6 +497,128 @@ def _parse_exif_datetime(raw: Any, offset_raw: Any) -> int | None:
return None
def _clean_description(value: Any) -> str | None:
"""Normalise a raw description value to trimmed text or ``None``."""
if isinstance(value, bytes):
try:
value = value.decode("utf-8", errors="ignore")
except Exception:
return None
if not isinstance(value, str):
return None
text = value.strip().replace("\x00", "")
return text or None
def _read_photo_description(img: Any, exif: Any) -> str | None:
"""Extract a photo description from EXIF, IPTC, or XMP metadata.
Tries, in order: EXIF ``ImageDescription``, IPTC ``Caption-Abstract``
(record 2, dataset 120), and XMP ``dc:description``. Returns the first
non-empty value, or ``None``. All lookups are defensive because these
metadata blocks are frequently absent or malformed.
"""
try:
desc = _clean_description(exif.get(_EXIF_TAG_IMAGE_DESCRIPTION))
if desc:
return desc
except Exception:
pass
try:
from PIL import IptcImagePlugin
iptc = IptcImagePlugin.getiptcinfo(img)
if iptc:
desc = _clean_description(iptc.get((2, 120)))
if desc:
return desc
except Exception:
pass
try:
xmp = img.getxmp()
except Exception:
xmp = None
if isinstance(xmp, dict):
found = _find_xmp_description(xmp)
if found:
return found
return None
def _find_xmp_description(node: Any) -> str | None:
"""Recursively search a parsed XMP tree for the ``dc:description`` text.
Pillow's ``getxmp()`` returns nested dicts. The relevant path is
``xmpmeta -> RDF -> Description -> description -> Alt -> li``. We match
the ``dc:description`` *field* by its exact lowercase localname
``description`` (case-sensitive) so we don't accidentally match the
``rdf:Description`` *container* (capital ``D``) that wraps it.
"""
if isinstance(node, dict):
for key, value in node.items():
# Case-sensitive: dc:description is lowercase; the rdf:Description
# container is capitalised and must be skipped here.
if isinstance(key, str) and key.split(":")[-1] == "description":
text = _extract_xmp_text(value)
if text:
return text
for value in node.values():
found = _find_xmp_description(value)
if found:
return found
elif isinstance(node, list):
for value in node:
found = _find_xmp_description(value)
if found:
return found
return None
def _extract_xmp_text(value: Any) -> str | None:
"""Pull plain text out of an XMP language-alternative structure.
Handles the shapes Pillow produces for ``dc:description``:
- a plain string
- ``{"Alt"/"Bag"/"Seq": {"li": ...}}`` containers
- an ``li`` that is a string, a ``{"lang", "text"}`` dict, or a list of
such dicts (multiple languages)
Prefers the ``x-default`` language entry when several are present.
"""
if isinstance(value, str):
return _clean_description(value)
if isinstance(value, dict):
# A language-alternative leaf: ``{"lang": ..., "text": ...}``.
if "text" in value:
return _clean_description(value.get("text"))
# Container wrappers: descend only through the known XMP keys so we
# never grab a sibling attribute value (e.g. a ``lang`` code).
for container in ("Alt", "Bag", "Seq"):
if container in value:
text = _extract_xmp_text(value[container])
if text:
return text
if "li" in value:
return _extract_xmp_text(value["li"])
return None
if isinstance(value, list):
# Multiple ``rdf:li`` entries: prefer x-default, else the first
# non-empty one.
default = None
first = None
for v in value:
if isinstance(v, dict) and v.get("lang") == "x-default":
default = _clean_description(v.get("text"))
if default:
return default
if first is None:
first = _extract_xmp_text(v)
return first
return None
def _read_local_exif(path: Path) -> dict[str, Any]:
"""Read EXIF metadata for a local file.
@@ -382,17 +650,25 @@ def _read_local_exif(path: Path) -> dict[str, Any]:
try:
with Image.open(path) as img:
exif = img.getexif()
if exif:
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
_EXIF_TAG_DATETIME
)
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
parsed = _parse_exif_datetime(dt_raw, offset_raw)
if parsed is not None:
out["captured_at"] = parsed
# Description can come from IPTC / XMP even when the file has no
# EXIF IFD, so this runs regardless of ``exif`` being present.
description = _read_photo_description(img, exif)
if description:
out["description"] = description
if not exif:
return out
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
_EXIF_TAG_DATETIME
)
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
parsed = _parse_exif_datetime(dt_raw, offset_raw)
if parsed is not None:
out["captured_at"] = parsed
gps = None
try:
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
@@ -550,13 +826,18 @@ def _merge_prior_enrichment(
item.longitude = prev.longitude
if prev.location and not item.location:
item.location = prev.location
if prev.description and not item.description:
item.description = prev.description
if prev.exif_scanned:
item.exif_scanned = True
class AlbumCoordinator(DataUpdateCoordinator):
# Bump when the persisted item shape changes incompatibly.
_ITEM_CACHE_VERSION = 2
# v3: added ``description``; forces a re-scan so already-cached items
# (exif_scanned=True) get their description read instead of being
# skipped forever.
_ITEM_CACHE_VERSION = 3
# Bump independently of the items cache - the geocode cache is
# keyed by coordinate and is safe to keep across item-shape changes.
_GEOCODE_CACHE_VERSION = 1
@@ -570,6 +851,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.album_url: str | None = entry.data.get(CONF_ALBUM_URL)
self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH)
self.recursive: bool = bool(entry.data.get(CONF_RECURSIVE, True))
self.media_content_id: str | None = entry.data.get(CONF_MEDIA_CONTENT_ID)
# Extra headers the camera must send when fetching image bytes
# (Immich API key). Empty for providers that need no auth.
self.image_request_headers: dict[str, str] = {}
# Persist the most recent successful album fetch so that a transient
# network/Google failure doesn't blank the slideshow on restart.
@@ -631,6 +916,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_local_folder()
elif self.provider == PROVIDER_GOOGLE_SHARED:
data = await self._update_google_shared()
elif self.provider == PROVIDER_MEDIA_SOURCE:
data = await self._update_media_source()
elif self.provider == PROVIDER_IMMICH:
data = await self._update_immich()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -644,9 +933,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise
items = data.get("items") or []
if self.provider == PROVIDER_LOCAL_FOLDER and items:
# Carry forward EXIF/geocode metadata for files we've already
# scanned this session; new files get filled in by the
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) and items:
# Carry forward EXIF/geocode metadata for items we've already
# scanned this session; new items get filled in by the
# background worker below.
prior_items = (self.data or {}).get("items") if isinstance(self.data, dict) else None
if prior_items:
@@ -740,6 +1029,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
latitude=raw.get("latitude"),
longitude=raw.get("longitude"),
location=raw.get("location"),
description=raw.get("description"),
source_id=raw.get("source_id"),
exif_scanned=bool(raw.get("exif_scanned", False)),
))
except Exception:
@@ -770,6 +1061,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
"latitude": it.latitude,
"longitude": it.longitude,
"location": it.location,
"description": it.description,
"source_id": it.source_id,
"exif_scanned": it.exif_scanned,
}
for it in items
@@ -839,6 +1132,261 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_media_source(self) -> dict[str, Any]:
"""Build the item list from a Home Assistant Media Source node.
Browses the configured ``media-source://`` content id recursively,
collecting image children, then resolves each to a playable URL.
Media Source exposes no per-photo EXIF, so date/GPS/description
features do not apply here (same as the Google provider).
"""
content_id = self.media_content_id
if not content_id:
raise UpdateFailed("Missing media source content id")
try:
from homeassistant.components import media_source
except Exception as err: # pragma: no cover - core ships media_source
raise UpdateFailed("media_source integration is not available") from err
collected: list[tuple[str, str | None]] = []
try:
await self._browse_media_source(media_source, content_id, collected, 0)
except UpdateFailed:
raise
except Exception as err:
raise UpdateFailed(f"Error browsing media source: {err}") from err
if not collected:
raise UpdateFailed("No images found in the selected media source")
base_url = self._internal_base_url()
items: list[MediaItem] = []
for cid, title in collected:
resolved = await self._resolve_media(media_source, cid)
if resolved is None:
continue
raw = resolved[0]
# ``async_resolve_media`` returns an UNSIGNED ``/media/...`` path
# when called directly (the frontend's websocket handler is what
# normally signs it). Fetching an unsigned local-media path
# server-side gets a 401, so sign it ourselves before use.
if isinstance(raw, str) and raw.startswith("/"):
raw = self._sign_media_path(raw)
url = _normalize_resolved_url(raw, base_url)
if not url:
continue
items.append(
MediaItem(
url=url,
width=None,
height=None,
mime_type=resolved[1],
filename=title,
)
)
if not items:
raise UpdateFailed("Could not resolve any media source images")
return {
"title": self.entry.title,
"items": items,
}
def _sign_media_path(self, path: str) -> str:
"""Sign a relative ``/media/...`` path so it can be fetched server-side.
Mirrors what Home Assistant's ``media_source/resolve_media`` websocket
handler does: quote the path and append an ``authSig`` signature. Uses
the content user so signing works from a background task with no
request context (the same mechanism that lets Cast devices fetch local
media). Signs for a window comfortably longer than the album refresh
interval; every refresh re-signs, so URLs stay fresh.
"""
from urllib.parse import quote
try:
from homeassistant.components.http.auth import async_sign_path
except Exception: # pragma: no cover - http always present
return path
quoted = quote(path)
expiration = timedelta(hours=max(48, int(self.store.refresh_hours) * 2 + 1))
for kwargs in ({"use_content_user": True}, {}):
try:
return async_sign_path(self.hass, quoted, expiration, **kwargs)
except TypeError:
# Older/newer signature without ``use_content_user``.
continue
except Exception as err:
_LOGGER.debug("media_source: failed to sign %s: %s", path, err)
return path
return path
async def _browse_media_source(
self,
media_source,
content_id: str,
collected: list[tuple[str, str | None]],
depth: int,
) -> None:
"""Recursively walk a media source tree collecting image leaves."""
if len(collected) >= _MEDIA_SOURCE_MAX_ITEMS or depth > _MEDIA_SOURCE_MAX_DEPTH:
return
browsed = await media_source.async_browse_media(self.hass, content_id)
children = getattr(browsed, "children", None) or []
for child in children:
if len(collected) >= _MEDIA_SOURCE_MAX_ITEMS:
break
child_id = getattr(child, "media_content_id", None)
if not child_id:
continue
title = getattr(child, "title", None)
if _is_junk_media_title(title):
continue
if _media_node_is_image(
getattr(child, "media_class", None),
getattr(child, "media_content_type", None),
):
collected.append((child_id, title))
elif getattr(child, "can_expand", False):
await self._browse_media_source(
media_source, child_id, collected, depth + 1
)
async def _resolve_media(
self, media_source, content_id: str
) -> tuple[str, str | None] | None:
"""Resolve a media content id to ``(url, mime_type)`` or ``None``."""
try:
try:
play = await media_source.async_resolve_media(
self.hass, content_id, None
)
except TypeError:
# Older cores: async_resolve_media(hass, content_id).
play = await media_source.async_resolve_media(self.hass, content_id)
except Exception as err:
_LOGGER.debug("media_source: failed to resolve %s: %s", content_id, err)
return None
url = getattr(play, "url", None)
if not url:
return None
return url, getattr(play, "mime_type", None)
def _internal_base_url(self) -> str:
"""Best-effort internal base URL for site-relative media URLs."""
try:
from homeassistant.helpers.network import get_url
return get_url(self.hass, prefer_external=False, allow_ip=True)
except Exception:
return ""
async def _update_immich(self) -> dict[str, Any]:
"""Build the item list from an Immich album or person via its API.
Unlike Media Source, the Immich API exposes per-photo metadata, so
capture date, GPS/location, and description all work. Dates come from
the asset list up front; location and description are filled in by the
background enrichment worker (one asset-detail call each, cached).
"""
from . import immich as immich_api
url = self.entry.data.get(CONF_IMMICH_URL)
api_key = self.entry.data.get(CONF_IMMICH_API_KEY)
sel_type = self.entry.data.get(CONF_IMMICH_SELECTION_TYPE)
sel_id = self.entry.data.get(CONF_IMMICH_SELECTION_ID)
size = self.entry.data.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
if not url or not api_key or not sel_type:
raise UpdateFailed("Immich provider is missing URL, API key, or selection")
# ``album``/``albums`` and ``person``/``people`` need a target id;
# composite/favorites/all/random/search do not (an empty composite
# means "all photos").
if sel_type in ("album", "albums", "person", "people") and not sel_id:
raise UpdateFailed("Immich provider is missing the album/person id")
filter_body = None
raw_filter = self.entry.data.get(CONF_IMMICH_FILTER)
if sel_type in ("search", "composite") and raw_filter:
try:
parsed = json.loads(raw_filter)
if isinstance(parsed, dict):
filter_body = parsed
except (ValueError, TypeError):
raise UpdateFailed("Immich search filter is not valid JSON")
client = immich_api.ImmichClient(self.hass, url, api_key)
# Auth header the camera must send when fetching image bytes. Sent
# server-side only, so the key never reaches the browser or the
# ``current_url`` attribute.
self.image_request_headers = dict(client.image_headers)
try:
assets = await client.async_collect_assets(sel_type, sel_id, filter_body)
except Exception as err:
raise UpdateFailed(f"Error querying Immich: {err}") from err
if not assets:
raise UpdateFailed("No images found for the selected Immich source")
items: list[MediaItem] = []
for a in assets:
aid = a.get("id")
if not aid:
continue
captured = immich_api._to_epoch_ms(
a.get("localDateTime")
) or immich_api._to_epoch_ms(a.get("fileCreatedAt"))
w = a.get("width")
h = a.get("height")
items.append(
MediaItem(
url=immich_api.build_image_url(client.base_url, aid, size),
width=w if isinstance(w, int) else None,
height=h if isinstance(h, int) else None,
mime_type=None,
filename=a.get("originalFileName"),
captured_at=captured,
source_id=aid,
)
)
return {
"title": self.entry.title,
"items": items,
}
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
from . import immich as immich_api
url = self.entry.data.get(CONF_IMMICH_URL)
api_key = self.entry.data.get(CONF_IMMICH_API_KEY)
if not item.source_id or not url or not api_key:
item.exif_scanned = True
return
client = immich_api.ImmichClient(self.hass, url, api_key)
try:
asset = await client.async_get_asset(item.source_id)
except Exception as err:
_LOGGER.debug("Immich: failed to fetch asset %s: %s", item.source_id, err)
item.exif_scanned = True
return
info = immich_api.parse_asset_exif(asset)
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
if "location" in info:
item.location = info["location"]
if "description" in info:
item.description = info["description"]
item.exif_scanned = True
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
"""Read EXIF for unscanned local files, then reverse-geocode.
@@ -865,6 +1413,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
await asyncio.sleep(0)
continue
if self.provider == PROVIDER_IMMICH:
try:
await self._enrich_immich_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("Immich enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
continue
url = item.url
if not url.startswith("file://"):
item.exif_scanned = True
@@ -883,6 +1449,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "description" in info:
item.description = info["description"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
@@ -1202,6 +1770,21 @@ class AlbumCoordinator(DataUpdateCoordinator):
byte_size=byte_size,
))
# Cross-source date enrichment: publicalbum.org often returns a fuller
# item list but with no (or partial) date metadata, while batchexecute
# returns dated items. Where the same photo appears in both, backfill
# the publicalbum item's captured_at / uploaded_at from its dated
# batchexecute twin (matched by the stable per-photo URL key). This
# keeps the larger item count while restoring the dates the date
# filter needs. See issue #18.
enriched = _enrich_missing_dates(api_items, scraped_items)
if enriched:
_LOGGER.info(
"Album scraper: enriched %d publicalbum item(s) with "
"batchexecute dates",
enriched,
)
# Pick the source with more items; prefer publicalbum.org on a tie
# because its URLs come pre-decorated with size hints.
if len(scraped_items) > len(api_items):
+375
View File
@@ -0,0 +1,375 @@
"""Immich (direct API) client and pure parsing helpers.
Talks to an Immich server using an API key. HTTP lives in ``ImmichClient``;
the parsing/URL helpers are pure functions so they can be unit-tested without
a live server or aiohttp.
API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
- ``GET /api/server/about`` -> ``{version, ...}`` (used to validate URL + key)
- ``GET /api/albums`` -> ``[{id, albumName, assetCount}]``
- ``GET /api/people`` -> ``{people: [{id, name}]}``
- ``POST /api/search/metadata`` ``{albumIds|personIds, type, size, page}``
-> ``{assets: {items: [...], total, nextPage}}``. List items carry
``id``/``type``/``localDateTime``/``fileCreatedAt``/``width``/``height``/
``originalFileName`` but NOT ``exifInfo``.
- ``GET /api/assets/{id}`` -> full asset incl ``exifInfo`` (lat/long, city,
country, description) - used to enrich location/description per asset.
- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or
``/api/assets/{id}/original`` (all require the ``x-api-key`` header).
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
import async_timeout
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_TIMEOUT = 30
_PAGE_SIZE = 1000
_MAX_ASSETS = 20_000
def normalize_base_url(url: str) -> str:
"""Strip trailing slashes and a trailing ``/api`` from a base URL."""
u = (url or "").strip().rstrip("/")
if u.endswith("/api"):
u = u[: -len("/api")]
return u
def build_image_url(base_url: str, asset_id: str, size: str) -> str:
"""Build the image URL for an asset at the requested size.
``preview`` / ``fullsize`` map to the thumbnail endpoint; ``original``
fetches the untouched original file. The API key is NOT included here - it
is sent as a request header so it never leaks into logs or the camera's
``current_url`` attribute.
"""
base = normalize_base_url(base_url)
if size == "original":
return f"{base}/api/assets/{asset_id}/original"
thumb_size = "fullsize" if size == "fullsize" else "preview"
return f"{base}/api/assets/{asset_id}/thumbnail?size={thumb_size}"
def _to_epoch_ms(value: Any) -> int | None:
"""Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``."""
if not isinstance(value, str) or not value:
return None
try:
iso = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(iso)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
try:
return int(dt.timestamp() * 1000)
except (OverflowError, OSError, ValueError):
return None
def location_label(city: Any, state: Any, country: Any) -> str | None:
"""Build a short ``"City, Country"`` style label from EXIF place fields.
Prefers ``city`` for the locality, falling back to ``state``. Appends the
country when present. Returns ``None`` when nothing usable is available.
"""
parts: list[str] = []
locality = None
for candidate in (city, state):
if isinstance(candidate, str) and candidate.strip():
locality = candidate.strip()
break
if locality:
parts.append(locality)
if isinstance(country, str) and country.strip():
parts.append(country.strip())
return ", ".join(parts) if parts else None
def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]:
"""Return ``(image_items, next_page)`` from a search/metadata response.
Filters out non-image assets and anything trashed/archived. ``next_page``
is the page number to request next, or ``None`` when done.
"""
assets = (payload or {}).get("assets") if isinstance(payload, dict) else None
if not isinstance(assets, dict):
return [], None
items = assets.get("items")
out = _filter_image_items(items)
next_page = assets.get("nextPage")
if isinstance(next_page, str) and next_page.isdigit():
next_page = int(next_page)
if not isinstance(next_page, int):
next_page = None
return out, next_page
def parse_random(payload: Any) -> list[dict[str, Any]]:
"""Return image items from a ``/api/search/random`` response.
``search/random`` returns a plain list of assets (no pagination wrapper).
"""
if isinstance(payload, list):
return _filter_image_items(payload)
# Some cores wrap it like search/metadata; handle that too.
if isinstance(payload, dict):
assets = payload.get("assets")
if isinstance(assets, dict):
return _filter_image_items(assets.get("items"))
return []
def _filter_image_items(items: Any) -> list[dict[str, Any]]:
"""Keep only non-trashed, non-archived image assets with an id."""
out: list[dict[str, Any]] = []
if isinstance(items, list):
for it in items:
if not isinstance(it, dict):
continue
if str(it.get("type", "")).upper() != "IMAGE":
continue
if it.get("isTrashed") or it.get("isArchived"):
continue
if not it.get("id"):
continue
out.append(it)
return out
def build_search_body(
selection_type: str, selection_id: str | None, filter_body: dict | None
) -> dict[str, Any]:
"""Build the ``search/metadata`` request body for a selection.
Always constrains to images. For ``search`` the user-supplied filter is
used as a base (with ``type`` forced to IMAGE). ``album``/``person`` add
the id filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing.
"""
body: dict[str, Any] = {"type": "IMAGE"}
if selection_type == "search" and isinstance(filter_body, dict):
body = dict(filter_body)
body["type"] = "IMAGE"
elif selection_type == "album" and selection_id:
body["albumIds"] = [selection_id]
elif selection_type == "person" and selection_id:
body["personIds"] = [selection_id]
elif selection_type == "favorites":
body["isFavorite"] = True
# ``all`` -> no extra filter (whole library).
return body
def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
"""Parse a composite selection id into ``{albums, people, favorites}``.
The id is a JSON object; anything malformed degrades to an empty
composite (which means "all photos").
"""
albums: list[str] = []
people: list[str] = []
favorites = False
if selection_id:
try:
data = json.loads(selection_id)
except (ValueError, TypeError):
data = None
if isinstance(data, dict):
albums = [a for a in data.get("albums", []) if isinstance(a, str) and a]
people = [p for p in data.get("people", []) if isinstance(p, str) and p]
favorites = bool(data.get("favorites"))
return {"albums": albums, "people": people, "favorites": favorites}
def build_composite_bodies(
selection_id: str | None, filter_body: dict | None = None
) -> list[dict[str, Any]]:
"""Build one ``search/metadata`` body per composite union member.
Immich has no OR, so each album, person, the favorites flag and any
custom filter becomes its own image query; the caller unions the
results. An empty composite yields a single unfiltered query -> the
whole library ("all photos").
"""
sel = parse_composite_selection(selection_id)
bodies: list[dict[str, Any]] = []
for aid in sel["albums"]:
bodies.append({"type": "IMAGE", "albumIds": [aid]})
for pid in sel["people"]:
bodies.append({"type": "IMAGE", "personIds": [pid]})
if sel["favorites"]:
bodies.append({"type": "IMAGE", "isFavorite": True})
if isinstance(filter_body, dict) and filter_body:
member = dict(filter_body)
member["type"] = "IMAGE"
bodies.append(member)
if not bodies:
bodies.append({"type": "IMAGE"})
return bodies
def parse_asset_exif(asset: Any) -> dict[str, Any]:
"""Extract the metadata we surface from a full asset detail response."""
out: dict[str, Any] = {}
if not isinstance(asset, dict):
return out
exif = asset.get("exifInfo")
if not isinstance(exif, dict):
return out
captured = _to_epoch_ms(exif.get("dateTimeOriginal")) or _to_epoch_ms(
asset.get("localDateTime")
)
if captured is not None:
out["captured_at"] = captured
lat = exif.get("latitude")
lon = exif.get("longitude")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
# Immich returns 0/0 or null when there is no fix; treat 0,0 as none.
if not (abs(lat) < 1e-6 and abs(lon) < 1e-6):
out["latitude"] = float(lat)
out["longitude"] = float(lon)
label = location_label(exif.get("city"), exif.get("state"), exif.get("country"))
if label:
out["location"] = label
desc = exif.get("description")
if isinstance(desc, str) and desc.strip():
out["description"] = desc.strip()
return out
class ImmichClient:
"""Thin async wrapper over the Immich REST API."""
def __init__(self, hass, base_url: str, api_key: str) -> None:
self.hass = hass
self.base_url = normalize_base_url(base_url)
self.api_key = api_key
@property
def headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key, "Accept": "application/json"}
@property
def image_headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key}
async def _get(self, path: str) -> Any:
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.get(self.base_url + path, headers=self.headers) as resp:
resp.raise_for_status()
return await resp.json()
async def _post(self, path: str, body: dict[str, Any]) -> Any:
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.post(
self.base_url + path, headers=self.headers, json=body
) as resp:
resp.raise_for_status()
return await resp.json()
async def async_validate(self) -> str | None:
"""Return the server version if the URL + key work, else raise."""
data = await self._get("/api/server/about")
return data.get("version") if isinstance(data, dict) else None
async def async_list_albums(self) -> list[dict[str, Any]]:
data = await self._get("/api/albums")
return data if isinstance(data, list) else []
async def async_list_people(self) -> list[dict[str, Any]]:
data = await self._get("/api/people")
if isinstance(data, dict):
people = data.get("people")
return people if isinstance(people, list) else []
return data if isinstance(data, list) else []
async def async_collect_assets(
self,
selection_type: str,
selection_id: str | None = None,
filter_body: dict | None = None,
) -> list[dict[str, Any]]:
"""Collect image assets for a selection.
``random`` uses ``/api/search/random`` (a single, unpaginated batch).
Everything else pages through ``/api/search/metadata`` with a body
built from the selection.
"""
if selection_type == "random":
body = {"size": min(_PAGE_SIZE, 250), "type": "IMAGE"}
if isinstance(filter_body, dict):
merged = dict(filter_body)
merged.update(body)
body = merged
payload = await self._post("/api/search/random", body)
return parse_random(payload)
if selection_type == "people":
# Immich treats multiple personIds in one query as AND (only photos
# where everyone appears together). To get OR (any of the people),
# query each person separately and union by asset id. See #19.
ids = [p for p in (selection_id or "").split(",") if p]
bodies = [{"type": "IMAGE", "personIds": [p]} for p in ids]
return await self._collect_union(bodies)
if selection_type == "albums":
# Same OR behavior for a set of albums: query each album on its own
# and union the results, deduped by asset id.
ids = [a for a in (selection_id or "").split(",") if a]
bodies = [{"type": "IMAGE", "albumIds": [a]} for a in ids]
return await self._collect_union(bodies)
if selection_type == "composite":
# A mix of albums, people, favorites and/or a custom filter. Each
# is queried on its own and unioned; an empty composite means the
# whole library. See #19.
bodies = build_composite_bodies(selection_id, filter_body)
return await self._collect_union(bodies)
base = build_search_body(selection_type, selection_id, filter_body)
return await self._collect_metadata(base)
async def _collect_metadata(self, base: dict[str, Any]) -> list[dict[str, Any]]:
"""Page through ``search/metadata`` for a prebuilt body."""
collected: list[dict[str, Any]] = []
page: int | None = 1
while page is not None and len(collected) < _MAX_ASSETS:
body = dict(base)
body["size"] = _PAGE_SIZE
body["page"] = page
payload = await self._post("/api/search/metadata", body)
items, next_page = parse_search_page(payload)
collected.extend(items)
page = next_page
return collected
async def _collect_union(
self, bodies: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Union several ``search/metadata`` queries (OR), deduped by asset id.
Each body is queried on its own so the results are a union (any of),
not Immich's default AND (only assets that match every filter at
once). See #19.
"""
seen: set[str] = set()
out: list[dict[str, Any]] = []
for body in bodies:
if len(out) >= _MAX_ASSETS:
break
items = await self._collect_metadata(body)
for it in items:
aid = it.get("id")
if aid and aid not in seen:
seen.add(aid)
out.append(it)
return out
async def async_get_asset(self, asset_id: str) -> dict[str, Any]:
return await self._get(f"/api/assets/{asset_id}")
@@ -3,10 +3,10 @@
"name": "Album Slideshow Camera",
"codeowners": ["@eyalgal"],
"config_flow": true,
"dependencies": ["http", "frontend"],
"dependencies": ["http", "frontend", "media_source"],
"documentation": "https://github.com/eyalgal/album_slideshow",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.0.0"
"version": "1.2.2"
}
+31 -6
View File
@@ -17,6 +17,9 @@ from .const import (
DATE_FILTER_ON_THIS_DAY,
DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR,
DEFAULT_MISSING_DATE_MODE,
MISSING_DATE_EXCLUDE,
MISSING_DATE_USE_UPLOADED,
ORDER_ALBUM,
ORDER_NEWEST_ADDED,
ORDER_NEWEST_TAKEN,
@@ -72,12 +75,20 @@ def filter_items(
items: Iterable[T],
*,
mode: str,
missing_date: str = DEFAULT_MISSING_DATE_MODE,
now: datetime | None = None,
) -> list[T]:
"""Filter items by ``captured_at`` according to ``mode``.
"""Filter items by date according to ``mode``.
Items with no ``captured_at`` are kept by default unless the mode is
``on_this_day`` (treated as a strict filter).
``missing_date`` decides what happens to photos that have no EXIF
``captured_at``:
- ``use_uploaded_at`` - fall back to ``uploaded_at`` for the comparison
so date windows stay meaningful. Photos with neither timestamp are
kept for window filters and dropped for the strict ``on_this_day``.
- ``include`` - keep undated photos for window filters (the legacy
behaviour); the strict ``on_this_day`` still drops them.
- ``exclude`` - drop undated photos entirely.
``now`` is overridable for deterministic tests.
"""
@@ -93,11 +104,25 @@ def filter_items(
out: list[T] = []
for it in items:
ts = getattr(it, "captured_at", None)
if not isinstance(ts, int):
if not strict:
if isinstance(ts, int):
if pred(ts):
out.append(it)
continue
if pred(ts):
# No capture date: behaviour depends on ``missing_date``.
if missing_date == MISSING_DATE_EXCLUDE:
continue
if missing_date == MISSING_DATE_USE_UPLOADED:
up = getattr(it, "uploaded_at", None)
if isinstance(up, int):
if pred(up):
out.append(it)
continue
# No upload date either: fall through to the lenient default.
# ``include`` (or ``use_uploaded_at`` with no usable date): keep for
# window filters, drop for strict modes like ``on_this_day``.
if not strict:
out.append(it)
return out
@@ -19,6 +19,8 @@ from .const import (
ORDER_RANDOM,
DATE_FILTER_OPTIONS,
DATE_FILTER_OFF,
MISSING_DATE_OPTIONS,
MISSING_DATE_USE_UPLOADED,
)
from .store import SlideshowStore
@@ -39,6 +41,7 @@ async def async_setup_entry(
AspectRatioSelect(entry, store),
MaxResolutionSelect(entry, store),
DateFilterSelect(entry, store),
MissingDateModeSelect(entry, store),
]
)
@@ -239,3 +242,31 @@ class DateFilterSelect(_BaseSelect):
if old and old.state in self.options:
self.store.date_filter = old.state
self.store.notify()
class MissingDateModeSelect(_BaseSelect):
_attr_icon = "mdi:calendar-question"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_missing_date_mode"
self._attr_name = "Missing capture date"
self._attr_options = list(MISSING_DATE_OPTIONS)
@property
def current_option(self):
value = self.store.missing_date_mode
return value if value in self.options else MISSING_DATE_USE_UPLOADED
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.missing_date_mode = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.missing_date_mode = old.state
self.store.notify()
@@ -15,6 +15,7 @@ from .const import (
DEFAULT_IMAGE_CACHE_MB,
DEFAULT_MAX_RESOLUTION,
DEFAULT_DATE_FILTER,
DEFAULT_MISSING_DATE_MODE,
)
@@ -37,6 +38,9 @@ class SlideshowStore:
# Date filter mode (preset windows like this_year / on_this_day).
date_filter: str = DEFAULT_DATE_FILTER
# How the date filter treats photos with no EXIF capture date.
missing_date_mode: str = DEFAULT_MISSING_DATE_MODE
# Pause toggle - when True, the slideshow holds on the current frame.
paused: bool = False
+36 -1
View File
@@ -24,11 +24,46 @@
"local_path": "Folder path",
"recursive": "Include subfolders"
}
},
"media_source": {
"title": "Media Source",
"description": "Paste a Media Source content id pointing at a folder-like node, for example media-source://immich/SERVER_ID|people or media-source://media_source/local/Albums. Browse to the folder in the HA media browser and copy its media-source:// id. Note: Media Source photos have no EXIF, so date, GPS and description features do not apply.",
"data": {
"album_name": "Album name",
"media_content_id": "Media Source id (media-source://...)"
}
},
"immich": {
"title": "Immich",
"description": "Connect directly to your Immich server for full photo metadata (date, location, description). Create an API key in Immich under Account Settings > API Keys.",
"data": {
"immich_url": "Immich URL (e.g. http://192.168.1.10:2283)",
"immich_api_key": "API key"
}
},
"immich_select": {
"title": "Immich source",
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add an Immich search filter (JSON) to include those results too - see the README for examples.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
}
}
},
"error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.",
"invalid_path": "Path is empty or invalid."
"invalid_path": "Path is empty or invalid.",
"invalid_media_source": "That does not look like a Media Source id (should start with media-source://).",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
"immich_no_content": "No albums or named people were found on this Immich server.",
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
"immich_people_required": "Pick at least one person for the People source.",
"immich_albums_required": "Pick at least one album for the Albums source."
}
},
"options": {
@@ -24,11 +24,46 @@
"local_path": "Folder path",
"recursive": "Include subfolders"
}
},
"media_source": {
"title": "Media Source",
"description": "Paste a Media Source content id pointing at a folder-like node, for example media-source://immich/SERVER_ID|people or media-source://media_source/local/Albums. Browse to the folder in the HA media browser and copy its media-source:// id. Note: Media Source photos have no EXIF, so date, GPS and description features do not apply.",
"data": {
"album_name": "Album name",
"media_content_id": "Media Source id (media-source://...)"
}
},
"immich": {
"title": "Immich",
"description": "Connect directly to your Immich server for full photo metadata (date, location, description). Create an API key in Immich under Account Settings > API Keys.",
"data": {
"immich_url": "Immich URL (e.g. http://192.168.1.10:2283)",
"immich_api_key": "API key"
}
},
"immich_select": {
"title": "Immich source",
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add an Immich search filter (JSON) to include those results too - see the README for examples.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
}
}
},
"error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.",
"invalid_path": "Path is empty or invalid."
"invalid_path": "Path is empty or invalid.",
"invalid_media_source": "That does not look like a Media Source id (should start with media-source://).",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
"immich_no_content": "No albums or named people were found on this Immich server.",
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
"immich_people_required": "Pick at least one person for the People source.",
"immich_albums_required": "Pick at least one album for the Albums source."
}
},
"options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.0.0";
const VERSION = "1.2.2";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -46,10 +46,10 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
const FIT_MODES = new Set(["auto", "cover", "contain"]);
// Caption overlay (date / location). ``show`` is an ordered subset of
// these fields; ``position`` is one of a 3x3 anchor grid; ``date_format``
// is one of the named presets below or a custom token string.
const CAPTION_FIELDS = ["date", "location"];
// Caption overlay (date / location / description). ``show`` is an ordered
// subset of these fields; ``position`` is one of a 3x3 anchor grid;
// ``date_format`` is one of the named presets below or a custom token string.
const CAPTION_FIELDS = ["date", "location", "description"];
const CAPTION_POSITIONS = new Set([
"top-left",
"top-center",
@@ -492,6 +492,7 @@ function createAlbumSlideshowCardClass(Base) {
location: attrs.location,
latitude: attrs.latitude,
longitude: attrs.longitude,
description: attrs.description,
}
: null;
this._loadAndSwap(url, fit, blurBackdrop, captionData);
@@ -644,6 +645,7 @@ function createAlbumSlideshowCardClass(Base) {
location: data.location ?? null,
latitude: data.latitude ?? null,
longitude: data.longitude ?? null,
description: data.description ?? null,
},
];
}
@@ -656,6 +658,8 @@ function createAlbumSlideshowCardClass(Base) {
if (txt) lines.push(txt);
} else if (field === "location") {
if (frame.location) lines.push(String(frame.location));
} else if (field === "description") {
if (frame.description) lines.push(String(frame.description));
}
}
return lines;
@@ -890,6 +894,7 @@ const TAP_OPTIONS = [
const CAPTION_SHOW_OPTIONS = [
{ value: "date", label: "Date" },
{ value: "location", label: "Location" },
{ value: "description", label: "Description" },
];
const CAPTION_POSITION_OPTIONS = [
@@ -952,6 +957,7 @@ const CAPTION_DEFAULTS = {
const LIVE_FIELDS = [
"paused",
"date_filter",
"missing_date_mode",
"portrait_mode",
"order_mode",
"slide_interval",
@@ -962,6 +968,7 @@ const LIVE_FIELDS = [
const LIVE_SUFFIX = {
paused: "_paused",
date_filter: "_date_filter",
missing_date_mode: "_missing_date_mode",
portrait_mode: "_portrait_mode",
order_mode: "_order_mode",
slide_interval: "_interval",
@@ -974,6 +981,7 @@ const LIVE_SUFFIX = {
const LIVE_LABELS = {
live_paused: "Pause slideshow",
live_date_filter: "Date filter",
live_missing_date_mode: "Missing capture date",
live_portrait_mode: "Orientation mismatch mode",
live_order_mode: "Order mode",
live_slide_interval: "Slide interval (seconds)",
@@ -1130,6 +1138,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
}
for (const [field, id] of [
["date_filter", s.date_filter],
["missing_date_mode", s.missing_date_mode],
["portrait_mode", s.portrait_mode],
["order_mode", s.order_mode],
]) {
@@ -1263,7 +1272,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
},
{
type: "expandable",
title: "Caption (date & location)",
title: "Caption (date, location & description)",
icon: "mdi:format-text",
schema: [
{ name: "caption_enabled", selector: { boolean: {} } },
@@ -1377,7 +1386,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
const e = st(s.paused);
out.live_paused = !!e && e.state === "on";
}
for (const f of ["date_filter", "portrait_mode", "order_mode"]) {
for (const f of ["date_filter", "missing_date_mode", "portrait_mode", "order_mode"]) {
if (s[f]) {
const e = st(s[f]);
out[`live_${f}`] = e ? e.state : "";
@@ -1429,12 +1438,16 @@ function createAlbumSlideshowCardEditorClass(Base) {
"How long the card freezes its slide after a tap. 0 disables it.",
caption_date_format:
"Pick a preset or type a custom format (YYYY, MMMM, MMM, MM, DD, D).",
caption_show:
"Description comes from the photo's EXIF/IPTC/XMP caption and is only available with the local-folder provider.",
caption_per_image:
"When a portrait pair is shown, caption each photo with its own date and location.",
"When a portrait pair is shown, caption each photo with its own date, location and description.",
caption_color: "CSS color, e.g. #ffffff or white.",
caption_font_size: "CSS size, e.g. 14px, 1.1em.",
live_paused:
"These control the Album Slideshow integration directly and apply everywhere this album is shown, not only this card.",
live_missing_date_mode:
"What a date filter does with photos that have no capture date: use the upload date, keep them, or drop them.",
};
return helpers[s.name] || "";
};
@@ -1558,6 +1571,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
});
} else if (
field === "date_filter" ||
field === "missing_date_mode" ||
field === "portrait_mode" ||
field === "order_mode"
) {
@@ -1636,7 +1650,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
if (data.caption_enabled) {
let show = data.caption_show;
if (!Array.isArray(show)) show = show ? [show] : [];
show = show.filter((v) => v === "date" || v === "location");
show = show.filter(
(v) => v === "date" || v === "location" || v === "description",
);
if (show.length > 0) {
const cap = { show };
const pos = data.caption_position || CAPTION_DEFAULTS.position;