diff --git a/.ha_run.lock b/.ha_run.lock index 4c76df85..da0465a2 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 73, "version": 1, "ha_version": "2026.10.1", "start_ts": 1792001005.2570755} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.10.1", "start_ts": 1792153632.14175} \ No newline at end of file diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources index 9ee90105..db307fa4 100644 --- a/.storage/lovelace_resources +++ b/.storage/lovelace_resources @@ -81,117 +81,117 @@ }, { "id": "88b90f2b1d6b45f0b44bee786ee1a525", - "url": "/taskmate/taskmate-attr-resolver.js?v=6.3.0", + "url": "/taskmate/taskmate-attr-resolver.js?v=6.4.0", "type": "module" }, { "id": "0796c92931dd458e81dc9c2677248274", - "url": "/taskmate/taskmate-localize.js?v=6.3.0", + "url": "/taskmate/taskmate-localize.js?v=6.4.0", "type": "module" }, { "id": "d2e281920ac7409583eafff1f1db69bd", - "url": "/taskmate/taskmate-design.js?v=6.3.0", + "url": "/taskmate/taskmate-design.js?v=6.4.0", "type": "module" }, { "id": "9eae9632f0734480be5edd784b030673", - "url": "/taskmate/taskmate-badges-card.js?v=6.3.0", + "url": "/taskmate/taskmate-badges-card.js?v=6.4.0", "type": "module" }, { "id": "5df49cd8a4eb420497b5df22cb31362c", - "url": "/taskmate/taskmate-child-card.js?v=6.3.0", + "url": "/taskmate/taskmate-child-card.js?v=6.4.0", "type": "module" }, { "id": "b44c87d0d74f468cb96c5eabc340f9ee", - "url": "/taskmate/taskmate-rewards-card.js?v=6.3.0", + "url": "/taskmate/taskmate-rewards-card.js?v=6.4.0", "type": "module" }, { "id": "787a96f9c30548708165a48457c99feb", - "url": "/taskmate/taskmate-approvals-card.js?v=6.3.0", + "url": "/taskmate/taskmate-approvals-card.js?v=6.4.0", "type": "module" }, { "id": "84031597de424ad68e7c2bc66d06b72e", - "url": "/taskmate/taskmate-points-card.js?v=6.3.0", + "url": "/taskmate/taskmate-points-card.js?v=6.4.0", "type": "module" }, { "id": "02613225844746b699bfbfac9642eca2", - "url": "/taskmate/taskmate-reorder-card.js?v=6.3.0", + "url": "/taskmate/taskmate-reorder-card.js?v=6.4.0", "type": "module" }, { "id": "7a06a07e17ba4b31b712bc91b77360b4", - "url": "/taskmate/taskmate-overview-card.js?v=6.3.0", + "url": "/taskmate/taskmate-overview-card.js?v=6.4.0", "type": "module" }, { "id": "08b42b53b33b4fa88db932455cf8d5aa", - "url": "/taskmate/taskmate-activity-card.js?v=6.3.0", + "url": "/taskmate/taskmate-activity-card.js?v=6.4.0", "type": "module" }, { "id": "15907eff51784acdba88ff694b574509", - "url": "/taskmate/taskmate-streak-card.js?v=6.3.0", + "url": "/taskmate/taskmate-streak-card.js?v=6.4.0", "type": "module" }, { "id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39", - "url": "/taskmate/taskmate-weekly-card.js?v=6.3.0", + "url": "/taskmate/taskmate-weekly-card.js?v=6.4.0", "type": "module" }, { "id": "289ce5943f5a4972a593697db4c96ece", - "url": "/taskmate/taskmate-graph-card.js?v=6.3.0", + "url": "/taskmate/taskmate-graph-card.js?v=6.4.0", "type": "module" }, { "id": "d2904acee4c74a398dcc7b996c23e7e7", - "url": "/taskmate/taskmate-reward-progress-card.js?v=6.3.0", + "url": "/taskmate/taskmate-reward-progress-card.js?v=6.4.0", "type": "module" }, { "id": "46d378736fd54a26be253f2dfb27202e", - "url": "/taskmate/taskmate-leaderboard-card.js?v=6.3.0", + "url": "/taskmate/taskmate-leaderboard-card.js?v=6.4.0", "type": "module" }, { "id": "db11ae36457842fa826c7991e1d9c6f4", - "url": "/taskmate/taskmate-parent-dashboard-card.js?v=6.3.0", + "url": "/taskmate/taskmate-parent-dashboard-card.js?v=6.4.0", "type": "module" }, { "id": "ce85f301b59a412e9abfe5ffd51cb9a2", - "url": "/taskmate/taskmate-penalties-card.js?v=6.3.0", + "url": "/taskmate/taskmate-penalties-card.js?v=6.4.0", "type": "module" }, { "id": "1959e1629de2422daa8adaf87069c288", - "url": "/taskmate/taskmate-bonuses-card.js?v=6.3.0", + "url": "/taskmate/taskmate-bonuses-card.js?v=6.4.0", "type": "module" }, { "id": "2548524b269c4959bc7e0e967df943af", - "url": "/taskmate/taskmate-points-display-card.js?v=6.3.0", + "url": "/taskmate/taskmate-points-display-card.js?v=6.4.0", "type": "module" }, { "id": "36ada49368db4187a283fbbbef45454f", - "url": "/taskmate/taskmate-calendar-card.js?v=6.3.0", + "url": "/taskmate/taskmate-calendar-card.js?v=6.4.0", "type": "module" }, { "id": "67c32687acd2497b90030ef141f4b6db", - "url": "/taskmate/taskmate-photo-gallery-card.js?v=6.3.0", + "url": "/taskmate/taskmate-photo-gallery-card.js?v=6.4.0", "type": "module" }, { "id": "6f0c8348a91f4b74a9b846ab3f62621f", - "url": "/taskmate/taskmate-family-goal-card.js?v=6.3.0", + "url": "/taskmate/taskmate-family-goal-card.js?v=6.4.0", "type": "module" }, { @@ -276,7 +276,7 @@ }, { "id": "73dbd2de03de4530b451a55bfe6c46cf", - "url": "/taskmate/taskmate-routine-card.js?v=6.3.0", + "url": "/taskmate/taskmate-routine-card.js?v=6.4.0", "type": "module" }, { @@ -291,7 +291,7 @@ }, { "id": "1a4939ce1d2b47f89502176d3c5ac6e7", - "url": "/taskmate/taskmate-sounds.js?v=6.3.0", + "url": "/taskmate/taskmate-sounds.js?v=6.4.0", "type": "module" }, { @@ -306,32 +306,32 @@ }, { "id": "2cc757922cd142b69ce8844afc0e8410", - "url": "/taskmate/taskmate-wishlist-card.js?v=6.3.0", + "url": "/taskmate/taskmate-wishlist-card.js?v=6.4.0", "type": "module" }, { "id": "107319877a72413fbbea40b7f8adb06c", - "url": "/taskmate/taskmate-kiosk-card.js?v=6.3.0", + "url": "/taskmate/taskmate-kiosk-card.js?v=6.4.0", "type": "module" }, { "id": "5a2899c29e9046c78f060580ba113d9f", - "url": "/taskmate/taskmate-bounty-card.js?v=6.3.0", + "url": "/taskmate/taskmate-bounty-card.js?v=6.4.0", "type": "module" }, { "id": "a1aae7e334ba4c2d8dd3849178f780f6", - "url": "/taskmate/taskmate-auction-card.js?v=6.3.0", + "url": "/taskmate/taskmate-auction-card.js?v=6.4.0", "type": "module" }, { "id": "464b73198b7d4fe480c09bbf386a4e5d", - "url": "/taskmate/taskmate-recap-card.js?v=6.3.0", + "url": "/taskmate/taskmate-recap-card.js?v=6.4.0", "type": "module" }, { "id": "35f1f324116d4b589f3087065519360b", - "url": "/taskmate/taskmate-chore-board-card.js?v=6.3.0", + "url": "/taskmate/taskmate-chore-board-card.js?v=6.4.0", "type": "module" }, { @@ -350,13 +350,18 @@ "type": "module" }, { - "id": "a1a494805ac84a78a6dba82b528ba900", - "url": "/album_slideshow_static/album-slideshow-card.js?v=1.15.0", + "id": "9eef7a5619294eefa48bda26787ad279", + "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", "type": "module" }, { - "id": "ec621063df614ae38f26b7c0c97d16fd", - "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", + "id": "35e5c04420bc4aa0bf56704195e4fcb7", + "url": "/album_slideshow_static/album-slideshow-card.js?v=1.15.1", + "type": "module" + }, + { + "id": "f65bb1cdcb3f4fefb7c8ca3bfd28aa45", + "url": "/taskmate/taskmate-sticker-chart-card.js?v=6.4.0", "type": "module" } ] diff --git a/custom_components/album_slideshow/__init__.py b/custom_components/album_slideshow/__init__.py index 89795476..3728b9b4 100644 --- a/custom_components/album_slideshow/__init__.py +++ b/custom_components/album_slideshow/__init__.py @@ -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() diff --git a/custom_components/album_slideshow/camera.py b/custom_components/album_slideshow/camera.py index 11251be5..ff5749ea 100644 --- a/custom_components/album_slideshow/camera.py +++ b/custom_components/album_slideshow/camera.py @@ -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) diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py index ffc799be..2d749db8 100644 --- a/custom_components/album_slideshow/config_flow.py +++ b/custom_components/album_slideshow/config_flow.py @@ -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: diff --git a/custom_components/album_slideshow/const.py b/custom_components/album_slideshow/const.py index b63aaba8..e9d95f58 100644 --- a/custom_components/album_slideshow/const.py +++ b/custom_components/album_slideshow/const.py @@ -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" diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py index e9d877da..df45740b 100644 --- a/custom_components/album_slideshow/coordinator.py +++ b/custom_components/album_slideshow/coordinator.py @@ -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: diff --git a/custom_components/album_slideshow/immich.py b/custom_components/album_slideshow/immich.py index fe0072d2..25ddf2cd 100644 --- a/custom_components/album_slideshow/immich.py +++ b/custom_components/album_slideshow/immich.py @@ -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): diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json index dab734e4..dcaa49ae 100644 --- a/custom_components/album_slideshow/manifest.json +++ b/custom_components/album_slideshow/manifest.json @@ -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" } diff --git a/custom_components/album_slideshow/number.py b/custom_components/album_slideshow/number.py index 14526732..078136de 100644 --- a/custom_components/album_slideshow/number.py +++ b/custom_components/album_slideshow/number.py @@ -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 diff --git a/custom_components/album_slideshow/store.py b/custom_components/album_slideshow/store.py index 682428dc..c8103a07 100644 --- a/custom_components/album_slideshow/store.py +++ b/custom_components/album_slideshow/store.py @@ -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 diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json index 492900dd..5fa89b53 100644 --- a/custom_components/album_slideshow/strings.json +++ b/custom_components/album_slideshow/strings.json @@ -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" } diff --git a/custom_components/album_slideshow/switch.py b/custom_components/album_slideshow/switch.py index 072c9058..3bba79ab 100644 --- a/custom_components/album_slideshow/switch.py +++ b/custom_components/album_slideshow/switch.py @@ -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.""" diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json index 492900dd..5fa89b53 100644 --- a/custom_components/album_slideshow/translations/en.json +++ b/custom_components/album_slideshow/translations/en.json @@ -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" } diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js index 0bdf2473..7b5038e0 100644 --- a/custom_components/album_slideshow/www/album-slideshow-card.js +++ b/custom_components/album_slideshow/www/album-slideshow-card.js @@ -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 = ` - -
-${o(i.period)}: ${o(d)} \xB7 ${o(i.generated)} ${o(t.date(s))}
-| ${o(i.colObject)} | ${o(i.completions)} | -${o(i.colCost)} | ${o(i.share)} | -
|---|---|---|---|
| ${o(i.none)} | |||
| ${o(i.totalCost)} | ${o(t.number(r.completions))} | -${o(t.cost(r.totalCost))} | - |
| ${o(i.period)} | ${o(i.completions)} | -${o(i.colCost)} | -|
|---|---|---|---|
| ${o(i.none)} | |||
| ${o(i.colDate)} | ${o(i.colObject)} | ${o(i.colTask)} | -${o(i.colCost)} | ${o(i.colDuration)} | ${o(i.colNotes)} | -
|---|---|---|---|---|---|
| ${o(i.none)} | |||||
| ${o(i.totalCost)} | -${o(t.cost(r.totalCost))} | -${r.totalDuration?o(t.duration(r.totalDuration)):""} | - | ||
${o(i.capNote)}
`:""} - -