361 files
This commit is contained in:
@@ -5,6 +5,7 @@ from collections import OrderedDict, deque
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -20,6 +21,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CAMERA_METADATA_FIELDS,
|
||||
DEFAULT_CUSTOM_LOOKBACK_DAYS,
|
||||
DEFAULT_SHUFFLE_AGE_BIAS,
|
||||
DEFAULT_MISSING_DATE_MODE,
|
||||
MAX_RESOLUTION_SHORT_EDGE,
|
||||
ORIENTATION_MISMATCH_PAIR,
|
||||
ORIENTATION_MISMATCH_AVOID,
|
||||
@@ -111,6 +116,41 @@ def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _camera_metadata_attributes(item: MediaItem | None) -> dict[str, Any]:
|
||||
metadata = getattr(item, "camera_metadata", None)
|
||||
return {
|
||||
field: metadata.get(field) if isinstance(metadata, dict) else None
|
||||
for field in CAMERA_METADATA_FIELDS
|
||||
}
|
||||
|
||||
|
||||
def _item_faces(item: MediaItem | None) -> tuple[ip.FaceBox, ...] | None:
|
||||
"""Return the validated face boxes stored on a media item.
|
||||
|
||||
None means no face data; an empty tuple means the photo has no faces.
|
||||
"""
|
||||
faces = getattr(item, "faces", None) if item is not None else None
|
||||
if not isinstance(faces, (list, tuple)):
|
||||
return None
|
||||
valid: list[ip.FaceBox] = []
|
||||
for face in faces:
|
||||
if (
|
||||
not isinstance(face, (list, tuple))
|
||||
or len(face) not in (5, 6)
|
||||
or not all(
|
||||
not isinstance(value, bool) and isinstance(value, (int, float))
|
||||
and math.isfinite(value) for value in face[:5]
|
||||
)
|
||||
or (len(face) == 6 and not isinstance(face[5], bool))
|
||||
):
|
||||
continue
|
||||
left, top, right, bottom, weight = (float(value) for value in face[:5])
|
||||
if 0 <= left < right <= 1 and 0 <= top < bottom <= 1 and weight > 0:
|
||||
selected = face[5] if len(face) == 6 else False
|
||||
valid.append(ip.FaceBox(left, top, right, bottom, weight, selected))
|
||||
return tuple(valid)
|
||||
|
||||
|
||||
class _DownloadCache:
|
||||
"""Byte-budget LRU cache for downloaded image data, O(1) per operation."""
|
||||
|
||||
@@ -208,6 +248,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._frame_id: int = 0
|
||||
|
||||
self._interrupt_event: asyncio.Event = asyncio.Event()
|
||||
self._slide_deadline: float | None = None
|
||||
# Navigation runs directly in the button/service coroutine. The lock
|
||||
# serialises rapid presses while buffered swaps remain independent of
|
||||
# the background timer loop.
|
||||
@@ -244,6 +285,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
def _on_store_change() -> None:
|
||||
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
|
||||
self._effective_cache = None
|
||||
self._slide_deadline = None
|
||||
self._invalidate_timeline()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
@@ -333,12 +375,14 @@ class AlbumSlideshowCamera(Camera):
|
||||
"captured_at_primary": captured_at,
|
||||
"uploaded_at": _ts_to_iso(getattr(cur, "uploaded_at", None)),
|
||||
"byte_size": getattr(cur, "byte_size", None),
|
||||
# GPS + reverse-geocoded label come from EXIF for local-folder
|
||||
# entries; Google albums leave these as ``None``.
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
"location": getattr(cur, "location", None),
|
||||
"description": getattr(cur, "description", None),
|
||||
**_camera_metadata_attributes(cur),
|
||||
"google_metadata_enabled": bool(getattr(self.coordinator, "google_metadata_enabled", False)),
|
||||
"google_location_enabled": bool(getattr(self.coordinator, "google_location_enabled", False)),
|
||||
"google_reverse_geocode_enabled": bool(getattr(self.coordinator, "google_reverse_geocode_enabled", False)),
|
||||
# Structured per-image caption metadata. A single-element list for
|
||||
# normal slides; two elements (top/left first) for paired slides,
|
||||
# so the card can overlay an accurate date/location on each half.
|
||||
@@ -351,6 +395,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
"portrait_mode": self.store.portrait_mode,
|
||||
"order_mode": self.store.order_mode,
|
||||
"date_filter": self.store.date_filter,
|
||||
"custom_lookback_days": int(self.store.custom_lookback_days),
|
||||
"shuffle_age_bias": int(self.store.shuffle_age_bias),
|
||||
"missing_date_mode": self.store.missing_date_mode,
|
||||
"paused": bool(self.store.paused),
|
||||
"refresh_hours": int(self.store.refresh_hours),
|
||||
@@ -393,6 +439,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
"description": getattr(cur, "description", None),
|
||||
**_camera_metadata_attributes(cur),
|
||||
}
|
||||
]
|
||||
|
||||
@@ -429,6 +476,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
cache_key = (
|
||||
id(raw),
|
||||
self.store.date_filter,
|
||||
getattr(self.store, "custom_lookback_days", DEFAULT_CUSTOM_LOOKBACK_DAYS),
|
||||
self.store.missing_date_mode,
|
||||
self.store.order_mode,
|
||||
self.store.hidden_revision,
|
||||
@@ -440,6 +488,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
[item for item in raw if item.photo_id and item.photo_id not in self.store.hidden_photo_ids]
|
||||
if self.store.hidden_photo_ids else raw,
|
||||
mode=self.store.date_filter,
|
||||
lookback_days=getattr(self.store, "custom_lookback_days", DEFAULT_CUSTOM_LOOKBACK_DAYS),
|
||||
missing_date=self.store.missing_date_mode,
|
||||
)
|
||||
ordered = playlist.order_items(filtered, self.store.order_mode)
|
||||
@@ -463,6 +512,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._last_nav_outcome = "pending"
|
||||
self._last_nav_error = None
|
||||
# Wake the timer loop so the manual frame starts a fresh interval.
|
||||
self._slide_deadline = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -492,6 +542,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
)
|
||||
finally:
|
||||
self._navigation_pending -= 1
|
||||
self._slide_deadline = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_force_refresh(self) -> None:
|
||||
@@ -1015,6 +1067,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
|
||||
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()
|
||||
if initial_delay > 0:
|
||||
await asyncio.sleep(initial_delay)
|
||||
|
||||
@@ -1045,6 +1098,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
|
||||
while True:
|
||||
try:
|
||||
if self._slide_deadline is None:
|
||||
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
|
||||
if self._timeline_dirty:
|
||||
async with self._navigation_lock:
|
||||
if self._timeline_dirty:
|
||||
@@ -1052,16 +1107,22 @@ class AlbumSlideshowCamera(Camera):
|
||||
continue
|
||||
|
||||
interrupted = await self._wait_or_interrupt(
|
||||
float(int(self.store.slide_interval))
|
||||
max(0.0, self._slide_deadline - loop.time())
|
||||
)
|
||||
if not interrupted and not self.store.paused:
|
||||
async with self._navigation_lock:
|
||||
if not self._timeline_dirty:
|
||||
if (
|
||||
not self._timeline_dirty
|
||||
and self._slide_deadline is not None
|
||||
and loop.time() >= self._slide_deadline
|
||||
):
|
||||
await self._show_next_frame()
|
||||
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
|
||||
self._consecutive_failures = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
self._slide_deadline = None
|
||||
self._consecutive_failures += 1
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: buffered navigation/render failed (attempt %d): %s",
|
||||
@@ -1104,7 +1165,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._index = (self._index + 1) % count
|
||||
return
|
||||
|
||||
self._index = self._next_random_index(count)
|
||||
bias = getattr(self.store, "shuffle_age_bias", DEFAULT_SHUFFLE_AGE_BIAS)
|
||||
self._index = self._next_age_weighted_index(items, bias) if bias else self._next_random_index(count)
|
||||
cur_url = items[self._index].url
|
||||
self._recent_urls.append(cur_url)
|
||||
keep = min(20, max(1, count - 1))
|
||||
@@ -1122,6 +1184,13 @@ class AlbumSlideshowCamera(Camera):
|
||||
return
|
||||
self._index = (self._index + 1) % count
|
||||
|
||||
def _crop_hints(self, item: MediaItem | None) -> ip.CropHints:
|
||||
return ip.CropHints(
|
||||
faces=_item_faces(item),
|
||||
debug=bool(getattr(getattr(self, "store", None), "face_debug", False)),
|
||||
name=getattr(item, "filename", None),
|
||||
)
|
||||
|
||||
async def _compose_for_index(
|
||||
self, items: list[MediaItem]
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
@@ -1183,6 +1252,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
composed = await self._async_image_job(
|
||||
ip.pair_images, img, other_img, width, height, fill_mode,
|
||||
is_portrait_canvas, divider, divider_fill, transparent_divider,
|
||||
self._crop_hints(cur), self._crop_hints(other_item),
|
||||
)
|
||||
pair_frames = [
|
||||
{
|
||||
@@ -1191,6 +1261,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
"description": getattr(cur, "description", None),
|
||||
**_camera_metadata_attributes(cur),
|
||||
},
|
||||
{
|
||||
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
|
||||
@@ -1198,12 +1269,13 @@ class AlbumSlideshowCamera(Camera):
|
||||
"latitude": getattr(other_item, "latitude", None),
|
||||
"longitude": getattr(other_item, "longitude", None),
|
||||
"description": getattr(other_item, "description", None),
|
||||
**_camera_metadata_attributes(other_item),
|
||||
},
|
||||
]
|
||||
pair_meta = [f["captured_at"] for f in pair_frames]
|
||||
else:
|
||||
composed = await self._async_image_job(
|
||||
ip.render_image, img, fill_mode, width, height,
|
||||
ip.render_image, img, fill_mode, width, height, self._crop_hints(cur),
|
||||
)
|
||||
finally:
|
||||
ip.safe_close(other_img)
|
||||
@@ -1226,7 +1298,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
return composed, meta
|
||||
|
||||
composed = await self._async_image_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
ip.render_image, img, fill_mode, width, height, self._crop_hints(cur)
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": cur_is_portrait,
|
||||
@@ -1278,7 +1350,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
if self._index != start:
|
||||
self._do_advance(count, items)
|
||||
composed = await self._async_image_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
ip.render_image, img, fill_mode, width, height, self._crop_hints(cur)
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": is_portrait_canvas,
|
||||
@@ -1304,7 +1376,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
try:
|
||||
cur_is_portrait = ip.is_portrait_item(item, img)
|
||||
composed = await self._async_image_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
ip.render_image, img, fill_mode, width, height, self._crop_hints(item)
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": cur_is_portrait,
|
||||
@@ -1407,6 +1479,23 @@ class AlbumSlideshowCamera(Camera):
|
||||
|
||||
return None
|
||||
|
||||
def _next_age_weighted_index(self, items: list[MediaItem], bias: int) -> int:
|
||||
weights = playlist.age_weights(
|
||||
items, bias, missing_date=getattr(self.store, "missing_date_mode", DEFAULT_MISSING_DATE_MODE),
|
||||
)
|
||||
count = len(items)
|
||||
if count <= 1 or min(weights) == max(weights):
|
||||
return self._next_random_index(count)
|
||||
cooldown = min(5, max(1, count // 10))
|
||||
recent = set(self._recent_urls[-cooldown:])
|
||||
candidates = [
|
||||
index for index, item in enumerate(items)
|
||||
if index != self._index and item.url not in recent
|
||||
]
|
||||
if not candidates:
|
||||
candidates = [index for index in range(count) if index != self._index]
|
||||
return self._rng.choices(candidates, weights=[weights[index] for index in candidates], k=1)[0]
|
||||
|
||||
def _next_random_index(self, count: int) -> int:
|
||||
if count <= 1:
|
||||
self._random_order = [0]
|
||||
|
||||
Reference in New Issue
Block a user