updated apps
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user