251 files
This commit is contained in:
@@ -22,6 +22,7 @@ from .const import (
|
||||
SERVICE_LIST_HIDDEN_PHOTOS,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
from .viewers import async_register_websocket as async_register_viewer_websocket
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -336,6 +337,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
await _async_register_card(hass)
|
||||
async_register_viewer_websocket(hass)
|
||||
|
||||
async def _retry_lovelace_resource(_event) -> None:
|
||||
if hass.data.get(DOMAIN, {}).get("lovelace_resource_registered"):
|
||||
@@ -456,6 +458,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# itself never has to render a transition burst on the event loop.
|
||||
await _async_register_card(hass)
|
||||
|
||||
# Streams video slides to the card (provider credentials stay server-side).
|
||||
try:
|
||||
from .video import async_register_video_view
|
||||
|
||||
async_register_video_view(hass)
|
||||
except Exception: # noqa: BLE001 - stills keep working without it
|
||||
_LOGGER.exception("Failed to register Album Slideshow video view")
|
||||
|
||||
await _async_cleanup_legacy_entities(hass, entry)
|
||||
|
||||
store = SlideshowStore()
|
||||
|
||||
@@ -36,8 +36,10 @@ from .const import (
|
||||
)
|
||||
from . import image_processing as ip
|
||||
from . import playlist
|
||||
from . import video
|
||||
from .coordinator import AlbumCoordinator, MediaItem
|
||||
from .store import SlideshowStore
|
||||
from .viewers import async_get_tracker
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,6 +61,13 @@ _SKIP_SEARCH_LIMIT = 30
|
||||
|
||||
_MAX_RENDER_ATTEMPTS = 10
|
||||
|
||||
# How long a render waits for an item's metadata (faces for the crop,
|
||||
# caption fields) to be fetched on demand before rendering without it.
|
||||
# Upcoming slides render ahead of time, so this rarely delays a slide.
|
||||
_ENRICH_WAIT_SECONDS = 8.0
|
||||
|
||||
_MB = 1024 * 1024
|
||||
|
||||
# Fixed floor (in items) for the pairing cooldown exclusion zone, applied on
|
||||
# each side of the current index in addition to the percentage-based radius.
|
||||
# Keeps tiny albums from pairing consecutive/near-consecutive shots even when
|
||||
@@ -161,9 +170,34 @@ def _caption_frame(item: MediaItem | None) -> dict[str, Any]:
|
||||
"longitude": getattr(item, "longitude", None),
|
||||
"description": getattr(item, "description", None),
|
||||
**_camera_metadata_attributes(item),
|
||||
**_file_attributes(item),
|
||||
}
|
||||
|
||||
|
||||
def _file_attributes(item: MediaItem | None) -> dict[str, Any]:
|
||||
return {
|
||||
"filename": getattr(item, "filename", None),
|
||||
"path": getattr(item, "source_path", None),
|
||||
}
|
||||
|
||||
|
||||
# Describe the stream a video slide is served as (see
|
||||
# ``AlbumCoordinator.async_video_info``); ``None`` for photos.
|
||||
VIDEO_INFO_FIELDS = (
|
||||
"video_version", "video_bitrate_mbps", "video_size_bytes", "video_content_type",
|
||||
)
|
||||
|
||||
|
||||
def _is_video(item: Any) -> bool:
|
||||
"""A video slide (played alone). Live Photos are photos with motion."""
|
||||
return bool(getattr(item, "video_url", None)) and not getattr(item, "live_photo", False)
|
||||
|
||||
|
||||
def _has_motion(item: Any) -> bool:
|
||||
"""A video, or a Live Photo whose motion clip can play over its still."""
|
||||
return bool(getattr(item, "video_url", None))
|
||||
|
||||
|
||||
def _item_faces(item: MediaItem | None) -> tuple[ip.FaceBox, ...] | None:
|
||||
"""Return the validated face boxes stored on a media item.
|
||||
|
||||
@@ -213,6 +247,10 @@ class _DownloadCache:
|
||||
def put(self, url: str, data: bytes) -> None:
|
||||
if len(data) > self._max_bytes:
|
||||
# Item exceeds the entire cache budget; skip caching but don't raise.
|
||||
_LOGGER.debug(
|
||||
"Image cache: not caching %.1f MB image, larger than the %.1f MB budget",
|
||||
len(data) / _MB, self._max_bytes / _MB,
|
||||
)
|
||||
return
|
||||
if url in self._cache:
|
||||
self._total_bytes -= len(self._cache[url])
|
||||
@@ -222,13 +260,28 @@ class _DownloadCache:
|
||||
self._evict()
|
||||
|
||||
def resize(self, max_bytes: int) -> None:
|
||||
self._max_bytes = max(max_bytes, 1)
|
||||
max_bytes = max(max_bytes, 1)
|
||||
if max_bytes != self._max_bytes:
|
||||
_LOGGER.debug(
|
||||
"Image cache: budget %.1f MB -> %.1f MB (%.1f MB in use)",
|
||||
self._max_bytes / _MB, max_bytes / _MB, self._total_bytes / _MB,
|
||||
)
|
||||
self._max_bytes = max_bytes
|
||||
self._evict()
|
||||
|
||||
def _evict(self) -> None:
|
||||
count = freed = 0
|
||||
while self._total_bytes > self._max_bytes and self._cache:
|
||||
_, data = self._cache.popitem(last=False)
|
||||
self._total_bytes -= len(data)
|
||||
count += 1
|
||||
freed += len(data)
|
||||
if count:
|
||||
_LOGGER.debug(
|
||||
"Image cache: evicted %d image(s) (%.1f MB), now %.1f/%.1f MB, %d cached",
|
||||
count, freed / _MB, self._total_bytes / _MB, self._max_bytes / _MB,
|
||||
len(self._cache),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
@@ -243,6 +296,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
||||
|
||||
class AlbumSlideshowCamera(Camera):
|
||||
_attr_should_poll = False
|
||||
# The signed stream URL and the server's file paths stay out of history.
|
||||
_unrecorded_attributes = frozenset({"video_url", "current_path"})
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
|
||||
super().__init__()
|
||||
@@ -272,6 +327,10 @@ class AlbumSlideshowCamera(Camera):
|
||||
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
|
||||
# top/bottom) for a paired frame; None for single frames.
|
||||
self._last_pair_orientation: str | None = None
|
||||
# ``{"photo_id", "duration_ms"}`` when the current slide is a video
|
||||
# (always unpaired); ``_video_url`` is its signed proxy URL.
|
||||
self._last_video: dict | None = None
|
||||
self._video_url: str | None = None
|
||||
# Cached effective playlist (after date filter + ordering). Invalidated
|
||||
# by any store change or coordinator update.
|
||||
self._effective_cache: tuple[int, list[MediaItem]] | None = None
|
||||
@@ -315,6 +374,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._consecutive_failures: int = 0
|
||||
self._render_task: asyncio.Task | None = None
|
||||
self._render_settings = self._current_render_settings()
|
||||
self._viewers = async_get_tracker(hass)
|
||||
self._unsub_viewers = None
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
previous_items = self._effective_cache[1] if self._effective_cache else None
|
||||
@@ -326,7 +387,14 @@ class AlbumSlideshowCamera(Camera):
|
||||
|
||||
coordinator.async_add_listener(_on_coordinator_update)
|
||||
|
||||
self._pause_when_unwatched = bool(store.pause_when_unwatched)
|
||||
|
||||
def _on_store_change() -> None:
|
||||
enabled = bool(self.store.pause_when_unwatched)
|
||||
if enabled and not self._pause_when_unwatched and self._unsub_viewers is not None:
|
||||
# Cards stay quiet while the setting is off; let them check in.
|
||||
self._viewers.async_restart_grace(self.entry.entry_id)
|
||||
self._pause_when_unwatched = enabled
|
||||
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
|
||||
self._slide_deadline = None
|
||||
settings = self._current_render_settings()
|
||||
@@ -355,12 +423,18 @@ class AlbumSlideshowCamera(Camera):
|
||||
# offset based on entry_id keeps the pattern stable across
|
||||
# restarts. Up to ~3 s spread across albums.
|
||||
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
|
||||
self._unsub_viewers = self._viewers.async_track(
|
||||
self.entry.entry_id, self._on_viewers_change
|
||||
)
|
||||
self._render_task = self.hass.async_create_background_task(
|
||||
self._render_loop(initial_delay=startup_delay),
|
||||
name="album_slideshow_render_loop",
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
if self._unsub_viewers is not None:
|
||||
self._unsub_viewers()
|
||||
self._unsub_viewers = None
|
||||
preload_task = self._preload_task
|
||||
self._cancel_preload()
|
||||
if self._render_task is not None:
|
||||
@@ -396,6 +470,12 @@ class AlbumSlideshowCamera(Camera):
|
||||
cur = items[self._index] if items and 0 <= self._index < len(items) else None
|
||||
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
|
||||
caption_frames = self._caption_frames(cur)
|
||||
# Paths go out separately as ``current_path``, which isn't recorded.
|
||||
paths = [frame.get("path") for frame in caption_frames]
|
||||
caption_frames = [
|
||||
{key: value for key, value in frame.items() if key != "path"}
|
||||
for frame in caption_frames
|
||||
]
|
||||
captured_at_pair = (
|
||||
[frame["captured_at"] for frame in caption_frames]
|
||||
if self._last_pair_frames else None
|
||||
@@ -425,6 +505,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
"media_count_total": len(data.get("items", []) or []),
|
||||
"current_index": self._index,
|
||||
"current_filename": getattr(cur, "filename", None),
|
||||
"current_path": paths if self._last_pair_frames else getattr(cur, "source_path", None),
|
||||
"current_url": getattr(cur, "url", None),
|
||||
"current_is_portrait": self._last_is_portrait,
|
||||
"captured_at": captured_at_pair if captured_at_pair else captured_at,
|
||||
@@ -445,6 +526,17 @@ class AlbumSlideshowCamera(Camera):
|
||||
# ``pair_orientation`` tells the card how the two halves are laid
|
||||
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
|
||||
"caption_frames": caption_frames,
|
||||
# Video slides: the camera image is the poster frame; the card
|
||||
# plays ``video_url`` over it for ``video_hold_seconds``.
|
||||
"media_kind": (
|
||||
("live_photo" if self._last_video.get("live") else "video")
|
||||
if self._last_video else "image"
|
||||
),
|
||||
"video_url": self._video_url if self._last_video else None,
|
||||
"video_hold_seconds": (
|
||||
self._slide_hold_seconds() if self._last_video else None
|
||||
),
|
||||
**self._video_info_attributes(),
|
||||
"pair_orientation": self._last_pair_orientation,
|
||||
"slide_interval": int(self.store.slide_interval),
|
||||
"fill_mode": self.store.fill_mode,
|
||||
@@ -455,6 +547,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
"shuffle_age_bias": int(self.store.shuffle_age_bias),
|
||||
"missing_date_mode": self.store.missing_date_mode,
|
||||
"paused": bool(self.store.paused),
|
||||
"pause_when_unwatched": bool(self.store.pause_when_unwatched),
|
||||
"paused_unwatched": self._paused_unwatched,
|
||||
"refresh_hours": int(self.store.refresh_hours),
|
||||
"aspect_ratio": self.store.aspect_ratio,
|
||||
"pair_divider_px": int(self.store.pair_divider_px),
|
||||
@@ -489,7 +583,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
the slide was rendered still shows.
|
||||
"""
|
||||
if not self._last_pair_frames:
|
||||
return [_caption_frame(cur)]
|
||||
return [{**_caption_frame(cur), **self._video_info_attributes()}]
|
||||
photo_ids = self._current_frame.meta.get("photo_ids", []) if self._current_frame else []
|
||||
if len(photo_ids) != len(self._last_pair_frames):
|
||||
return self._last_pair_frames
|
||||
@@ -762,6 +856,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._last_is_portrait = None
|
||||
self._last_pair_frames = None
|
||||
self._last_pair_orientation = None
|
||||
self._last_video = None
|
||||
self._video_url = None
|
||||
|
||||
def blank_frame():
|
||||
with Image.new("RGB", (16, 9), "black") as image:
|
||||
@@ -795,12 +891,17 @@ class AlbumSlideshowCamera(Camera):
|
||||
# that stub out homeassistant.
|
||||
from homeassistant.components.camera import async_get_still_stream
|
||||
|
||||
return await async_get_still_stream(
|
||||
request,
|
||||
self.async_camera_image,
|
||||
self.content_type,
|
||||
self.frame_interval,
|
||||
)
|
||||
# Someone is watching the stream, so "Pause when not viewed" holds off.
|
||||
close = self._viewers.async_add_stream(self.entry.entry_id)
|
||||
try:
|
||||
return await async_get_still_stream(
|
||||
request,
|
||||
self.async_camera_image,
|
||||
self.content_type,
|
||||
self.frame_interval,
|
||||
)
|
||||
finally:
|
||||
close()
|
||||
|
||||
# Older HA cores may dispatch via the alt name; alias for compatibility.
|
||||
async def async_handle_async_mjpeg_stream(self, request):
|
||||
@@ -874,9 +975,20 @@ class AlbumSlideshowCamera(Camera):
|
||||
try:
|
||||
composed, meta = await renderer._compose_for_index(items)
|
||||
meta = dict(meta or {})
|
||||
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
|
||||
shown = items[renderer._index]
|
||||
meta.setdefault("photo_ids", [getattr(shown, "photo_id", None)])
|
||||
# Faces used for the crop, so later face data can be detected.
|
||||
meta.setdefault("faces", (_item_faces(items[renderer._index]),))
|
||||
meta.setdefault("faces", (_item_faces(shown),))
|
||||
# A paired Live Photo shows only its still.
|
||||
if _has_motion(shown) and len(meta["photo_ids"]) == 1 and shown.photo_id:
|
||||
if _is_video(shown):
|
||||
meta["video"] = {
|
||||
"photo_id": shown.photo_id,
|
||||
"duration_ms": shown.duration_ms,
|
||||
**await self._video_info(shown),
|
||||
}
|
||||
else:
|
||||
meta["video"] = {"photo_id": shown.photo_id, "live": True}
|
||||
cursor = self._capture_cursor(renderer)
|
||||
if composed is None:
|
||||
raise RuntimeError("Image composition returned no frame")
|
||||
@@ -902,6 +1014,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
|
||||
def _apply_frame(self, frame: _RenderedFrame) -> None:
|
||||
"""Make a rendered frame current and publish it to Home Assistant."""
|
||||
previous_frame = getattr(self, "_current_frame", None)
|
||||
self._current_frame = frame
|
||||
self._framebuffer = frame.data
|
||||
self.store.last_frame = frame.data
|
||||
@@ -915,19 +1028,41 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._last_is_portrait = meta.get("is_portrait")
|
||||
self._last_pair_frames = meta.get("pair_frames")
|
||||
self._last_pair_orientation = meta.get("pair_orientation")
|
||||
previous_video = getattr(self, "_last_video", None)
|
||||
self._last_video = meta.get("video")
|
||||
if not self._last_video:
|
||||
self._video_url = None
|
||||
elif not (
|
||||
previous_video
|
||||
and previous_video.get("photo_id") == self._last_video["photo_id"]
|
||||
and getattr(self, "_video_url", None)
|
||||
):
|
||||
# Re-rendering the same clip (metadata refreshes do this) keeps its
|
||||
# URL, so the card lets it play on instead of restarting it.
|
||||
self._video_url = video.sign_video_path(
|
||||
self.hass, video.video_path(self.entry.entry_id, self._last_video["photo_id"])
|
||||
)
|
||||
self._frame_id += 1
|
||||
self._frame_serials[self._frame_id] = frame.serial
|
||||
while len(self._frame_serials) > _FRAME_ID_MEMORY:
|
||||
del self._frame_serials[next(iter(self._frame_serials))]
|
||||
if previous_frame is None or previous_frame.meta.get("photo_ids") != meta.get("photo_ids"):
|
||||
# A different slide is up: restart the clock with its own hold
|
||||
# (a video's length), whichever path put it here. Re-renders and
|
||||
# exclusion rebuilds can swap the slide without going through the
|
||||
# timer; a re-render of the same slide keeps the running clock.
|
||||
self._slide_deadline = None
|
||||
self._interrupt_event.set()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow %s: displayed buffered frame_id=%d index=%d "
|
||||
"previous=%d next=%d",
|
||||
"previous=%d next=%d hold=%.1fs",
|
||||
self.entry.title,
|
||||
self._frame_id,
|
||||
self._index,
|
||||
len(self._previous_frames),
|
||||
len(self._next_frames),
|
||||
self._slide_hold_seconds(),
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -1203,7 +1338,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
if self._timeline_dirty:
|
||||
return True
|
||||
try:
|
||||
if self.store.paused:
|
||||
if self._held:
|
||||
await self._interrupt_event.wait()
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
@@ -1214,6 +1349,33 @@ class AlbumSlideshowCamera(Camera):
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
@property
|
||||
def _paused_unwatched(self) -> bool:
|
||||
"""Held by "Pause when not viewed": no card or stream is showing us."""
|
||||
return bool(self.store.pause_when_unwatched) and self._viewers.is_unwatched(
|
||||
self.entry.entry_id
|
||||
)
|
||||
|
||||
@property
|
||||
def _held(self) -> bool:
|
||||
"""Whether the slide timer is stopped (manual navigation still works)."""
|
||||
return bool(self.store.paused) or self._paused_unwatched
|
||||
|
||||
def _on_viewers_change(self) -> None:
|
||||
if not self.store.pause_when_unwatched:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow %s: %s",
|
||||
self.entry.title,
|
||||
"paused, no one is viewing it" if self._paused_unwatched
|
||||
else "resumed, a viewer is back",
|
||||
)
|
||||
# Wake the render loop; a returning viewer gets a full interval on
|
||||
# the slide they find.
|
||||
self._slide_deadline = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _render_loop(self, initial_delay: float = 0.0) -> None:
|
||||
"""Display buffered frames on command/timer and refill them in the background."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -1248,7 +1410,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
while True:
|
||||
try:
|
||||
if self._slide_deadline is None:
|
||||
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
|
||||
self._slide_deadline = loop.time() + self._slide_hold_seconds()
|
||||
if self._timeline_dirty:
|
||||
async with self._navigation_lock:
|
||||
if self._timeline_dirty:
|
||||
@@ -1258,7 +1420,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
interrupted = await self._wait_or_interrupt(
|
||||
max(0.0, self._slide_deadline - loop.time())
|
||||
)
|
||||
if not interrupted and not self.store.paused:
|
||||
if not interrupted and not self._held:
|
||||
async with self._navigation_lock:
|
||||
if (
|
||||
not self._timeline_dirty
|
||||
@@ -1266,7 +1428,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
and loop.time() >= self._slide_deadline
|
||||
):
|
||||
await self._show_next_frame()
|
||||
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
|
||||
self._slide_deadline = loop.time() + self._slide_hold_seconds()
|
||||
self._consecutive_failures = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
@@ -1279,6 +1441,68 @@ class AlbumSlideshowCamera(Camera):
|
||||
err,
|
||||
)
|
||||
|
||||
async def _ensure_enriched(self, item: MediaItem) -> None:
|
||||
"""Fetch an item's metadata before rendering it, if still missing.
|
||||
|
||||
Faces steer the crop and caption fields are baked into paired slides,
|
||||
so a slide rendered before the background pass reaches its photo
|
||||
would otherwise be missing both. Bounded so a slow server only
|
||||
delays the slide briefly; the fetch carries on in the background.
|
||||
"""
|
||||
enrich = getattr(getattr(self, "coordinator", None), "async_enrich_item", None)
|
||||
if enrich is None:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
started = loop.time()
|
||||
try:
|
||||
fetched = await asyncio.wait_for(asyncio.shield(enrich(item)), _ENRICH_WAIT_SECONDS)
|
||||
if fetched:
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow: fetched metadata for %s in %d ms before display",
|
||||
getattr(item, "filename", None), (loop.time() - started) * 1000,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - render without metadata
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow: metadata for %s not ready: %r",
|
||||
getattr(item, "filename", None), err,
|
||||
)
|
||||
|
||||
async def _video_info(self, item: MediaItem) -> dict[str, Any]:
|
||||
"""Which stream a video is served as; empty when it can't be told."""
|
||||
fetch = getattr(self.coordinator, "async_video_info", None)
|
||||
if fetch is None:
|
||||
return {}
|
||||
try:
|
||||
info = await fetch(item)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - a caption must not cost the slide
|
||||
_LOGGER.debug("Album Slideshow: could not describe video %s: %s", item.filename, err)
|
||||
return {}
|
||||
return {field: info.get(field) for field in VIDEO_INFO_FIELDS if field in info}
|
||||
|
||||
def _video_info_attributes(self) -> dict[str, Any]:
|
||||
video_meta = getattr(self, "_last_video", None) or {}
|
||||
duration_ms = video_meta.get("duration_ms")
|
||||
return {
|
||||
"video_duration": duration_ms / 1000 if duration_ms else None,
|
||||
**{field: video_meta.get(field) for field in VIDEO_INFO_FIELDS},
|
||||
}
|
||||
|
||||
def _slide_hold_seconds(self) -> float:
|
||||
"""Seconds the current slide stays up: the interval, or the clip's length."""
|
||||
interval = int(self.store.slide_interval)
|
||||
if not self._last_video or self._last_video.get("live"):
|
||||
# Photos, including Live Photos, keep the slide interval.
|
||||
return float(interval)
|
||||
return video.video_hold_seconds(
|
||||
self._last_video.get("duration_ms"),
|
||||
int(getattr(self.store, "video_max_seconds", interval)),
|
||||
interval,
|
||||
)
|
||||
|
||||
@property
|
||||
def _compose_semaphore(self) -> asyncio.Semaphore:
|
||||
"""Return the domain-wide compose semaphore, creating it on demand.
|
||||
@@ -1372,6 +1596,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
):
|
||||
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
||||
|
||||
await self._ensure_enriched(cur)
|
||||
cur_bytes = await self._fetch_bytes(cur.url)
|
||||
if not cur_bytes:
|
||||
raise RuntimeError(f"Failed to fetch image: {cur.url}")
|
||||
@@ -1388,7 +1613,12 @@ class AlbumSlideshowCamera(Camera):
|
||||
img = None
|
||||
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
||||
|
||||
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
|
||||
# A video plays alone, so it is never split into a pair.
|
||||
if (
|
||||
orientation_mismatch
|
||||
and portrait_mode == ORIENTATION_MISMATCH_PAIR
|
||||
and not _is_video(cur)
|
||||
):
|
||||
pair = await self._find_next_mismatch_image(
|
||||
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
|
||||
)
|
||||
@@ -1485,6 +1715,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
continue
|
||||
if self._index != start:
|
||||
self._do_advance(count, items)
|
||||
await self._ensure_enriched(cur)
|
||||
composed = await self._async_image_job(
|
||||
ip.render_image, img, fill_mode, width, height, self._crop_hints(cur)
|
||||
)
|
||||
@@ -1505,6 +1736,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
height: int,
|
||||
fill_mode: str,
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
await self._ensure_enriched(item)
|
||||
b = await self._fetch_bytes(item.url)
|
||||
if not b:
|
||||
return None, None
|
||||
@@ -1592,7 +1824,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
it = items[idx]
|
||||
tries += 1
|
||||
|
||||
if it.url in self._recent_urls:
|
||||
if it.url in self._recent_urls or _is_video(it):
|
||||
continue
|
||||
|
||||
meta_portrait = ip.is_portrait_item_by_metadata(it)
|
||||
@@ -1610,6 +1842,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
continue
|
||||
|
||||
if ip.is_portrait_item(it, img) != is_portrait_canvas:
|
||||
# Only the chosen partner needs faces and caption fields.
|
||||
await self._ensure_enriched(it)
|
||||
return img, it
|
||||
ip.safe_close(img)
|
||||
|
||||
|
||||
@@ -36,8 +36,12 @@ from .const import (
|
||||
CONF_IMMICH_SELECTION_TYPE,
|
||||
CONF_IMMICH_SELECTION_ID,
|
||||
CONF_IMMICH_IMAGE_SIZE,
|
||||
CONF_IMMICH_INCLUDE_VIDEOS,
|
||||
CONF_IMMICH_LIVE_PHOTOS,
|
||||
CONF_IMMICH_FILTER,
|
||||
DEFAULT_IMMICH_IMAGE_SIZE,
|
||||
DEFAULT_IMMICH_INCLUDE_VIDEOS,
|
||||
DEFAULT_IMMICH_LIVE_PHOTOS,
|
||||
IMMICH_IMAGE_SIZE_OPTIONS,
|
||||
IMMICH_SELECTION_COMPOSITE,
|
||||
CONF_PHOTOPRISM_URL,
|
||||
@@ -279,6 +283,18 @@ def _immich_select_schema(
|
||||
default=_default(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE),
|
||||
)
|
||||
] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS)
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_IMMICH_INCLUDE_VIDEOS,
|
||||
default=_default(CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS),
|
||||
)
|
||||
] = selector.BooleanSelector()
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_IMMICH_LIVE_PHOTOS,
|
||||
default=_default(CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS),
|
||||
)
|
||||
] = selector.BooleanSelector()
|
||||
return vol.Schema(fields)
|
||||
|
||||
|
||||
@@ -323,6 +339,8 @@ def _parse_immich_select(
|
||||
CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE,
|
||||
CONF_IMMICH_SELECTION_ID: json.dumps(selection, sort_keys=True),
|
||||
CONF_IMMICH_IMAGE_SIZE: size,
|
||||
CONF_IMMICH_INCLUDE_VIDEOS: bool(user_input.get(CONF_IMMICH_INCLUDE_VIDEOS)),
|
||||
CONF_IMMICH_LIVE_PHOTOS: bool(user_input.get(CONF_IMMICH_LIVE_PHOTOS)),
|
||||
CONF_ALBUM_NAME: name,
|
||||
CONF_IMMICH_FILTER: raw_filter,
|
||||
}
|
||||
@@ -1711,6 +1729,12 @@ class ImmichOptionsFlow(config_entries.OptionsFlow):
|
||||
CONF_IMMICH_IMAGE_SIZE: entry.data.get(
|
||||
CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE
|
||||
),
|
||||
CONF_IMMICH_INCLUDE_VIDEOS: bool(entry.data.get(
|
||||
CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS
|
||||
)),
|
||||
CONF_IMMICH_LIVE_PHOTOS: bool(entry.data.get(
|
||||
CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS
|
||||
)),
|
||||
}
|
||||
if entry.data.get(CONF_IMMICH_FILTER):
|
||||
defaults[CONF_IMMICH_FILTER] = entry.data[CONF_IMMICH_FILTER]
|
||||
@@ -1739,7 +1763,10 @@ def _immich_source_changed(old: dict[str, Any], new: dict[str, Any]) -> bool:
|
||||
CONF_IMMICH_FILTER,
|
||||
CONF_IMMICH_IMAGE_SIZE,
|
||||
)
|
||||
return any(old.get(key) != new.get(key) for key in keys)
|
||||
return any(old.get(key) != new.get(key) for key in keys) or any(
|
||||
bool(old.get(key)) != bool(new.get(key))
|
||||
for key in (CONF_IMMICH_INCLUDE_VIDEOS, CONF_IMMICH_LIVE_PHOTOS)
|
||||
)
|
||||
|
||||
|
||||
async def _async_clear_item_cache(hass: Any, entry_id: str) -> None:
|
||||
|
||||
@@ -58,6 +58,13 @@ IMMICH_IMAGE_SIZE_OPTIONS = [
|
||||
IMMICH_IMAGE_ORIGINAL,
|
||||
]
|
||||
DEFAULT_IMMICH_IMAGE_SIZE = IMMICH_IMAGE_PREVIEW
|
||||
# Immich option: include video assets. The camera still renders a poster
|
||||
# frame for each video; the dashboard card plays the clip on top of it.
|
||||
CONF_IMMICH_INCLUDE_VIDEOS = "immich_include_videos"
|
||||
DEFAULT_IMMICH_INCLUDE_VIDEOS = False
|
||||
# Immich option: play a Live Photo's motion clip over its still.
|
||||
CONF_IMMICH_LIVE_PHOTOS = "immich_live_photos"
|
||||
DEFAULT_IMMICH_LIVE_PHOTOS = False
|
||||
# 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``
|
||||
@@ -351,6 +358,19 @@ DEFAULT_IMAGE_CACHE_MB = 75
|
||||
# Previous/Next can swap these JPEGs immediately without downloading,
|
||||
# composing, or encoding during the button press.
|
||||
DEFAULT_NAVIGATION_BUFFER_SIZE = 2
|
||||
# A video slide stays up for the clip's length, capped by this setting.
|
||||
# Very short clips are held (and looped by the card) for the minimum so a
|
||||
# one-second clip doesn't flash past.
|
||||
DEFAULT_VIDEO_MAX_SECONDS = 60
|
||||
MAX_VIDEO_MAX_SECONDS = 3600
|
||||
MIN_VIDEO_HOLD_SECONDS = 5
|
||||
# "Pause when not viewed": each visible card checks in every
|
||||
# VIEWER_HEARTBEAT_SECONDS (the card hard-codes the same value); the
|
||||
# slideshow holds once nothing has checked in for VIEWER_GRACE_SECONDS, so
|
||||
# one or two missed check-ins or a page reload never pause it.
|
||||
VIEWER_HEARTBEAT_SECONDS = 20
|
||||
VIEWER_GRACE_SECONDS = 60
|
||||
WS_VIEWER_HEARTBEAT = "album_slideshow/viewer_heartbeat"
|
||||
|
||||
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
|
||||
DEFAULT_MAX_RESOLUTION = "1080p"
|
||||
|
||||
@@ -10,6 +10,7 @@ import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -41,8 +42,13 @@ from .const import (
|
||||
CONF_IMMICH_SELECTION_TYPE,
|
||||
CONF_IMMICH_SELECTION_ID,
|
||||
CONF_IMMICH_IMAGE_SIZE,
|
||||
CONF_IMMICH_INCLUDE_VIDEOS,
|
||||
CONF_IMMICH_LIVE_PHOTOS,
|
||||
CONF_IMMICH_FILTER,
|
||||
DEFAULT_IMMICH_IMAGE_SIZE,
|
||||
DEFAULT_IMMICH_INCLUDE_VIDEOS,
|
||||
DEFAULT_IMMICH_LIVE_PHOTOS,
|
||||
IMMICH_IMAGE_PREVIEW,
|
||||
CONF_PHOTOPRISM_URL,
|
||||
CONF_PHOTOPRISM_AUTH_METHOD,
|
||||
CONF_PHOTOPRISM_TOKEN,
|
||||
@@ -162,11 +168,29 @@ class MediaItem:
|
||||
# True when ``location`` came from our OpenStreetMap lookup rather than
|
||||
# the provider, so it can be re-formatted from the place cache.
|
||||
location_geocoded: bool = False
|
||||
# Upstream playback URL for a video item (``url`` is then its poster
|
||||
# still), or for a Live Photo's motion clip (``live_photo``; ``url`` is
|
||||
# the photo). Fetched server-side by the video proxy view only, so
|
||||
# provider credentials never reach the browser. ``duration_ms`` is the
|
||||
# clip length (videos only).
|
||||
video_url: str | None = None
|
||||
duration_ms: int | None = None
|
||||
live_photo: bool = False
|
||||
# Provider file path (Immich ``originalPath``), shown by the card's
|
||||
# ``path`` caption.
|
||||
source_path: str | None = None
|
||||
# Runtime-only description of the stream a video is served as (see
|
||||
# ``async_video_info``); not persisted.
|
||||
video_info: dict[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.photo_id is None:
|
||||
self.photo_id = _photo_identifier(self.source_id)
|
||||
|
||||
@property
|
||||
def is_video(self) -> bool:
|
||||
return bool(self.video_url) and not self.live_photo
|
||||
|
||||
|
||||
def _photo_identifier(source_id: str | None) -> str | None:
|
||||
if not source_id:
|
||||
@@ -505,9 +529,12 @@ _NAME_PREFIXES = (
|
||||
"village of ", "town of ", "city of ",
|
||||
)
|
||||
|
||||
# How many EXIF reads to perform between cache flushes. Bigger batches
|
||||
# mean fewer Store writes but more rework if HA is killed mid-scan.
|
||||
# How many EXIF reads to perform between listener updates, and the minimum
|
||||
# time between items-cache writes during a pass (the cache holds every item,
|
||||
# so writing it per batch is costly on large libraries; the pass still saves
|
||||
# when it finishes or is cancelled).
|
||||
_EXIF_BATCH_SAVE = 25
|
||||
_ENRICHMENT_SAVE_INTERVAL = 60.0
|
||||
_GEOCODE_BATCH_SAVE = 10
|
||||
|
||||
# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first
|
||||
@@ -1145,6 +1172,11 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
CONF_IMMICH_URL, CONF_IMMICH_API_KEY, CONF_IMMICH_SELECTION_TYPE,
|
||||
CONF_IMMICH_SELECTION_ID, CONF_IMMICH_FILTER, CONF_IMMICH_IMAGE_SIZE,
|
||||
)]
|
||||
# Appended only when enabled so existing caches stay valid.
|
||||
if entry.data.get(CONF_IMMICH_INCLUDE_VIDEOS):
|
||||
source.append("videos")
|
||||
if entry.data.get(CONF_IMMICH_LIVE_PHOTOS):
|
||||
source.append("live_photos")
|
||||
self._immich_cache_source = sha256(json.dumps(source).encode()).hexdigest()
|
||||
self.album_url: str | None = entry.data.get(CONF_ALBUM_URL)
|
||||
self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH)
|
||||
@@ -1208,6 +1240,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"geocode_total": 0,
|
||||
"geocode_done": 0,
|
||||
}
|
||||
# Per-item enrichment in flight, shared by the background pass and
|
||||
# on-demand requests from the camera so an item is fetched once.
|
||||
self._enrich_inflight: dict[int, asyncio.Task] = {}
|
||||
self._last_items_save: float | None = None
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -1526,6 +1562,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
} if isinstance(raw.get("camera_metadata"), dict) else None,
|
||||
google_location_scanned=bool(raw.get("google_location_scanned", False)),
|
||||
location_geocoded=bool(raw.get("location_geocoded", False)),
|
||||
video_url=raw.get("video_url") if isinstance(raw.get("video_url"), str) else None,
|
||||
duration_ms=raw.get("duration_ms") if isinstance(raw.get("duration_ms"), int) else None,
|
||||
source_path=raw.get("source_path") if isinstance(raw.get("source_path"), str) else None,
|
||||
live_photo=bool(raw.get("live_photo", False)),
|
||||
))
|
||||
except Exception:
|
||||
continue
|
||||
@@ -1570,6 +1610,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"camera_metadata": it.camera_metadata,
|
||||
"google_location_scanned": it.google_location_scanned,
|
||||
"location_geocoded": it.location_geocoded,
|
||||
"video_url": it.video_url,
|
||||
"duration_ms": it.duration_ms,
|
||||
"source_path": it.source_path,
|
||||
"live_photo": it.live_photo,
|
||||
}
|
||||
for it in items
|
||||
],
|
||||
@@ -1811,6 +1855,12 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
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)
|
||||
include_videos = bool(
|
||||
self.entry.data.get(CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS)
|
||||
)
|
||||
live_photos = bool(
|
||||
self.entry.data.get(CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS)
|
||||
)
|
||||
if not url or not api_key or not sel_type:
|
||||
raise UpdateFailed("Immich provider is missing URL, API key, or selection")
|
||||
|
||||
@@ -1837,7 +1887,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
self.image_request_headers = dict(client.image_headers)
|
||||
|
||||
try:
|
||||
assets = await client.async_collect_assets(sel_type, sel_id, filter_body)
|
||||
assets = await client.async_collect_assets(
|
||||
sel_type, sel_id, filter_body, include_videos
|
||||
)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error querying Immich: {err}") from err
|
||||
|
||||
@@ -1854,18 +1906,46 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
) or immich_api._to_epoch_ms(a.get("fileCreatedAt"))
|
||||
w = a.get("width")
|
||||
h = a.get("height")
|
||||
is_video = immich_api.is_video_item(a)
|
||||
motion_id = immich_api.live_photo_video_id(a) if live_photos else None
|
||||
if is_video:
|
||||
video_url = immich_api.build_video_url(client.base_url, aid)
|
||||
elif motion_id:
|
||||
video_url = immich_api.build_video_url(client.base_url, motion_id)
|
||||
else:
|
||||
video_url = None
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=immich_api.build_image_url(client.base_url, aid, size),
|
||||
# A video's poster still is its preview thumbnail: the
|
||||
# ``original`` endpoint would return the video file and
|
||||
# ``fullsize`` is only generated for images.
|
||||
url=immich_api.build_image_url(
|
||||
client.base_url, aid, IMMICH_IMAGE_PREVIEW if is_video else 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,
|
||||
video_url=video_url,
|
||||
live_photo=bool(motion_id),
|
||||
duration_ms=(
|
||||
immich_api.parse_duration_ms(a.get("duration")) if is_video else None
|
||||
),
|
||||
source_path=(
|
||||
a.get("originalPath") if isinstance(a.get("originalPath"), str) else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Immich %s: %d assets (%d videos, %d Live Photos)",
|
||||
self.entry.title,
|
||||
len(items),
|
||||
sum(1 for item in items if item.is_video),
|
||||
sum(1 for item in items if item.live_photo),
|
||||
)
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
@@ -2592,6 +2672,33 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
return candidate, {}
|
||||
return None, {}
|
||||
|
||||
async def async_video_info(self, item: MediaItem) -> dict[str, Any]:
|
||||
"""Describe the stream a video slide is served as; cached per item.
|
||||
|
||||
Used for the card's ``video_version`` caption: whether Immich serves
|
||||
its transcoded copy or the original, and the stream's bitrate.
|
||||
"""
|
||||
if item.video_info is not None:
|
||||
return item.video_info
|
||||
if self.provider != PROVIDER_IMMICH or not item.source_id or not item.is_video:
|
||||
return {}
|
||||
from . import immich as immich_api
|
||||
|
||||
client = immich_api.ImmichClient(
|
||||
self.hass,
|
||||
self.entry.data.get(CONF_IMMICH_URL),
|
||||
self.entry.data.get(CONF_IMMICH_API_KEY),
|
||||
)
|
||||
content_type, served = await client.async_probe_video(item.source_id)
|
||||
if item.byte_size is None:
|
||||
# Enrichment may not have reached this item yet.
|
||||
info = immich_api.parse_asset_exif(await client.async_get_asset(item.source_id))
|
||||
item.byte_size = info.get("byte_size")
|
||||
item.video_info = immich_api.describe_video(
|
||||
content_type, served, item.byte_size, item.duration_ms
|
||||
)
|
||||
return item.video_info
|
||||
|
||||
async def _enrich_immich_item(self, item: MediaItem) -> None:
|
||||
"""Fetch Immich metadata and the face boxes used for cropping."""
|
||||
from . import immich as immich_api
|
||||
@@ -2642,6 +2749,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
item.location_geocoded = False
|
||||
if "description" in info:
|
||||
item.description = info["description"]
|
||||
if "byte_size" in info:
|
||||
item.byte_size = info["byte_size"]
|
||||
item.exif_scanned = True
|
||||
else:
|
||||
if asset is None:
|
||||
@@ -2798,6 +2907,73 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
item.location_geocoded = False
|
||||
item.exif_scanned = True
|
||||
|
||||
async def async_enrich_item(self, item: MediaItem) -> bool:
|
||||
"""Enrich one item now, sharing the work with the background pass.
|
||||
|
||||
The camera calls this just before rendering a slide so faces (for
|
||||
cropping) and caption metadata are in place without waiting for the
|
||||
pass to reach the item. Concurrent callers await the same fetch.
|
||||
Returns whether metadata was fetched (``False``: nothing to do).
|
||||
"""
|
||||
if self.provider == PROVIDER_GOOGLE_SHARED or not self._needs_enrichment(item):
|
||||
return False
|
||||
inflight = getattr(self, "_enrich_inflight", None)
|
||||
if inflight is None:
|
||||
inflight = self._enrich_inflight = {}
|
||||
key = id(item)
|
||||
task = inflight.get(key)
|
||||
if task is None:
|
||||
task = self.hass.async_create_background_task(
|
||||
self._enrich_one(item),
|
||||
name=f"album_slideshow_enrich_item_{self.entry.entry_id}",
|
||||
)
|
||||
inflight[key] = task
|
||||
task.add_done_callback(lambda _task: inflight.pop(key, None))
|
||||
# Shielded: a cancelled caller (e.g. a dropped preload) must not
|
||||
# abandon a fetch another caller is waiting on.
|
||||
return await asyncio.shield(task)
|
||||
|
||||
async def _enrich_one(self, item: MediaItem) -> bool:
|
||||
"""Fetch one item's metadata from its provider; never raises."""
|
||||
try:
|
||||
if self.provider == PROVIDER_IMMICH:
|
||||
await self._enrich_immich_item(item)
|
||||
elif self.provider == PROVIDER_NEXTCLOUD:
|
||||
await self._enrich_nextcloud_item(item)
|
||||
elif self.provider == PROVIDER_UGREEN:
|
||||
await self._enrich_ugreen_item(item)
|
||||
elif item.url.startswith("file://"):
|
||||
path = Path(item.url[len("file://"):])
|
||||
info = await self.hass.async_add_executor_job(_read_local_exif, path)
|
||||
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"]
|
||||
item.exif_scanned = True
|
||||
else:
|
||||
# Nothing to read for remote files without a provider API.
|
||||
item.exif_scanned = True
|
||||
return False
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("%s enrich error: %s", self.provider, err)
|
||||
item.exif_scanned = True
|
||||
self._enrich_progress["exif_done"] = self._enrich_progress.get("exif_done", 0) + 1
|
||||
return True
|
||||
|
||||
async def _flush_enrichment(self, data: dict[str, Any]) -> None:
|
||||
"""Persist progress (at most once a minute) and notify listeners."""
|
||||
now = time.monotonic()
|
||||
last = getattr(self, "_last_items_save", None)
|
||||
if last is None or now - last >= _ENRICHMENT_SAVE_INTERVAL:
|
||||
self._last_items_save = now
|
||||
await self._save_cached_items(data)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
|
||||
"""Read EXIF for unscanned local files, then reverse-geocode.
|
||||
|
||||
@@ -2813,110 +2989,28 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
if not items:
|
||||
return
|
||||
|
||||
scanned_since_save = 0
|
||||
pending = 0
|
||||
try:
|
||||
for idx, item in enumerate(items):
|
||||
if not self._needs_enrichment(item):
|
||||
# Fast path: yield occasionally so we don't starve
|
||||
# the event loop when (re-)visiting a long list of
|
||||
# already-processed items.
|
||||
# already-processed items (including ones the camera
|
||||
# enriched on demand).
|
||||
if idx % _ENRICHMENT_FAST_PATH_YIELD_EVERY == 0:
|
||||
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)
|
||||
if not await self.async_enrich_item(item):
|
||||
continue
|
||||
|
||||
if self.provider == PROVIDER_NEXTCLOUD:
|
||||
try:
|
||||
await self._enrich_nextcloud_item(item)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("Nextcloud 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
|
||||
|
||||
if self.provider == PROVIDER_UGREEN:
|
||||
try:
|
||||
await self._enrich_ugreen_item(item)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("UGREEN 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
|
||||
continue
|
||||
|
||||
path = Path(url[len("file://"):])
|
||||
try:
|
||||
info = await self.hass.async_add_executor_job(
|
||||
_read_local_exif, path
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("EXIF: executor error for %s: %s", path, err)
|
||||
info = {}
|
||||
|
||||
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"]
|
||||
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)
|
||||
pending += 1
|
||||
if pending >= _EXIF_BATCH_SAVE:
|
||||
pending = 0
|
||||
await self._flush_enrichment(data)
|
||||
|
||||
# Flush any tail items before the geocode phase.
|
||||
if scanned_since_save:
|
||||
await self._save_cached_items(data)
|
||||
self.async_set_updated_data(data)
|
||||
if pending:
|
||||
await self._flush_enrichment(data)
|
||||
|
||||
await self._geocode_items_background(data)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -18,6 +18,11 @@ API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
|
||||
keep faces whole in cover-mode crops.
|
||||
- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or
|
||||
``/api/assets/{id}/original`` (all require the ``x-api-key`` header).
|
||||
- Video bytes: ``/api/assets/{id}/video/playback`` - the browser-friendly
|
||||
(transcoded when Immich's policy requires it) stream; honours ``Range``.
|
||||
Video list items carry ``duration``: integer milliseconds on current
|
||||
servers, an ``"H:MM:SS.ffffff"`` string on older ones. A Live Photo is an
|
||||
image whose ``livePhotoVideoId`` names its (hidden) motion-clip asset.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -57,6 +62,97 @@ def build_image_url(base_url: str, asset_id: str, size: str) -> str:
|
||||
return f"{base}/api/assets/{asset_id}/thumbnail?size={thumb_size}"
|
||||
|
||||
|
||||
def build_video_url(base_url: str, asset_id: str) -> str:
|
||||
"""Build the playback URL for a video asset (key sent as a header)."""
|
||||
return f"{normalize_base_url(base_url)}/api/assets/{asset_id}/video/playback"
|
||||
|
||||
|
||||
def is_video_item(item: Any) -> bool:
|
||||
return isinstance(item, dict) and str(item.get("type", "")).upper() == "VIDEO"
|
||||
|
||||
|
||||
def live_photo_video_id(item: Any) -> str | None:
|
||||
"""The motion-clip asset id of a Live Photo, or ``None``."""
|
||||
if not isinstance(item, dict) or is_video_item(item):
|
||||
return None
|
||||
value = item.get("livePhotoVideoId")
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def stream_total_bytes(status: int, headers: Any) -> int | None:
|
||||
"""Total size of a stream from a ranged probe's response headers.
|
||||
|
||||
A ``206`` carries it after the slash in ``Content-Range``
|
||||
(``bytes 0-0/12345``); a server that ignores ``Range`` answers ``200``
|
||||
with the full ``Content-Length``.
|
||||
"""
|
||||
raw = None
|
||||
if status == 206:
|
||||
content_range = headers.get("Content-Range") or ""
|
||||
raw = content_range.rpartition("/")[2]
|
||||
elif status == 200:
|
||||
raw = headers.get("Content-Length")
|
||||
try:
|
||||
total = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return total if total > 0 else None
|
||||
|
||||
|
||||
def describe_video(
|
||||
content_type: str | None,
|
||||
served_bytes: int | None,
|
||||
original_bytes: int | None,
|
||||
duration_ms: int | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarise the stream Immich serves for a video.
|
||||
|
||||
Immich's playback endpoint serves its transcoded copy when one exists and
|
||||
the original file otherwise, so a stream the same size as the original is
|
||||
the original. The bitrate is the stream's average over the clip.
|
||||
"""
|
||||
version = None
|
||||
if served_bytes and original_bytes:
|
||||
version = "original" if served_bytes == original_bytes else "transcoded"
|
||||
bitrate = None
|
||||
if served_bytes and duration_ms:
|
||||
bitrate = round(served_bytes * 8 / (duration_ms / 1000) / 1_000_000, 1)
|
||||
return {
|
||||
"video_version": version,
|
||||
"video_bitrate_mbps": bitrate,
|
||||
"video_size_bytes": served_bytes,
|
||||
"video_content_type": (content_type or "").split(";", 1)[0].strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def parse_duration_ms(value: Any) -> int | None:
|
||||
"""Parse an Immich video duration to milliseconds.
|
||||
|
||||
Current servers send integer milliseconds; older ones send an
|
||||
``"H:MM:SS.ffffff"`` string. Returns ``None`` for missing, malformed or
|
||||
zero durations (images report ``null``, ``0`` or ``"0:00:00.00000"``).
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return None
|
||||
return int(round(value))
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
hours, minutes, seconds = int(parts[0]), int(parts[1]), float(parts[2])
|
||||
except ValueError:
|
||||
return None
|
||||
if hours < 0 or minutes < 0 or not math.isfinite(seconds) or seconds < 0:
|
||||
return None
|
||||
total = int(round((hours * 3600 + minutes * 60 + seconds) * 1000))
|
||||
return total if total > 0 else None
|
||||
|
||||
|
||||
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:
|
||||
@@ -93,17 +189,20 @@ def location_label(city: Any, state: Any, country: Any) -> str | None:
|
||||
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.
|
||||
def parse_search_page(
|
||||
payload: Any, include_videos: bool = False
|
||||
) -> tuple[list[dict[str, Any]], int | None]:
|
||||
"""Return ``(media_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.
|
||||
Filters out non-image assets (videos too unless ``include_videos``) 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)
|
||||
out = _filter_image_items(items, include_videos)
|
||||
next_page = assets.get("nextPage")
|
||||
if isinstance(next_page, str) and next_page.isdigit():
|
||||
next_page = int(next_page)
|
||||
@@ -112,51 +211,74 @@ def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]:
|
||||
return out, next_page
|
||||
|
||||
|
||||
def parse_random(payload: Any) -> list[dict[str, Any]]:
|
||||
def parse_random(payload: Any, include_videos: bool = False) -> 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)
|
||||
return _filter_image_items(payload, include_videos)
|
||||
# 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 _filter_image_items(assets.get("items"), include_videos)
|
||||
return []
|
||||
|
||||
|
||||
def _filter_image_items(items: Any) -> list[dict[str, Any]]:
|
||||
"""Keep only non-trashed, non-archived image assets with an id."""
|
||||
def _filter_image_items(items: Any, include_videos: bool = False) -> list[dict[str, Any]]:
|
||||
"""Keep only visible, non-trashed, non-archived image (or video) assets.
|
||||
|
||||
Hidden assets are Live Photo motion clips; they play with their still
|
||||
(see ``live_photo_video_id``), never as videos of their own.
|
||||
"""
|
||||
allowed = {"IMAGE", "VIDEO"} if include_videos else {"IMAGE"}
|
||||
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":
|
||||
if str(it.get("type", "")).upper() not in allowed:
|
||||
continue
|
||||
if it.get("isTrashed") or it.get("isArchived"):
|
||||
continue
|
||||
if it.get("visibility") == "hidden" or it.get("isVisible") is False:
|
||||
continue
|
||||
if not it.get("id"):
|
||||
continue
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
def constrain_type(body: dict[str, Any], include_videos: bool = False) -> dict[str, Any]:
|
||||
"""Limit a search body to the asset types the slideshow can show.
|
||||
|
||||
Without videos the body is forced to ``IMAGE``. With videos, an explicit
|
||||
``IMAGE``/``VIDEO`` type (from a custom filter) is honoured; anything
|
||||
else is dropped so Immich returns both kinds.
|
||||
"""
|
||||
if not include_videos:
|
||||
body["type"] = "IMAGE"
|
||||
elif str(body.get("type", "")).upper() not in ("IMAGE", "VIDEO"):
|
||||
body.pop("type", None)
|
||||
return body
|
||||
|
||||
|
||||
def build_search_body(
|
||||
selection_type: str, selection_id: str | None, filter_body: dict | None
|
||||
selection_type: str,
|
||||
selection_id: str | None,
|
||||
filter_body: dict | None,
|
||||
include_videos: bool = False,
|
||||
) -> 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.
|
||||
Constrains to images (and videos when enabled). For ``search`` the
|
||||
user-supplied filter is used as a base. ``album``/``person`` add the id
|
||||
filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing.
|
||||
"""
|
||||
body: dict[str, Any] = {"type": "IMAGE"}
|
||||
body: dict[str, Any] = {}
|
||||
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:
|
||||
@@ -164,7 +286,7 @@ def build_search_body(
|
||||
elif selection_type == "favorites":
|
||||
body["isFavorite"] = True
|
||||
# ``all`` -> no extra filter (whole library).
|
||||
return body
|
||||
return constrain_type(body, include_videos)
|
||||
|
||||
|
||||
def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
|
||||
@@ -189,7 +311,9 @@ def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
|
||||
|
||||
|
||||
def build_composite_bodies(
|
||||
selection_id: str | None, filter_body: dict | None = None
|
||||
selection_id: str | None,
|
||||
filter_body: dict | None = None,
|
||||
include_videos: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build one ``search/metadata`` body per composite union member.
|
||||
|
||||
@@ -201,18 +325,16 @@ def build_composite_bodies(
|
||||
sel = parse_composite_selection(selection_id)
|
||||
bodies: list[dict[str, Any]] = []
|
||||
for aid in sel["albums"]:
|
||||
bodies.append({"type": "IMAGE", "albumIds": [aid]})
|
||||
bodies.append({"albumIds": [aid]})
|
||||
for pid in sel["people"]:
|
||||
bodies.append({"type": "IMAGE", "personIds": [pid]})
|
||||
bodies.append({"personIds": [pid]})
|
||||
if sel["favorites"]:
|
||||
bodies.append({"type": "IMAGE", "isFavorite": True})
|
||||
bodies.append({"isFavorite": True})
|
||||
if isinstance(filter_body, dict) and filter_body:
|
||||
member = dict(filter_body)
|
||||
member["type"] = "IMAGE"
|
||||
bodies.append(member)
|
||||
bodies.append(dict(filter_body))
|
||||
if not bodies:
|
||||
bodies.append({"type": "IMAGE"})
|
||||
return bodies
|
||||
bodies.append({})
|
||||
return [constrain_type(body, include_videos) for body in bodies]
|
||||
|
||||
|
||||
def parse_asset_exif(asset: Any) -> dict[str, Any]:
|
||||
@@ -241,6 +363,9 @@ def parse_asset_exif(asset: Any) -> dict[str, Any]:
|
||||
desc = exif.get("description")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
out["description"] = desc.strip()
|
||||
size = exif.get("fileSizeInByte")
|
||||
if isinstance(size, int) and not isinstance(size, bool) and size > 0:
|
||||
out["byte_size"] = size
|
||||
return out
|
||||
|
||||
|
||||
@@ -489,48 +614,58 @@ class ImmichClient:
|
||||
selection_type: str,
|
||||
selection_id: str | None = None,
|
||||
filter_body: dict | None = None,
|
||||
include_videos: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Collect image assets for a selection.
|
||||
"""Collect image (and optionally video) 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"}
|
||||
body: dict[str, Any] = {"size": min(_PAGE_SIZE, 250)}
|
||||
if isinstance(filter_body, dict):
|
||||
merged = dict(filter_body)
|
||||
merged.update(body)
|
||||
body = merged
|
||||
constrain_type(body, include_videos)
|
||||
payload = await self._post("/api/search/random", body)
|
||||
return parse_random(payload)
|
||||
return parse_random(payload, include_videos)
|
||||
|
||||
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)
|
||||
bodies = [
|
||||
constrain_type({"personIds": [p]}, include_videos) for p in ids
|
||||
]
|
||||
return await self._collect_union(bodies, include_videos)
|
||||
|
||||
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)
|
||||
bodies = [
|
||||
constrain_type({"albumIds": [a]}, include_videos) for a in ids
|
||||
]
|
||||
return await self._collect_union(bodies, include_videos)
|
||||
|
||||
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)
|
||||
bodies = build_composite_bodies(selection_id, filter_body, include_videos)
|
||||
return await self._collect_union(bodies, include_videos)
|
||||
|
||||
base = build_search_body(selection_type, selection_id, filter_body)
|
||||
return await self._collect_metadata(base)
|
||||
base = build_search_body(
|
||||
selection_type, selection_id, filter_body, include_videos
|
||||
)
|
||||
return await self._collect_metadata(base, include_videos)
|
||||
|
||||
async def _collect_metadata(self, base: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
async def _collect_metadata(
|
||||
self, base: dict[str, Any], include_videos: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Page through ``search/metadata`` for a prebuilt body."""
|
||||
collected: list[dict[str, Any]] = []
|
||||
page: int | None = 1
|
||||
@@ -539,13 +674,13 @@ class ImmichClient:
|
||||
body["size"] = _PAGE_SIZE
|
||||
body["page"] = page
|
||||
payload = await self._post("/api/search/metadata", body)
|
||||
items, next_page = parse_search_page(payload)
|
||||
items, next_page = parse_search_page(payload, include_videos)
|
||||
collected.extend(items)
|
||||
page = next_page
|
||||
return collected
|
||||
|
||||
async def _collect_union(
|
||||
self, bodies: list[dict[str, Any]]
|
||||
self, bodies: list[dict[str, Any]], include_videos: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Union several ``search/metadata`` queries (OR), deduped by asset id.
|
||||
|
||||
@@ -558,7 +693,7 @@ class ImmichClient:
|
||||
for body in bodies:
|
||||
if len(out) >= _MAX_ASSETS:
|
||||
break
|
||||
items = await self._collect_metadata(body)
|
||||
items = await self._collect_metadata(body, include_videos)
|
||||
for it in items:
|
||||
aid = it.get("id")
|
||||
if aid and aid not in seen:
|
||||
@@ -569,6 +704,24 @@ class ImmichClient:
|
||||
async def async_get_asset(self, asset_id: str) -> dict[str, Any]:
|
||||
return await self._get(f"/api/assets/{asset_id}")
|
||||
|
||||
async def async_probe_video(self, asset_id: str) -> tuple[str | None, int | None]:
|
||||
"""Return the playback stream's ``(content_type, total_bytes)``.
|
||||
|
||||
Requests a single byte; the body is never read, so this stays cheap
|
||||
even against a server that ignores ``Range``.
|
||||
"""
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with async_timeout.timeout(_TIMEOUT):
|
||||
async with session.get(
|
||||
build_video_url(self.base_url, asset_id),
|
||||
headers={**self.image_headers, "Range": "bytes=0-0"},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return (
|
||||
resp.headers.get("Content-Type"),
|
||||
stream_total_bytes(resp.status, resp.headers),
|
||||
)
|
||||
|
||||
async def async_get_faces(self, asset_id: str) -> list[dict[str, Any]]:
|
||||
data = await self._get(f"/api/faces?id={asset_id}")
|
||||
if not isinstance(data, list):
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["PyNaCl>=1.5.0"],
|
||||
"version": "1.15.0"
|
||||
"version": "1.15.1"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DOMAIN, MAX_CUSTOM_LOOKBACK_DAYS
|
||||
from .const import DOMAIN, MAX_CUSTOM_LOOKBACK_DAYS, MAX_VIDEO_MAX_SECONDS, PROVIDER_IMMICH
|
||||
from .coordinator import AlbumCoordinator
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -15,18 +15,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SlideIntervalNumber(entry, store),
|
||||
RefreshHoursNumber(entry, store, coordinator),
|
||||
PairDividerWidthNumber(entry, store),
|
||||
PairMinGapPercentNumber(entry, store),
|
||||
NavigationBufferSizeNumber(entry, store),
|
||||
ImageCacheMbNumber(entry, store),
|
||||
CustomLookbackDaysNumber(entry, store),
|
||||
ShuffleAgeBiasNumber(entry, store),
|
||||
]
|
||||
)
|
||||
entities = [
|
||||
SlideIntervalNumber(entry, store),
|
||||
RefreshHoursNumber(entry, store, coordinator),
|
||||
PairDividerWidthNumber(entry, store),
|
||||
PairMinGapPercentNumber(entry, store),
|
||||
NavigationBufferSizeNumber(entry, store),
|
||||
ImageCacheMbNumber(entry, store),
|
||||
CustomLookbackDaysNumber(entry, store),
|
||||
ShuffleAgeBiasNumber(entry, store),
|
||||
]
|
||||
# Only providers that can surface videos get the video-length setting.
|
||||
if getattr(coordinator, "provider", None) == PROVIDER_IMMICH:
|
||||
entities.append(VideoMaxSecondsNumber(entry, store))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class _BaseNumber(NumberEntity, RestoreEntity):
|
||||
@@ -111,6 +113,36 @@ class ShuffleAgeBiasNumber(_BaseNumber):
|
||||
return
|
||||
|
||||
|
||||
class VideoMaxSecondsNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:video-outline"
|
||||
_attr_native_min_value = 5
|
||||
_attr_native_max_value = MAX_VIDEO_MAX_SECONDS
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = "s"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_video_max_seconds"
|
||||
self._attr_name = "Max video length"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.video_max_seconds)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.video_max_seconds = max(5, min(MAX_VIDEO_MAX_SECONDS, int(value)))
|
||||
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 not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
await self.async_set_native_value(float(old.state))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return
|
||||
|
||||
|
||||
class SlideIntervalNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:timer-outline"
|
||||
_attr_native_min_value = 3
|
||||
|
||||
@@ -24,6 +24,7 @@ from .const import (
|
||||
DEFAULT_DATE_FILTER,
|
||||
DEFAULT_CUSTOM_LOOKBACK_DAYS,
|
||||
DEFAULT_MISSING_DATE_MODE,
|
||||
DEFAULT_VIDEO_MAX_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +46,7 @@ class SlideshowStore:
|
||||
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
|
||||
navigation_buffer_size: int = DEFAULT_NAVIGATION_BUFFER_SIZE
|
||||
max_resolution: str = DEFAULT_MAX_RESOLUTION
|
||||
video_max_seconds: int = DEFAULT_VIDEO_MAX_SECONDS
|
||||
|
||||
# Date filter mode (preset windows like this_year / on_this_day).
|
||||
date_filter: str = DEFAULT_DATE_FILTER
|
||||
@@ -56,6 +58,9 @@ class SlideshowStore:
|
||||
# Pause toggle - when True, the slideshow holds on the current frame.
|
||||
paused: bool = False
|
||||
|
||||
# Hold the slideshow while no dashboard card or live stream is showing it.
|
||||
pause_when_unwatched: bool = False
|
||||
|
||||
# Draw face boxes, a centre crosshair and a crop summary on each slide.
|
||||
face_debug: bool = False
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
"people": "People",
|
||||
"favorites": "Include favorites",
|
||||
"immich_filter": "Extra search filter (JSON, optional)",
|
||||
"immich_image_size": "Image quality"
|
||||
"immich_image_size": "Image quality",
|
||||
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
|
||||
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)"
|
||||
}
|
||||
},
|
||||
"photoprism": {
|
||||
@@ -225,6 +227,8 @@
|
||||
"favorites": "Include favorites",
|
||||
"immich_filter": "Extra search filter (JSON, optional)",
|
||||
"immich_image_size": "Image quality",
|
||||
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
|
||||
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)",
|
||||
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap",
|
||||
"hide_home_country": "Hide the country for places in your home country"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ from .store import SlideshowStore
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
async_add_entities([PauseSwitch(entry, store), CropDebugSwitch(entry, store)])
|
||||
async_add_entities([
|
||||
PauseSwitch(entry, store),
|
||||
PauseWhenUnwatchedSwitch(entry, store),
|
||||
CropDebugSwitch(entry, store),
|
||||
])
|
||||
|
||||
|
||||
class PauseSwitch(SwitchEntity, RestoreEntity):
|
||||
@@ -56,6 +60,49 @@ class PauseSwitch(SwitchEntity, RestoreEntity):
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class PauseWhenUnwatchedSwitch(SwitchEntity, RestoreEntity):
|
||||
"""Hold the slideshow while no card or live stream is showing it."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_icon = "mdi:eye-off-outline"
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
self._attr_unique_id = f"{entry.entry_id}_pause_when_unwatched"
|
||||
self._attr_name = "Pause when not viewed"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return bool(self.store.pause_when_unwatched)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
self.store.pause_when_unwatched = True
|
||||
self.store.notify()
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
self.store.pause_when_unwatched = False
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
old = await self.async_get_last_state()
|
||||
if old is not None and old.state in ("on", "off"):
|
||||
self.store.pause_when_unwatched = old.state == "on"
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class CropDebugSwitch(SwitchEntity, RestoreEntity):
|
||||
"""Overlay face boxes, a centre crosshair and a crop summary on slides."""
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
"people": "People",
|
||||
"favorites": "Include favorites",
|
||||
"immich_filter": "Extra search filter (JSON, optional)",
|
||||
"immich_image_size": "Image quality"
|
||||
"immich_image_size": "Image quality",
|
||||
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
|
||||
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)"
|
||||
}
|
||||
},
|
||||
"photoprism": {
|
||||
@@ -225,6 +227,8 @@
|
||||
"favorites": "Include favorites",
|
||||
"immich_filter": "Extra search filter (JSON, optional)",
|
||||
"immich_image_size": "Image quality",
|
||||
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
|
||||
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)",
|
||||
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap",
|
||||
"hide_home_country": "Hide the country for places in your home country"
|
||||
}
|
||||
|
||||
@@ -24,9 +24,16 @@
|
||||
* background: '' # CSS color shown behind contained images.
|
||||
* # Empty inherits theme card background.
|
||||
* tap_action: none # none | more-info
|
||||
* videos: true # play video slides (muted, looping) and
|
||||
* # Live Photo motion over the camera's still;
|
||||
* # false shows only the still.
|
||||
* video_audio: off # off | muted | on. ``muted`` starts videos
|
||||
* # muted with a speaker button to unmute;
|
||||
* # ``on`` plays with sound when the browser
|
||||
* # allows it, falling back to that button.
|
||||
*/
|
||||
|
||||
const VERSION = "1.15.0";
|
||||
const VERSION = "1.15.1";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
@@ -46,12 +53,31 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
|
||||
|
||||
const FIT_MODES = new Set(["auto", "cover", "contain"]);
|
||||
|
||||
// While on screen, the card tells the integration it is being viewed so
|
||||
// "Pause when not viewed" can hold the slideshow when nobody is looking.
|
||||
// Matches VIEWER_HEARTBEAT_SECONDS in const.py.
|
||||
const VIEWER_HEARTBEAT_MS = 20000;
|
||||
|
||||
const PHOTO_CONTROL_OPTIONS = [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "on_demand", label: "On demand" },
|
||||
{ value: "always", label: "Always" },
|
||||
];
|
||||
|
||||
const VIDEO_AUDIO_OPTIONS = [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "muted", label: "Muted, tap to unmute" },
|
||||
{ value: "on", label: "On" },
|
||||
];
|
||||
|
||||
/** ``true``/``false`` from earlier versions mean ``on``/``off``. */
|
||||
function normalizeVideoAudio(value) {
|
||||
if (value === true) return "on";
|
||||
if (value == null || value === false) return "off";
|
||||
if (VIDEO_AUDIO_OPTIONS.some((option) => option.value === value)) return value;
|
||||
throw new Error(`album-slideshow-card: unknown video_audio mode '${value}'`);
|
||||
}
|
||||
|
||||
function normalizePhotoControls(value) {
|
||||
if (value == null || value === false) return "off";
|
||||
if (value === true) return "always";
|
||||
@@ -66,8 +92,27 @@ const CAMERA_CAPTION_FIELDS = [
|
||||
"camera_make", "camera_model", "focal_length_mm", "aperture_f_number",
|
||||
"iso", "exposure_time_seconds",
|
||||
];
|
||||
const CAPTION_FIELDS = ["date", "location", "description", "camera", ...CAMERA_CAPTION_FIELDS, "current_date", "current_time", "weather"];
|
||||
const CAPTION_FIELDS = [
|
||||
"date", "location", "description", "camera", ...CAMERA_CAPTION_FIELDS,
|
||||
"filename", "path", "video_duration", "video_version", "current_date", "current_time", "weather",
|
||||
];
|
||||
const LIVE_CAPTION_FIELDS = new Set(["current_date", "current_time", "weather"]);
|
||||
/** ``1:50`` / ``1:02:03`` for a clip length in seconds; empty otherwise. */
|
||||
function formatClipLength(seconds) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "";
|
||||
const total = Math.round(seconds);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = String(total % 60).padStart(2, "0");
|
||||
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`;
|
||||
}
|
||||
|
||||
/** First corner that no caption or toolbar uses, for the video sound button. */
|
||||
function soundButtonCorner(captionPositions, toolbarCorner) {
|
||||
const taken = new Set([...captionPositions, toolbarCorner]);
|
||||
return ["top-left", "top-right", "bottom-left", "bottom-right"].find((corner) => !taken.has(corner)) ?? "top-left";
|
||||
}
|
||||
|
||||
const CAPTION_POSITIONS = new Set([
|
||||
"top-left",
|
||||
"top-center",
|
||||
@@ -669,6 +714,8 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
// Empty/missing background means inherit theme.
|
||||
background: typeof config.background === "string" ? config.background : "",
|
||||
tap_action: config.tap_action === "more-info" ? "more-info" : "none",
|
||||
videos: config.videos !== false,
|
||||
video_audio: normalizeVideoAudio(config.video_audio),
|
||||
photo_controls: normalizePhotoControls(config.photo_controls),
|
||||
// Number of seconds the card freezes its visible slide after a
|
||||
// tap, so the more-info dialog can settle without the slideshow
|
||||
@@ -751,11 +798,16 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
}
|
||||
this._captionResizeObserver ??= new ResizeObserver(() => this._fitCaptions());
|
||||
this._captionResizeObserver.observe(this);
|
||||
this._onVisibilityChange ??= () => this._syncViewerHeartbeat();
|
||||
globalThis.document?.addEventListener?.("visibilitychange", this._onVisibilityChange);
|
||||
this._maybeSwap();
|
||||
this._syncViewerHeartbeat();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._captionResizeObserver?.disconnect();
|
||||
globalThis.document?.removeEventListener?.("visibilitychange", this._onVisibilityChange);
|
||||
this._syncViewerHeartbeat();
|
||||
clearTimeout(this._captionClockTimer);
|
||||
this._captionClockTimer = null;
|
||||
this._cancelControlNavigation();
|
||||
@@ -766,6 +818,7 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
this._loadGeneration += 1;
|
||||
this._lastFrameId = null;
|
||||
this._lastEntityPicture = null;
|
||||
this._stopVideo();
|
||||
}
|
||||
|
||||
set hass(hass) {
|
||||
@@ -773,6 +826,7 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
this._hass = hass;
|
||||
if (!this._rendered) return;
|
||||
this._maybeSwap();
|
||||
this._syncViewerHeartbeat();
|
||||
if (this._captionData && this._captionConfigs().some((cap) => cap.show.includes("weather") && cap.weather_entity &&
|
||||
(previous?.states?.[cap.weather_entity] !== hass.states?.[cap.weather_entity] || previous?.locale !== hass.locale ||
|
||||
(cap.weather_display === "icon_temperature" &&
|
||||
@@ -781,6 +835,26 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Check in while this card is on a visible page and Pause when not viewed is on. */
|
||||
_syncViewerHeartbeat() {
|
||||
const attrs = this._hass?.states?.[this._config?.entity]?.attributes;
|
||||
const entryId = attrs?.pause_when_unwatched ? attrs.entry_id : null;
|
||||
if (!this.isConnected || globalThis.document?.visibilityState === "hidden" || !entryId) {
|
||||
clearInterval(this._viewerTimer);
|
||||
this._viewerTimer = null;
|
||||
this._viewerEntryId = null;
|
||||
return;
|
||||
}
|
||||
if (this._viewerTimer && this._viewerEntryId === entryId) return;
|
||||
clearInterval(this._viewerTimer);
|
||||
this._viewerEntryId = entryId;
|
||||
// Errors (reconnecting, or an older integration) are retried next beat.
|
||||
const beat = () => this._hass?.callWS?.({ type: "album_slideshow/viewer_heartbeat", entry_id: entryId })
|
||||
?.catch?.(() => {});
|
||||
beat();
|
||||
this._viewerTimer = setInterval(beat, VIEWER_HEARTBEAT_MS);
|
||||
}
|
||||
|
||||
_resolvedFit(attrs) {
|
||||
// ``auto`` inherits from the camera's fill_mode attribute. The camera
|
||||
// exposes cover / contain / blur. ``blur`` is rendered as ``contain``
|
||||
@@ -815,6 +889,11 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
const stageBg = c.background
|
||||
? c.background
|
||||
: "var(--ha-card-background, var(--card-background-color, transparent))";
|
||||
const toolbarEdge = c.caption?.position.startsWith("top") ? "bottom" : "top";
|
||||
const [soundY, soundX] = soundButtonCorner(
|
||||
c.captions.map((cap) => cap.position),
|
||||
c.photo_controls === "off" ? null : `${toolbarEdge}-right`,
|
||||
).split("-");
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
@@ -870,6 +949,35 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
.layer.fit-cover { object-fit: cover; }
|
||||
.layer.fit-contain { object-fit: contain; }
|
||||
.layer.show { opacity: 1; }
|
||||
.video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity ${c.duration}ms ${c.easing};
|
||||
pointer-events: none;
|
||||
}
|
||||
.video.fit-contain { object-fit: contain; }
|
||||
.video.show { opacity: 1; }
|
||||
.sound {
|
||||
position: absolute;
|
||||
${soundY}: 8px;
|
||||
${soundX}: 8px;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sound[hidden] { display: none; }
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -911,7 +1019,7 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
max-width: 100%; max-height: 100%; min-width: 0; overflow: hidden;
|
||||
}
|
||||
.cap-line { font-weight: inherit; flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${c.caption?.position.startsWith("top") ? "bottom" : "top"}: 8px; z-index: 3; }
|
||||
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${toolbarEdge}: 8px; z-index: 3; }
|
||||
.cap-box.cap-shadow {
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.9),
|
||||
@@ -939,10 +1047,12 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
<img class="blur-bg" id="blur-b" alt="" />
|
||||
<img class="layer" id="a" alt="" />
|
||||
<img class="layer" id="b" alt="" />
|
||||
<video class="video" id="video" muted playsinline loop preload="auto" disablepictureinpicture aria-hidden="true"></video>
|
||||
<div class="captions" id="captions" aria-hidden="true"></div>
|
||||
<div class="placeholder" id="placeholder">Waiting for first frame...</div>
|
||||
</div>
|
||||
<div id="photo-controls" ${c.photo_controls === "always" ? "" : "hidden"}></div>
|
||||
<button class="sound" id="sound" type="button" hidden aria-label="Unmute video"><ha-icon icon="mdi:volume-off"></ha-icon></button>
|
||||
</ha-card>
|
||||
`;
|
||||
this._photoControls = new PhotoControls(
|
||||
@@ -950,6 +1060,11 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
() => this._controlsReveal?.activity(),
|
||||
(phase, entryId) => this._controlNavigation(phase, entryId),
|
||||
);
|
||||
this.shadowRoot.getElementById("sound").addEventListener("click", (event) => {
|
||||
// Never let the speaker button open more-info or reveal controls.
|
||||
event.stopPropagation();
|
||||
this._toggleVideoSound();
|
||||
});
|
||||
const card = this.shadowRoot.querySelector("ha-card");
|
||||
if (this._config.tap_action === "more-info") {
|
||||
card.addEventListener("click", () => this._fireMoreInfo());
|
||||
@@ -1077,6 +1192,7 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
}
|
||||
this._hiddenRevision = attrs.hidden_revision;
|
||||
this._refreshPhotoControls(attrs);
|
||||
this._syncVideoPaused(attrs);
|
||||
if (attrs.empty_reason || (Array.isArray(attrs.displayed_photo_ids) && !attrs.displayed_photo_ids.length)) {
|
||||
this._clearDisplayedPhotos();
|
||||
this._setPlaceholder(attrs.empty_reason === "all_hidden" ? "All photos hidden" : attrs.empty_reason ? "No matching photos" : "Preparing next photo...");
|
||||
@@ -1129,6 +1245,7 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
const captionData = this._config.caption
|
||||
? {
|
||||
caption_frames: attrs.caption_frames,
|
||||
current_path: attrs.current_path,
|
||||
pair_orientation: attrs.pair_orientation,
|
||||
captured_at_primary: attrs.captured_at_primary,
|
||||
captured_at: attrs.captured_at,
|
||||
@@ -1146,6 +1263,12 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
frameId,
|
||||
hiddenRevision: attrs.hidden_revision,
|
||||
entityId: this._config.entity,
|
||||
videoUrl:
|
||||
this._config.videos !== false && ["video", "live_photo"].includes(attrs.media_kind) && attrs.video_url
|
||||
? attrs.video_url
|
||||
: null,
|
||||
// A Live Photo's motion plays once, then fades back to its still.
|
||||
videoLoop: attrs.media_kind !== "live_photo",
|
||||
};
|
||||
this._loadAndSwap(url, fit, blurBackdrop, captionData, photoData);
|
||||
}
|
||||
@@ -1167,6 +1290,7 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
image.removeAttribute("src");
|
||||
image.classList.remove("show", "exit", "enter");
|
||||
}
|
||||
this._stopVideo();
|
||||
this.shadowRoot.getElementById("captions").replaceChildren();
|
||||
this._refreshPhotoControls(this._hass.states[this._config.entity]?.attributes || {});
|
||||
}
|
||||
@@ -1203,11 +1327,17 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
this._maybeSwap();
|
||||
return;
|
||||
}
|
||||
// A re-render of the same photo (new metadata, e.g. a face-aware crop)
|
||||
// should not look like a new slide.
|
||||
const samePhoto = photoData.photoIds.length > 0 &&
|
||||
photoData.photoIds.length === this._displayedPhotoIds.length &&
|
||||
photoData.photoIds.every((id, index) => id === this._displayedPhotoIds[index]);
|
||||
this._displayedPhotoIds = [...photoData.photoIds];
|
||||
this._displayedFrameId = photoData.frameId;
|
||||
this._photoEntryId = photoData.entryId;
|
||||
this._photoOrientation = photoData.orientation;
|
||||
this._performSwap(url, fit, blurBackdrop, captionData);
|
||||
this._performSwap(url, fit, blurBackdrop, captionData, samePhoto);
|
||||
this._startVideo(photoData.videoUrl, fit, generation, photoData.videoLoop !== false);
|
||||
if (this._navigationRequest && !this._navigationRequest.pending && photoData.frameId !== this._navigationRequest.frameId) {
|
||||
this._cancelControlNavigation();
|
||||
}
|
||||
@@ -1219,7 +1349,105 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
next.src = url;
|
||||
}
|
||||
|
||||
_performSwap(url, fit, blurBackdrop, captionData) {
|
||||
/** Play a video slide over its poster frame (already swapped in). The
|
||||
* clip fades in once it is actually playing; if it can't load or the
|
||||
* browser blocks autoplay, the poster simply stays up. */
|
||||
_startVideo(videoUrl, fit, generation, loop = true) {
|
||||
const video = this.shadowRoot?.getElementById("video");
|
||||
if (!video) return;
|
||||
// A re-render of the same clip (metadata refreshes do this) keeps the
|
||||
// same URL; let it play on rather than restarting it.
|
||||
const continuing = Boolean(videoUrl) && video.getAttribute("src") === videoUrl;
|
||||
if (!continuing) this._stopVideo();
|
||||
if (!videoUrl) return;
|
||||
const attrs = this._hass?.states[this._config.entity]?.attributes || {};
|
||||
const reveal = () => {
|
||||
if (generation === this._loadGeneration) video.classList.add("show");
|
||||
};
|
||||
video.classList.toggle("fit-contain", fit === "contain");
|
||||
video.onerror = () => video.classList.remove("show");
|
||||
// Fade in once playing, or, while paused, once the first frame is ready.
|
||||
video.onplaying = reveal;
|
||||
video.onloadeddata = attrs.paused ? reveal : null;
|
||||
video.onended = loop ? null : () => {
|
||||
if (generation === this._loadGeneration) video.classList.remove("show");
|
||||
};
|
||||
video.onvolumechange = () => this._updateSoundButton();
|
||||
if (continuing) return;
|
||||
// Browsers only autoplay muted unless the page has been interacted
|
||||
// with; ``_playVideo`` falls back to muted. Set the properties as well
|
||||
// as the attributes, which some browsers read only at parse time.
|
||||
video.muted = !this._wantsVideoSound();
|
||||
video.loop = loop;
|
||||
video.playsInline = true;
|
||||
video.src = videoUrl;
|
||||
this._videoPaused = Boolean(attrs.paused);
|
||||
this._updateSoundButton();
|
||||
if (!attrs.paused) this._playVideo(video);
|
||||
}
|
||||
|
||||
/** Sound for the next video: the speaker button's last choice this
|
||||
* session, else the configured mode. */
|
||||
_wantsVideoSound() {
|
||||
if (this._config.video_audio === "off") return false;
|
||||
return this._videoSoundChoice ?? this._config.video_audio === "on";
|
||||
}
|
||||
|
||||
/** Play, falling back to muted when the browser blocks sound. */
|
||||
_playVideo(video) {
|
||||
video.play()?.catch((err) => {
|
||||
if (video.muted || err?.name !== "NotAllowedError") return;
|
||||
video.muted = true;
|
||||
this._updateSoundButton();
|
||||
video.play()?.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
_toggleVideoSound() {
|
||||
const video = this.shadowRoot?.getElementById("video");
|
||||
if (!video || !video.getAttribute("src")) return;
|
||||
// The tap itself lets the browser allow sound from now on.
|
||||
video.muted = !video.muted;
|
||||
this._videoSoundChoice = !video.muted;
|
||||
if (!video.muted && video.paused && !this._videoPaused) this._playVideo(video);
|
||||
this._updateSoundButton();
|
||||
}
|
||||
|
||||
_updateSoundButton() {
|
||||
const button = this.shadowRoot?.getElementById("sound");
|
||||
if (!button) return;
|
||||
const video = this.shadowRoot.getElementById("video");
|
||||
const active = this._config.video_audio !== "off" && Boolean(video?.getAttribute("src"));
|
||||
button.hidden = !active;
|
||||
if (!active) return;
|
||||
button.setAttribute("aria-label", video.muted ? "Unmute video" : "Mute video");
|
||||
button.querySelector("ha-icon")?.setAttribute("icon", video.muted ? "mdi:volume-off" : "mdi:volume-high");
|
||||
}
|
||||
|
||||
/** Stop any playing clip and drop its source so it stops downloading. */
|
||||
_stopVideo() {
|
||||
const video = this.shadowRoot?.getElementById("video");
|
||||
if (!video || !video.getAttribute("src")) return;
|
||||
video.classList.remove("show");
|
||||
video.onplaying = video.onerror = video.onloadeddata = video.onended = video.onvolumechange = null;
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
this._updateSoundButton();
|
||||
}
|
||||
|
||||
/** Follow the slideshow's pause switch. Acts only when it changes, since
|
||||
* ``hass`` updates arrive for every entity in Home Assistant. */
|
||||
_syncVideoPaused(attrs) {
|
||||
const video = this.shadowRoot?.getElementById("video");
|
||||
const paused = Boolean(attrs.paused);
|
||||
if (!video || !video.getAttribute("src") || paused === this._videoPaused) return;
|
||||
this._videoPaused = paused;
|
||||
if (paused) video.pause();
|
||||
else this._playVideo(video);
|
||||
}
|
||||
|
||||
_performSwap(url, fit, blurBackdrop, captionData, samePhoto = false) {
|
||||
const root = this.shadowRoot;
|
||||
const placeholder = root.getElementById("placeholder");
|
||||
if (placeholder) placeholder.remove();
|
||||
@@ -1239,7 +1467,7 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
el.classList.add(fit === "contain" ? "fit-contain" : "fit-cover");
|
||||
}
|
||||
|
||||
const transition = this._pickTransition();
|
||||
const transition = samePhoto ? "fade" : this._pickTransition();
|
||||
const transitionClass = `t-${transition}`;
|
||||
|
||||
// First frame: no animation, just place the image and reveal.
|
||||
@@ -1422,7 +1650,12 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
* the flat attributes for older integration versions. */
|
||||
_buildCaptionFrames(data) {
|
||||
const cf = data.caption_frames;
|
||||
if (Array.isArray(cf) && cf.length) return cf;
|
||||
if (Array.isArray(cf) && cf.length) {
|
||||
// File paths come separately (kept out of the recorder): a string, or
|
||||
// one per image for a pair.
|
||||
const paths = Array.isArray(data.current_path) ? data.current_path : [data.current_path];
|
||||
return cf.map((frame, index) => (paths[index] == null ? frame : { ...frame, path: paths[index] }));
|
||||
}
|
||||
let captured = data.captured_at_primary;
|
||||
if (captured == null) {
|
||||
captured = Array.isArray(data.captured_at)
|
||||
@@ -1458,6 +1691,14 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
if (frame.location) lines.push(String(frame.location));
|
||||
} else if (field === "description") {
|
||||
if (frame.description) lines.push(String(frame.description));
|
||||
} else if (field === "filename" || field === "path") {
|
||||
if (typeof frame[field] === "string" && frame[field].trim()) lines.push(frame[field].trim());
|
||||
} else if (field === "video_duration") {
|
||||
const length = formatClipLength(frame.video_duration);
|
||||
if (length) lines.push(length);
|
||||
} else if (field === "video_version") {
|
||||
const version = this._videoVersionCaption(frame);
|
||||
if (version) lines.push(version);
|
||||
} else if (field === "camera") {
|
||||
const make = typeof frame.camera_make === "string" ? frame.camera_make.trim() : "";
|
||||
const model = typeof frame.camera_model === "string" ? frame.camera_model.trim() : "";
|
||||
@@ -1481,6 +1722,17 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** ``Transcoded video · 3.1 Mbps`` for a video slide; empty for photos. */
|
||||
_videoVersionCaption(frame) {
|
||||
const version = { original: "Original video", transcoded: "Transcoded video" }[frame.video_version];
|
||||
const bitrate = frame.video_bitrate_mbps;
|
||||
const rate = typeof bitrate === "number" && Number.isFinite(bitrate) && bitrate > 0
|
||||
? `${new Intl.NumberFormat(this._locale(), { maximumFractionDigits: 1 }).format(bitrate)} Mbps`
|
||||
: "";
|
||||
if (!version && !rate) return "";
|
||||
return [version || "Video", rate].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
_weatherCaption(cap) {
|
||||
if (typeof cap.weather_entity !== "string" || !/^(weather|sensor)\.[a-z0-9_]+$/.test(cap.weather_entity)) return "";
|
||||
const entity = this._hass?.states?.[cap.weather_entity];
|
||||
@@ -1805,6 +2057,10 @@ const CAPTION_SHOW_OPTIONS = [
|
||||
{ value: "aperture_f_number", label: "Aperture" },
|
||||
{ value: "iso", label: "ISO" },
|
||||
{ value: "exposure_time_seconds", label: "Exposure time" },
|
||||
{ value: "filename", label: "File name" },
|
||||
{ value: "path", label: "File path" },
|
||||
{ value: "video_duration", label: "Video length" },
|
||||
{ value: "video_version", label: "Video version (original or transcoded)" },
|
||||
{ value: "current_date", label: "Today's date" },
|
||||
{ value: "current_time", label: "Current time" },
|
||||
{ value: "weather", label: "Weather" },
|
||||
@@ -1890,6 +2146,7 @@ const CAPTION_DEFAULTS = {
|
||||
// service call. Buttons are handled separately (see LIVE_ACTIONS).
|
||||
const LIVE_FIELDS = [
|
||||
"paused",
|
||||
"pause_when_unwatched",
|
||||
"date_filter",
|
||||
"custom_lookback_days",
|
||||
"shuffle_age_bias",
|
||||
@@ -1904,6 +2161,7 @@ const LIVE_FIELDS = [
|
||||
|
||||
const LIVE_SUFFIX = {
|
||||
paused: "_paused",
|
||||
pause_when_unwatched: "_pause_when_unwatched",
|
||||
date_filter: "_date_filter",
|
||||
custom_lookback_days: "_custom_lookback_days",
|
||||
shuffle_age_bias: "_shuffle_age_bias",
|
||||
@@ -1921,6 +2179,7 @@ const LIVE_SUFFIX = {
|
||||
|
||||
const LIVE_LABELS = {
|
||||
live_paused: "Pause slideshow",
|
||||
live_pause_when_unwatched: "Pause when not viewed",
|
||||
live_date_filter: "Date filter",
|
||||
live_custom_lookback_days: "Custom lookback (days)",
|
||||
live_shuffle_age_bias: "Shuffle age bias (older - / newer +)",
|
||||
@@ -2080,8 +2339,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
_liveSchema() {
|
||||
const s = this._siblings || {};
|
||||
const items = [];
|
||||
if (s.paused) {
|
||||
items.push({ name: "live_paused", selector: { boolean: {} } });
|
||||
for (const field of ["paused", "pause_when_unwatched"]) {
|
||||
if (s[field]) items.push({ name: `live_${field}`, selector: { boolean: {} } });
|
||||
}
|
||||
for (const [field, id] of [
|
||||
["date_filter", s.date_filter],
|
||||
@@ -2221,6 +2480,11 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
selector: { select: { mode: "dropdown", options: FIT_OPTIONS } },
|
||||
},
|
||||
{ name: "background", selector: { text: {} } },
|
||||
{ name: "videos", selector: { boolean: {} } },
|
||||
{
|
||||
name: "video_audio",
|
||||
selector: { select: { mode: "dropdown", options: VIDEO_AUDIO_OPTIONS } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2277,6 +2541,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
fit: c.fit || DEFAULTS.fit,
|
||||
background: c.background || "",
|
||||
tap_action: c.tap_action || DEFAULTS.tap_action,
|
||||
videos: c.videos !== false,
|
||||
video_audio: normalizeVideoAudio(c.video_audio),
|
||||
tap_pause_seconds:
|
||||
c.tap_pause_seconds != null
|
||||
? Number(c.tap_pause_seconds)
|
||||
@@ -2634,9 +2900,11 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
const out = {};
|
||||
if (!s || !this._hass) return out;
|
||||
const st = (id) => (id ? this._hass.states[id] : null);
|
||||
if (s.paused) {
|
||||
const e = st(s.paused);
|
||||
out.live_paused = !!e && e.state === "on";
|
||||
for (const f of ["paused", "pause_when_unwatched"]) {
|
||||
if (s[f]) {
|
||||
const e = st(s[f]);
|
||||
out[`live_${f}`] = !!e && e.state === "on";
|
||||
}
|
||||
}
|
||||
for (const f of ["date_filter", "missing_date_mode", "portrait_mode", "order_mode"]) {
|
||||
if (s[f]) {
|
||||
@@ -2668,6 +2936,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
background: "Background (optional)",
|
||||
tap_action: "Tap action",
|
||||
tap_pause_seconds: "Tap pause (seconds)",
|
||||
videos: "Play videos",
|
||||
video_audio: "Video sound",
|
||||
photo_controls: "Photo controls",
|
||||
caption_enabled: "Show caption overlay",
|
||||
caption_show: "Show",
|
||||
@@ -2693,6 +2963,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
const helpers = {
|
||||
background: "Leave blank to inherit the dashboard theme.",
|
||||
transition: "Random picks a different effect each slide.",
|
||||
videos: "Videos and Live Photo motion. Off shows a still frame instead. Must also be enabled in the integration.",
|
||||
video_audio: "Muted starts videos silent with a speaker button. On plays sound when the browser allows it, otherwise shows the button.",
|
||||
tap_pause_seconds:
|
||||
"How long the card freezes its slide after a tap. 0 disables it.",
|
||||
caption_date_format:
|
||||
@@ -2707,6 +2979,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
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_pause_when_unwatched:
|
||||
"Hold the slideshow when no dashboard card or camera view has shown it for a minute; it resumes as soon as one opens.",
|
||||
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.",
|
||||
};
|
||||
@@ -2899,7 +3173,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
if (!s || !hass) return;
|
||||
const id = s[field];
|
||||
if (!id) return;
|
||||
if (field === "paused") {
|
||||
if (field === "paused" || field === "pause_when_unwatched") {
|
||||
hass.callService("switch", value ? "turn_on" : "turn_off", {
|
||||
entity_id: id,
|
||||
});
|
||||
@@ -2982,6 +3256,10 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
const ta = data.tap_action || DEFAULTS.tap_action;
|
||||
if (ta !== DEFAULTS.tap_action) n.tap_action = ta;
|
||||
|
||||
if (data.videos === false) n.videos = false;
|
||||
const audio = normalizeVideoAudio(data.video_audio);
|
||||
if (audio !== "off") n.video_audio = audio;
|
||||
|
||||
const tps = Number(data.tap_pause_seconds);
|
||||
if (!isNaN(tps) && tps !== DEFAULTS.tap_pause_seconds) {
|
||||
n.tap_pause_seconds = tps;
|
||||
|
||||
Reference in New Issue
Block a user