361 files

This commit is contained in:
Home Assistant Version Control
2026-10-06 17:11:43 +00:00
parent d2c5af43e3
commit bf886c9347
362 changed files with 22060 additions and 1285 deletions
@@ -516,16 +516,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_register_photo_services(hass)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Reload when the options flow changes the entry (e.g. a new Immich
# selection or the reverse-geocode toggle) so it takes effect at once.
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
store.notify()
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
domain_data = hass.data.get(DOMAIN, {})
coordinator = domain_data.get(entry.entry_id, {}).get("coordinator")
if coordinator is not None:
await coordinator._cancel_enrichment()
domain_data.pop(entry.entry_id, None)
# Drop the shared semaphore once the last album is gone so it's
# re-created if the integration is re-added later.
+98 -9
View File
@@ -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]
+427 -120
View File
@@ -20,6 +20,11 @@ from .const import (
CONF_PROVIDER,
CONF_ALBUM_NAME,
CONF_ALBUM_URL,
CONF_GOOGLE_METADATA,
DEFAULT_GOOGLE_METADATA,
CONF_GOOGLE_LOCATION,
DEFAULT_GOOGLE_LOCATION,
DEFAULT_GOOGLE_REVERSE_GEOCODE,
CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
@@ -149,6 +154,193 @@ def _redact_token(token: str | None) -> str:
return f"{token[:3]}...({len(token)} chars, shape {shape})"
async def _fetch_immich_choices(
client: Any,
) -> tuple[dict[str, str], dict[str, str]]:
"""Return id -> name maps of the albums and named people a key can see.
A key with limited permissions can read the server but not albums or
people; keep going with whatever it can see.
"""
albums: list[dict[str, Any]] = []
people: list[dict[str, Any]] = []
for label, call in (
("albums", client.async_list_albums),
("people", client.async_list_people),
):
try:
result = await call()
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Immich %s listing failed for %s: %s",
label,
client.base_url,
_describe_error(err),
)
else:
if label == "albums":
albums = result
else:
people = result
album_map = {
a["id"]: (a.get("albumName") or a["id"]) for a in albums if a.get("id")
}
people_map = {
p["id"]: p["name"]
for p in people
if p.get("id") and (p.get("name") or "").strip()
}
return album_map, people_map
def _immich_validation_error(err: BaseException) -> str:
return (
"immich_invalid_auth"
if getattr(err, "status", None) in (401, 403)
else "immich_cannot_connect"
)
def _immich_select_schema(
albums: dict[str, str],
people: dict[str, str],
defaults: dict[str, Any] | None = None,
) -> vol.Schema:
"""Build the albums/people/favorites picker, optionally pre-filled."""
defaults = defaults or {}
def _default(key: str, fallback: Any = vol.UNDEFINED) -> Any:
return defaults.get(key, fallback)
fields: dict[Any, Any] = {
vol.Required(CONF_ALBUM_NAME, default=_default(CONF_ALBUM_NAME)): str
}
if albums:
album_options = [
selector.SelectOptionDict(value=_ALL_ALBUMS, label="Select all albums")
] + [
selector.SelectOptionDict(value=aid, label=name)
for aid, name in albums.items()
]
fields[vol.Optional("albums", default=_default("albums"))] = (
selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
)
if people:
people_options = [
selector.SelectOptionDict(value=_ALL_PEOPLE, label="Select all people")
] + [
selector.SelectOptionDict(value=pid, label=name)
for pid, name in people.items()
]
fields[vol.Optional("people", default=_default("people"))] = (
selector.SelectSelector(
selector.SelectSelectorConfig(
options=people_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
)
fields[vol.Optional("favorites", default=_default("favorites", False))] = (
selector.BooleanSelector()
)
filter_default = _default(CONF_IMMICH_FILTER)
fields[
vol.Optional(
CONF_IMMICH_FILTER,
description=(
{"suggested_value": filter_default}
if filter_default is not vol.UNDEFINED
else None
),
)
] = str
fields[
vol.Optional(
CONF_IMMICH_IMAGE_SIZE,
default=_default(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE),
)
] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS)
return vol.Schema(fields)
def _parse_immich_select(
user_input: dict[str, Any],
albums: dict[str, str],
people: dict[str, str],
) -> tuple[dict[str, Any], dict[str, str]]:
"""Turn picker input into entry data fields; return ``(fields, errors)``."""
errors: dict[str, str] = {}
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
raw_filter = (user_input.get(CONF_IMMICH_FILTER) or "").strip()
favorites = bool(user_input.get("favorites"))
chosen_albums = [a for a in user_input.get("albums", []) if a]
if _ALL_ALBUMS in chosen_albums:
chosen_albums = list(albums.keys())
else:
chosen_albums = [a for a in chosen_albums if a in albums]
chosen_people = [p for p in user_input.get("people", []) if p]
if _ALL_PEOPLE in chosen_people:
chosen_people = list(people.keys())
else:
chosen_people = [p for p in chosen_people if p in people]
if raw_filter:
try:
parsed = json.loads(raw_filter)
if not isinstance(parsed, dict):
raise ValueError
except ValueError:
errors[CONF_IMMICH_FILTER] = "immich_filter_invalid"
selection = {
"albums": chosen_albums,
"people": chosen_people,
"favorites": favorites,
}
fields = {
CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE,
CONF_IMMICH_SELECTION_ID: json.dumps(selection, sort_keys=True),
CONF_IMMICH_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
CONF_IMMICH_FILTER: raw_filter,
}
return fields, errors
def _immich_current_selection(data: dict[str, Any]) -> dict[str, Any]:
"""Map an entry's stored Immich selection onto the picker's fields.
Composite entries map one to one. Older single-type entries are
translated where the picker can express them (albums, people,
favorites); ``all``/``random``/``search`` start with nothing ticked,
which the picker saves as "all photos" (plus any stored filter).
"""
from . import immich as immich_api
sel_type = data.get(CONF_IMMICH_SELECTION_TYPE)
sel_id = data.get(CONF_IMMICH_SELECTION_ID) or ""
if sel_type == IMMICH_SELECTION_COMPOSITE:
return immich_api.parse_composite_selection(sel_id)
ids = [part for part in sel_id.split(",") if part]
return {
"albums": ids if sel_type in ("album", "albums") else [],
"people": ids if sel_type in ("person", "people") else [],
"favorites": sel_type == "favorites",
}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
@@ -190,9 +382,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> config_entries.OptionsFlow:
"""Return the options flow handler.
Only local-folder entries expose user-tunable options today (the
reverse-geocode toggle); Google entries get a no-op handler so
that the "Configure" button doesn't appear empty in the UI.
Local-folder, Nextcloud and Ente entries expose the reverse-geocode
toggle; Immich entries reopen the albums/people/favorites picker.
Other providers get a no-op handler so that the "Configure" button
doesn't appear empty in the UI.
Note: do NOT pass ``config_entry`` to the OptionsFlow constructor.
Since Home Assistant 2024.12 the base class manages
@@ -206,6 +399,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_ENTE,
):
return LocalFolderOptionsFlow()
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_IMMICH:
return ImmichOptionsFlow()
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_GOOGLE_SHARED:
return GoogleOptionsFlow()
return _NoOptionsFlow()
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
@@ -354,8 +551,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
from . import immich as immich_api
client = immich_api.ImmichClient(self.hass, url, key)
albums: list[dict[str, Any]] = []
people: list[dict[str, Any]] = []
try:
await client.async_validate()
except Exception as err: # noqa: BLE001 - any failure means bad URL/key
@@ -364,46 +559,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
client.base_url,
_describe_error(err),
)
errors["base"] = (
"immich_invalid_auth"
if getattr(err, "status", None) in (401, 403)
else "immich_cannot_connect"
)
errors["base"] = _immich_validation_error(err)
else:
# A key with limited permissions can read the server but not
# albums or people; keep going with whatever it can see.
for label, call in (
("albums", client.async_list_albums),
("people", client.async_list_people),
):
try:
result = await call()
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Immich %s listing failed for %s: %s",
label,
client.base_url,
_describe_error(err),
)
else:
if label == "albums":
albums = result
else:
people = result
self._immich_url = client.base_url
self._immich_key = key
# id -> name maps for the two multi-select pickers.
self._immich_albums = {
a["id"]: (a.get("albumName") or a["id"])
for a in albums
if a.get("id")
}
self._immich_people = {
p["id"]: p["name"]
for p in people
if p.get("id") and (p.get("name") or "").strip()
}
(
self._immich_albums,
self._immich_people,
) = await _fetch_immich_choices(client)
return await self.async_step_immich_select()
schema = vol.Schema(
@@ -428,41 +591,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
raw_filter = (user_input.get(CONF_IMMICH_FILTER) or "").strip()
favorites = bool(user_input.get("favorites"))
chosen_albums = [a for a in user_input.get("albums", []) if a]
if _ALL_ALBUMS in chosen_albums:
chosen_albums = list(self._immich_albums.keys())
else:
chosen_albums = [a for a in chosen_albums if a in self._immich_albums]
chosen_people = [p for p in user_input.get("people", []) if p]
if _ALL_PEOPLE in chosen_people:
chosen_people = list(self._immich_people.keys())
else:
chosen_people = [p for p in chosen_people if p in self._immich_people]
if raw_filter:
try:
parsed = json.loads(raw_filter)
if not isinstance(parsed, dict):
raise ValueError
except ValueError:
errors[CONF_IMMICH_FILTER] = "immich_filter_invalid"
fields, errors = _parse_immich_select(
user_input, self._immich_albums, self._immich_people
)
if not errors:
selection = {
"albums": chosen_albums,
"people": chosen_people,
"favorites": favorites,
}
sel_id = json.dumps(selection, sort_keys=True)
name = fields[CONF_ALBUM_NAME]
raw_filter = fields.pop(CONF_IMMICH_FILTER)
unique = (
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
f"composite:{sel_id}:{raw_filter}:{name}"
f"composite:{fields[CONF_IMMICH_SELECTION_ID]}:{raw_filter}:{name}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
@@ -470,58 +607,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_PROVIDER: PROVIDER_IMMICH,
CONF_IMMICH_URL: self._immich_url,
CONF_IMMICH_API_KEY: self._immich_key,
CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE,
CONF_IMMICH_SELECTION_ID: sel_id,
CONF_IMMICH_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
**fields,
}
if raw_filter:
data[CONF_IMMICH_FILTER] = raw_filter
return self.async_create_entry(title=name, data=data)
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
if self._immich_albums:
album_options = [
selector.SelectOptionDict(
value=_ALL_ALBUMS, label="Select all albums"
)
] + [
selector.SelectOptionDict(value=aid, label=name)
for aid, name in self._immich_albums.items()
]
fields[vol.Optional("albums")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
if self._immich_people:
people_options = [
selector.SelectOptionDict(
value=_ALL_PEOPLE, label="Select all people"
)
] + [
selector.SelectOptionDict(value=pid, label=name)
for pid, name in self._immich_people.items()
]
fields[vol.Optional("people")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=people_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
fields[vol.Optional("favorites", default=False)] = selector.BooleanSelector()
fields[vol.Optional(CONF_IMMICH_FILTER)] = str
fields[
vol.Optional(CONF_IMMICH_IMAGE_SIZE, default=DEFAULT_IMMICH_IMAGE_SIZE)
] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS)
schema = vol.Schema(fields)
return self.async_show_form(
step_id="immich_select", data_schema=schema, errors=errors
step_id="immich_select",
data_schema=_immich_select_schema(
self._immich_albums, self._immich_people
),
errors=errors,
)
async def async_step_photoprism(
@@ -1263,6 +1360,38 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
class GoogleOptionsFlow(config_entries.OptionsFlow):
"""Control anonymous Google photo metadata enrichment."""
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult:
return await self.async_step_google_metadata(user_input)
async def async_step_google_metadata(
self, user_input: dict[str, Any] | None = None,
) -> FlowResult:
defaults = {
CONF_GOOGLE_METADATA: DEFAULT_GOOGLE_METADATA,
CONF_GOOGLE_LOCATION: DEFAULT_GOOGLE_LOCATION,
CONF_REVERSE_GEOCODE: DEFAULT_GOOGLE_REVERSE_GEOCODE,
}
if user_input is not None:
options = {
**self.config_entry.options,
**{
field: bool(user_input.get(field, self.config_entry.options.get(field, default)))
for field, default in defaults.items()
},
}
return self.async_create_entry(title="", data=options)
return self.async_show_form(
step_id="google_metadata",
data_schema=vol.Schema({
vol.Required(field, default=bool(self.config_entry.options.get(field, default))): bool
for field, default in defaults.items()
}),
)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
@@ -1295,6 +1424,184 @@ class LocalFolderOptionsFlow(config_entries.OptionsFlow):
return self.async_show_form(step_id="init", data_schema=schema)
class ImmichOptionsFlow(config_entries.OptionsFlow):
"""Change an Immich entry's source without re-creating the entry.
The selection lives in ``entry.data`` (the coordinator reads it from
there), so this flow writes the new selection back into the entry data;
the update listener then reloads the entry. The entry id is unchanged,
so entities, their settings and the hidden-photo list are kept.
"""
def __init__(self) -> None:
super().__init__()
self._albums: dict[str, str] = {}
self._people: dict[str, str] = {}
self._url: str | None = None
self._key: str | None = None
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
from . import immich as immich_api
data = self.config_entry.data
client = immich_api.ImmichClient(
self.hass, data.get(CONF_IMMICH_URL), data.get(CONF_IMMICH_API_KEY)
)
try:
await client.async_validate()
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Immich validation failed for %s: %s",
client.base_url,
_describe_error(err),
)
return await self.async_step_connection(
errors={"base": _immich_validation_error(err)}
)
self._url = client.base_url
self._key = data.get(CONF_IMMICH_API_KEY)
self._albums, self._people = await _fetch_immich_choices(client)
return await self.async_step_immich_select()
async def async_step_connection(
self,
user_input: dict[str, Any] | None = None,
errors: dict[str, str] | None = None,
) -> FlowResult:
"""Fix the URL or API key when the stored ones stop working."""
from . import immich as immich_api
errors = dict(errors or {})
data = self.config_entry.data
if user_input is not None:
url = user_input[CONF_IMMICH_URL].strip()
key = user_input[CONF_IMMICH_API_KEY].strip()
client = immich_api.ImmichClient(self.hass, url, key)
try:
await client.async_validate()
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Immich validation failed for %s: %s",
client.base_url,
_describe_error(err),
)
errors["base"] = _immich_validation_error(err)
else:
self._url = client.base_url
self._key = key
self._albums, self._people = await _fetch_immich_choices(client)
return await self.async_step_immich_select()
schema = vol.Schema(
{
vol.Required(
CONF_IMMICH_URL, default=data.get(CONF_IMMICH_URL, "")
): str,
vol.Required(
CONF_IMMICH_API_KEY, default=data.get(CONF_IMMICH_API_KEY, "")
): str,
}
)
return self.async_show_form(
step_id="connection", data_schema=schema, errors=errors
)
async def async_step_immich_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
entry = self.config_entry
errors: dict[str, str] = {}
current = _immich_current_selection(entry.data)
for album_id in current["albums"]:
self._albums.setdefault(album_id, f"{album_id} (unavailable)")
for person_id in current["people"]:
self._people.setdefault(person_id, f"{person_id} (unavailable)")
if user_input is not None:
fields, errors = _parse_immich_select(
user_input, self._albums, self._people
)
if not errors:
new_data = {
**entry.data,
**fields,
CONF_IMMICH_URL: self._url,
CONF_IMMICH_API_KEY: self._key,
}
if not new_data[CONF_IMMICH_FILTER]:
new_data.pop(CONF_IMMICH_FILTER)
try:
if _immich_source_changed(entry.data, new_data):
await _async_clear_item_cache(self.hass, entry.entry_id)
except Exception as err:
_LOGGER.warning(
"Could not clear Immich source cache for %s (%s)",
entry.entry_id, type(err).__name__,
)
errors["base"] = "immich_cache_clear_failed"
else:
options = {
**entry.options,
CONF_REVERSE_GEOCODE: user_input.get(
CONF_REVERSE_GEOCODE,
entry.options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE),
),
}
self.hass.config_entries.async_update_entry(
entry, data=new_data, title=fields[CONF_ALBUM_NAME], options=options
)
return self.async_create_entry(title="", data=options)
defaults = {
CONF_ALBUM_NAME: entry.data.get(CONF_ALBUM_NAME) or entry.title,
"albums": current["albums"],
"people": current["people"],
"favorites": current["favorites"],
CONF_IMMICH_IMAGE_SIZE: entry.data.get(
CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE
),
}
if entry.data.get(CONF_IMMICH_FILTER):
defaults[CONF_IMMICH_FILTER] = entry.data[CONF_IMMICH_FILTER]
schema = _immich_select_schema(self._albums, self._people, defaults).extend({
vol.Required(CONF_REVERSE_GEOCODE, default=bool(entry.options.get(
CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE,
))): bool,
})
return self.async_show_form(
step_id="immich_select",
data_schema=schema,
errors=errors,
)
def _immich_source_changed(old: dict[str, Any], new: dict[str, Any]) -> bool:
"""True when the set of photos an Immich entry shows may have changed."""
keys = (
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_FILTER,
CONF_IMMICH_IMAGE_SIZE,
)
return any(old.get(key) != new.get(key) for key in keys)
async def _async_clear_item_cache(hass: Any, entry_id: str) -> None:
"""Drop the persisted item list so a new source never shows old photos.
The coordinator serves this cache when a refresh fails or comes back
empty; after a source change that would resurrect the previous album.
Uses the same storage key as ``AlbumCoordinator._items_cache_store``.
"""
from homeassistant.helpers.storage import Store
await Store(hass, 1, f"{DOMAIN}.{entry_id}.items").async_remove()
class _NoOptionsFlow(config_entries.OptionsFlow):
"""Fallback options flow for providers that expose nothing tunable."""
@@ -12,6 +12,15 @@ CONF_ALBUM_NAME = "album_name"
CONF_LOCAL_PATH = "local_path"
CONF_RECURSIVE = "recursive"
CONF_IMAGE_CACHE_MB = "image_cache_mb"
CONF_GOOGLE_METADATA = "google_metadata"
DEFAULT_GOOGLE_METADATA = True
CONF_GOOGLE_LOCATION = "google_location"
DEFAULT_GOOGLE_LOCATION = False
DEFAULT_GOOGLE_REVERSE_GEOCODE = False
CAMERA_METADATA_FIELDS = (
"camera_make", "camera_model", "focal_length_mm", "aperture_f_number",
"iso", "exposure_time_seconds",
)
# Media Source provider: a ``media-source://...`` content id pointing at a
# folder-like node (e.g. an Immich people/album view, or local media). The
# coordinator browses it, collects the image children, and resolves each to
@@ -258,6 +267,9 @@ DATE_FILTER_OFF = "off"
DATE_FILTER_LAST_7 = "last_7_days"
DATE_FILTER_LAST_30 = "last_30_days"
DATE_FILTER_LAST_365 = "last_365_days"
DATE_FILTER_CUSTOM = "custom_days"
DEFAULT_CUSTOM_LOOKBACK_DAYS = 365
MAX_CUSTOM_LOOKBACK_DAYS = 36500
DATE_FILTER_THIS_MONTH = "this_month"
DATE_FILTER_THIS_YEAR = "this_year"
DATE_FILTER_ON_THIS_DAY = "on_this_day"
@@ -267,6 +279,7 @@ DATE_FILTER_OPTIONS = [
DATE_FILTER_LAST_7,
DATE_FILTER_LAST_30,
DATE_FILTER_LAST_365,
DATE_FILTER_CUSTOM,
DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR,
DATE_FILTER_ON_THIS_DAY,
@@ -293,6 +306,7 @@ DEFAULT_REFRESH_HOURS = 24
DEFAULT_FILL_MODE = FILL_BLUR
DEFAULT_ORIENTATION_MISMATCH_MODE = ORIENTATION_MISMATCH_PAIR
DEFAULT_ORDER_MODE = ORDER_RANDOM
DEFAULT_SHUFFLE_AGE_BIAS = 0
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_PAIR_DIVIDER_PX = 8
DEFAULT_PAIR_DIVIDER_COLOR = "#FFFFFF"
+335 -26
View File
@@ -6,11 +6,13 @@ from datetime import timedelta
from hashlib import sha256
import json
import logging
import math
import os
from pathlib import Path
import re
from typing import Any
import aiohttp
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -22,6 +24,12 @@ from .const import (
PUBLICALBUM_ENDPOINT,
CONF_PROVIDER,
CONF_ALBUM_URL,
CONF_GOOGLE_METADATA,
DEFAULT_GOOGLE_METADATA,
CONF_GOOGLE_LOCATION,
DEFAULT_GOOGLE_LOCATION,
DEFAULT_GOOGLE_REVERSE_GEOCODE,
CAMERA_METADATA_FIELDS,
CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
@@ -97,6 +105,7 @@ from .const import (
from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__)
_GOOGLE_METADATA_INTERVAL = 1.0
@dataclass
@@ -127,12 +136,20 @@ class MediaItem:
# Provider-specific source identifier (e.g. the Immich asset id) used by
# background enrichment to fetch per-item metadata.
source_id: str | None = None
# Detected faces as normalised [x1, y1, x2, y2, weight, selected] boxes (0..1 in
# displayed-image coordinates). Cover-mode cropping keeps as many whole
# faces as fit. None means no face data (non-Immich source, not scanned
# yet or the face lookup failed); an empty list means "no faces".
faces: list[list[float | bool]] | None = None
# True once the local-folder EXIF reader has visited this file.
# Prevents re-reading EXIF on every coordinator refresh and lets the
# background enrichment task skip already-processed files even after
# an HA restart (the flag round-trips through the items cache).
exif_scanned: bool = False
photo_id: str | None = None
face_scanned: bool = False
camera_metadata: dict[str, Any] | None = None
google_location_scanned: bool = False
def __post_init__(self) -> None:
if self.photo_id is None:
@@ -311,11 +328,11 @@ def _pick_timestamp_ms(d: dict[str, Any], *path: str) -> int | None:
if not isinstance(cur, dict):
return None
cur = cur.get(p)
if cur is None:
if cur is None or isinstance(cur, bool):
return None
if isinstance(cur, int):
# Heuristic: < 1e12 means seconds, otherwise milliseconds.
return cur * 1000 if cur < 10**12 else cur
# Infer seconds only within the plausible 1800-2100 date range.
return cur * 1000 if -5_364_662_400 <= cur <= 4_102_444_800 else cur
if isinstance(cur, str):
try:
iso = cur.replace("Z", "+00:00")
@@ -451,6 +468,7 @@ _EXIF_GPS_LAT_REF = 1 # "N" / "S"
_EXIF_GPS_LAT = 2 # rational tuple
_EXIF_GPS_LON_REF = 3 # "E" / "W"
_EXIF_GPS_LON = 4 # rational tuple
_HEIF_EXIF_RE = re.compile(rb"Exif\x00\x00(?:MM\x00\x2a|II\x2a\x00)")
# Nominatim (OpenStreetMap) is free but rate-limited to 1 request/second
# per IP and asks integrators to identify themselves with a descriptive
@@ -777,7 +795,7 @@ def _read_exif_from_image(img: Any, out: dict[str, Any]) -> None:
def _read_exif_from_bytes(
data: bytes, mtime_fallback_ms: int | None
data: bytes, mtime_fallback_ms: int | None, *, strict: bool = False,
) -> dict[str, Any]:
"""Read EXIF metadata from already-downloaded image bytes.
@@ -802,11 +820,61 @@ def _read_exif_from_bytes(
with Image.open(io.BytesIO(data)) as img:
_read_exif_from_image(img, out)
except Exception as err:
if strict:
raise
_LOGGER.debug("EXIF: failed to read image bytes: %s", err)
return out
def _read_gps_from_header(data: bytes) -> dict[str, float]:
"""Read GPS from an original-image prefix; raise ``ValueError`` if it is too short to tell."""
if data[:2] == b"\xff\xd8":
payload = _jpeg_exif_segment(data)
elif data[4:8] == b"ftyp":
match = _HEIF_EXIF_RE.search(data)
if match is None:
raise ValueError("HEIF EXIF not found in header")
payload = data[match.start():]
else:
metadata = _read_exif_from_bytes(data, None, strict=True)
return {key: metadata[key] for key in ("latitude", "longitude") if key in metadata}
if payload is None:
return {}
from PIL import Image
try:
exif = Image.Exif()
exif.load(payload)
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD)
except Exception:
return {}
lat = _gps_to_decimal(gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF))
lon = _gps_to_decimal(gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF))
return {} if lat is None or lon is None else {"latitude": lat, "longitude": lon}
def _jpeg_exif_segment(data: bytes) -> bytes | None:
pos = 2
while pos + 4 <= len(data):
if data[pos] != 0xFF:
return None
marker = data[pos + 1]
if marker == 0xFF:
pos += 1
continue
if marker in (0xD9, 0xDA):
return None
end = pos + 2 + int.from_bytes(data[pos + 2:pos + 4], "big")
if marker == 0xE1 and data[pos + 4:pos + 10] == b"Exif\x00\x00":
if end > len(data):
raise ValueError("JPEG EXIF segment is truncated")
return data[pos + 4:end]
pos = end
raise ValueError("JPEG header ends before image data")
def _format_nominatim_location(payload: dict[str, Any]) -> str | None:
"""Turn a Nominatim reverse-geocode response into a short label.
@@ -988,8 +1056,12 @@ def _merge_prior_enrichment(
item.location = prev.location
if prev.description and not item.description:
item.description = prev.description
if prev.faces is not None and item.faces is None:
item.faces = prev.faces
if prev.exif_scanned:
item.exif_scanned = True
if prev.face_scanned and prev.faces is not None:
item.face_scanned = True
class AlbumCoordinator(DataUpdateCoordinator):
@@ -1008,6 +1080,13 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.store = store
self.provider: str = entry.data.get(CONF_PROVIDER, PROVIDER_GOOGLE_SHARED)
self._immich_cache_source = None
if self.provider == PROVIDER_IMMICH:
source = [entry.data.get(key) for key in (
CONF_IMMICH_URL, CONF_IMMICH_API_KEY, CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID, CONF_IMMICH_FILTER, CONF_IMMICH_IMAGE_SIZE,
)]
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)
self.recursive: bool = bool(entry.data.get(CONF_RECURSIVE, True))
@@ -1106,7 +1185,29 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise
items = data.get("items") or []
if self.provider in ENRICHING_PROVIDERS and items:
self._apply_google_location_privacy(items)
if (self.google_metadata_enabled or self.google_location_enabled) and items:
prior = (self.data or {}).get("items", []) if isinstance(self.data, dict) else []
if not self._items_cache_loaded:
cached = await self._load_cached_items()
prior = (cached or {}).get("items", []) + prior
self._items_cache_loaded = True
by_id = {item.source_id: item for item in prior if item.source_id}
for item in items:
previous = by_id.get(item.source_id)
if previous is None:
continue
if self.google_metadata_enabled and isinstance(previous.camera_metadata, dict):
item.camera_metadata = dict(previous.camera_metadata)
item.description = previous.description
if self.google_location_enabled and previous.google_location_scanned:
item.latitude = previous.latitude
item.longitude = previous.longitude
item.location = previous.location
item.google_location_scanned = True
self._apply_google_location_privacy(items)
self._schedule_enrichment(data)
elif self.provider in ENRICHING_PROVIDERS and items:
# Carry forward EXIF/geocode metadata for items we've already
# scanned this session; new items get filled in by the
# background worker below.
@@ -1144,13 +1245,57 @@ class AlbumCoordinator(DataUpdateCoordinator):
finally:
self._enrichment_task = None
@property
def google_metadata_enabled(self) -> bool:
options = getattr(getattr(self, "entry", None), "options", None) or {}
return self.provider == PROVIDER_GOOGLE_SHARED and bool(
options.get(CONF_GOOGLE_METADATA, DEFAULT_GOOGLE_METADATA)
)
@property
def google_location_enabled(self) -> bool:
options = getattr(getattr(self, "entry", None), "options", None) or {}
return self.provider == PROVIDER_GOOGLE_SHARED and bool(
options.get(CONF_GOOGLE_LOCATION, DEFAULT_GOOGLE_LOCATION)
)
@property
def google_reverse_geocode_enabled(self) -> bool:
options = getattr(getattr(self, "entry", None), "options", None) or {}
return self.google_location_enabled and bool(
options.get(CONF_REVERSE_GEOCODE, DEFAULT_GOOGLE_REVERSE_GEOCODE)
)
def _apply_google_location_privacy(self, items: list[MediaItem]) -> None:
if getattr(self, "provider", None) != PROVIDER_GOOGLE_SHARED:
return
for item in items:
if not self.google_location_enabled:
item.latitude = None
item.longitude = None
item.location = None
item.google_location_scanned = False
elif not self.google_reverse_geocode_enabled:
item.location = None
def _needs_enrichment(self, item: MediaItem) -> bool:
if self.provider == PROVIDER_GOOGLE_SHARED:
return (
self.google_metadata_enabled and bool(item.source_id) and item.camera_metadata is None
) or (self.google_location_enabled and not item.google_location_scanned)
if not item.exif_scanned:
return True
return self.provider == PROVIDER_IMMICH and not item.face_scanned
def _schedule_enrichment(self, data: dict[str, Any]) -> None:
"""Kick off the background EXIF + geocode worker if there's work."""
items: list[MediaItem] = data.get("items") or []
unscanned = [it for it in items if not it.exif_scanned]
unscanned = [it for it in items if self._needs_enrichment(it)]
# Providers that decrypt/return GPS inline (Ente) have nothing to scan
# but still need the coordinates turned into a place label.
needs_geocode = any(
needs_geocode = (
self.provider != PROVIDER_GOOGLE_SHARED or self.google_reverse_geocode_enabled
) and any(
it.latitude is not None and it.longitude is not None and not it.location
for it in items
)
@@ -1174,7 +1319,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
"geocode_done": 0,
}
self._enrichment_task = self.hass.async_create_background_task(
self._enrich_items_background(data),
self._enrich_google_items_background(data) if self.provider == PROVIDER_GOOGLE_SHARED
else self._enrich_items_background(data),
name=f"album_slideshow_enrich_{self.entry.entry_id}",
)
@@ -1187,6 +1333,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
if not isinstance(payload, dict):
return None
cached_source = payload.get("immich_source")
if cached_source is not None and cached_source != getattr(self, "_immich_cache_source", None):
return None
raw_items = payload.get("items")
if not isinstance(raw_items, list):
return None
@@ -1210,12 +1360,21 @@ class AlbumCoordinator(DataUpdateCoordinator):
location=raw.get("location"),
description=raw.get("description"),
source_id=raw.get("source_id"),
faces=raw.get("faces"),
exif_scanned=bool(raw.get("exif_scanned", False)),
photo_id=raw.get("photo_id"),
face_scanned=bool(raw.get("face_scanned", False))
and isinstance(raw.get("faces"), list),
camera_metadata={
key: value for key, value in raw["camera_metadata"].items()
if key in CAMERA_METADATA_FIELDS
} if isinstance(raw.get("camera_metadata"), dict) else None,
google_location_scanned=bool(raw.get("google_location_scanned", False)),
))
except Exception:
continue
self._apply_google_location_privacy(items)
return {
"title": payload.get("title"),
"items": items,
@@ -1223,6 +1382,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
async def _save_cached_items(self, data: dict[str, Any]) -> None:
items = data.get("items") or []
self._apply_google_location_privacy(items)
# Local-folder URLs are absolute paths on the host; persisting them
# is fine but they don't survive a host reformat. Persist anyway,
# the URL check at load time will skip any stale entries.
@@ -1243,12 +1403,18 @@ class AlbumCoordinator(DataUpdateCoordinator):
"location": it.location,
"description": it.description,
"source_id": it.source_id,
"faces": it.faces,
"exif_scanned": it.exif_scanned,
"photo_id": it.photo_id,
"face_scanned": it.face_scanned,
"camera_metadata": it.camera_metadata,
"google_location_scanned": it.google_location_scanned,
}
for it in items
],
}
if source := getattr(self, "_immich_cache_source", None):
payload["immich_source"] = source
try:
await self._items_cache_store.async_save(payload)
except Exception as err: # pragma: no cover - storage layer
@@ -2168,7 +2334,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
return None, {}
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
"""Fetch Immich metadata and the face boxes used for cropping."""
from . import immich as immich_api
url = self.entry.data.get(CONF_IMMICH_URL)
@@ -2177,23 +2343,164 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.exif_scanned = True
return
client = immich_api.ImmichClient(self.hass, url, api_key)
try:
asset = await client.async_get_asset(item.source_id)
except Exception as err:
_LOGGER.debug("Immich: failed to fetch asset %s: %s", item.source_id, err)
item.exif_scanned = True
person_ids = immich_api.selected_person_ids(
self.entry.data.get(CONF_IMMICH_SELECTION_TYPE),
self.entry.data.get(CONF_IMMICH_SELECTION_ID),
)
requests = {}
needs_faces = not item.face_scanned
if not item.exif_scanned or needs_faces:
requests["asset"] = client.async_get_asset(item.source_id)
if needs_faces:
requests["faces"] = client.async_get_faces(item.source_id)
results = await asyncio.gather(*requests.values(), return_exceptions=True)
asset = None
for kind, result in zip(requests, results):
if isinstance(result, asyncio.CancelledError):
raise result
if isinstance(result, Exception):
_LOGGER.debug(
"Immich: failed to fetch %s for asset %s: %s",
kind,
item.source_id,
result,
)
continue
if kind == "asset":
if not isinstance(result, dict):
continue
asset = result
info = immich_api.parse_asset_exif(result)
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
if "location" in info:
item.location = info["location"]
if "description" in info:
item.description = info["description"]
item.exif_scanned = True
else:
if asset is None:
continue
edits = []
if asset.get("isEdited"):
try:
edits = await client.async_get_asset_edits(item.source_id)
if not edits:
continue
except asyncio.CancelledError:
raise
except Exception:
_LOGGER.debug("Immich: edit metadata unavailable for asset %s", item.source_id)
continue
try:
boxes = immich_api.parse_face_boxes(
result, person_ids, edits=edits,
original_size=immich_api.original_image_size(asset),
)
except (TypeError, ValueError):
_LOGGER.debug("Immich: unsupported face geometry for asset %s", item.source_id)
continue
item.faces = boxes
item.face_scanned = True
async def _enrich_google_items_background(self, data: dict[str, Any]) -> None:
"""Cache optional Google metadata without delaying or failing the playlist."""
from . import google_scraper
jobs = []
for item in data.get("items", []):
if self.google_metadata_enabled and item.source_id and item.camera_metadata is None:
jobs.append((item, False))
if self.google_location_enabled and not item.google_location_scanned:
jobs.append((item, True))
if not jobs:
try:
if self.google_reverse_geocode_enabled:
await self._geocode_items_background(data)
finally:
self._enrich_progress["phase"] = "done"
return
info = immich_api.parse_asset_exif(asset)
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
if "location" in info:
item.location = info["location"]
if "description" in info:
item.description = info["description"]
item.exif_scanned = True
failures = 0
completed = 0
next_request = 0.0
loop = asyncio.get_running_loop()
try:
async with aiohttp.ClientSession(
cookie_jar=aiohttp.DummyCookieJar(), trust_env=False,
) as session:
keys = None
for item, location_job in jobs:
delay = next_request - loop.time()
if delay > 0:
await asyncio.sleep(delay)
try:
if location_job:
header = await google_scraper.fetch_photo_location_header(session, item.url)
try:
metadata = await self.hass.async_add_executor_job(
_read_gps_from_header, header,
)
except Exception as err:
# A photo that cannot be parsed must not stop the scan for the rest.
_LOGGER.debug("Google photo GPS unreadable (%s)", type(err).__name__)
continue
latitude = metadata.get("latitude")
longitude = metadata.get("longitude")
if (
all(type(value) in (int, float) and math.isfinite(value)
for value in (latitude, longitude))
and -90 <= latitude <= 90 and -180 <= longitude <= 180
and (abs(latitude) > 1e-6 or abs(longitude) > 1e-6)
):
item.latitude, item.longitude = latitude, longitude
else:
item.latitude = item.longitude = None
item.location = None
item.google_location_scanned = True
else:
if keys is None:
keys = await google_scraper.fetch_metadata_keys(session, self.album_url)
metadata = await google_scraper.fetch_photo_metadata(
session, keys, item.source_id,
)
item.description = metadata.pop("description", None)
item.camera_metadata = metadata
except asyncio.CancelledError:
raise
except Exception as err:
failures += 1
_LOGGER.debug(
"Google photo metadata unavailable (%s, status=%s)",
type(err).__name__, getattr(err, "status", None),
)
if failures >= 3 or getattr(err, "status", None) in (401, 403, 429):
break
else:
failures = 0
completed += 1
if not self._needs_enrichment(item):
self._enrich_progress["exif_done"] += 1
if completed == 1 or completed % 10 == 0:
await self._save_cached_items(data)
self.async_set_updated_data(data)
finally:
next_request = loop.time() + _GOOGLE_METADATA_INTERVAL
if self.google_reverse_geocode_enabled:
await self._geocode_items_background(data)
except asyncio.CancelledError:
raise
except Exception as err:
_LOGGER.debug("Google photo enrichment stopped (%s)", type(err).__name__)
finally:
self._enrich_progress["phase"] = "done"
await self._save_cached_items(data)
if completed:
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.
@@ -2213,7 +2520,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
scanned_since_save = 0
try:
for idx, item in enumerate(items):
if item.exif_scanned:
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.
@@ -2318,6 +2625,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
the GPS coordinates themselves. Successful labels are written
back into the items in-place and persisted via the items cache.
"""
if getattr(self, "provider", None) == PROVIDER_GOOGLE_SHARED and not self.google_reverse_geocode_enabled:
return
if not bool(
self.entry.options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE)
if hasattr(self.entry, "options") and self.entry.options is not None
@@ -26,9 +26,10 @@ from __future__ import annotations
import json
import logging
import math
import re
from typing import Any
from urllib.parse import quote
from urllib.parse import quote, urlsplit, urlunsplit
from .coordinator import MediaItem
@@ -61,6 +62,7 @@ _SIZE_SUFFIX_RE = re.compile(r"=[wh]\d+(?:-[a-z0-9]+)*$", re.IGNORECASE)
_VIDEO_DURATION_KEY = 76647426 # presence indicates a video; we skip those
_LIVEPHOTO_KEY = 146008172
_LOCATION_HEADER_BYTES = 256 * 1024
class _AlbumKeys:
@@ -129,6 +131,146 @@ async def fetch_album(
return title, items, video_keys
async def fetch_metadata_keys(session, share_url: str, *, timeout: float = 15.0):
"""Read public share keys for optional enrichment, without changing pagination."""
async with session.get(
share_url, headers={"User-Agent": _BROWSER_UA}, timeout=timeout,
allow_redirects=True,
) as response:
html = await _read_metadata_body(response, 4 * 1024 * 1024)
keys = _extract_keys(html)
if keys is None:
raise ValueError("Public Google album metadata keys unavailable")
return keys
async def fetch_photo_location_header(
session, image_url: str, *, timeout: float = 15.0,
) -> bytes:
"""Read a bounded original-image prefix for explicitly enabled GPS extraction."""
parsed = urlsplit(image_url)
if (
parsed.scheme != "https"
or not (parsed.hostname or "").endswith(".googleusercontent.com")
or parsed.port not in (None, 443)
or parsed.username is not None
or parsed.password is not None
):
raise ValueError("Unexpected Google image URL")
original = urlunsplit(parsed._replace(
path=_SIZE_SUFFIX_RE.sub("", parsed.path) + "=d", fragment="",
))
async with session.get(
original, headers={"Range": f"bytes=0-{_LOCATION_HEADER_BYTES - 1}"},
timeout=timeout, allow_redirects=False,
) as response:
response.raise_for_status()
if response.status not in (200, 206):
raise ValueError("Unexpected Google image response")
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].lower()
if not content_type.startswith("image/") and content_type != "application/octet-stream":
raise ValueError("Google image response is not an image")
if response.status == 206 and not re.fullmatch(
r"bytes 0-\d+/(?:\d+|\*)", response.headers.get("Content-Range", ""),
):
raise ValueError("Unexpected Google image byte range")
content = bytearray()
async for chunk in response.content.iter_chunked(32768):
content.extend(chunk[:_LOCATION_HEADER_BYTES - len(content)])
if len(content) >= _LOCATION_HEADER_BYTES:
break
return bytes(content)
async def fetch_photo_metadata(
session, keys: _AlbumKeys, media_key: str, *, timeout: float = 15.0,
) -> dict[str, Any]:
"""Read camera metadata and description from Google's public photo endpoint."""
payload = [media_key, None, keys.auth_key, None, keys.album_key]
envelope = json.dumps([[["VrseUb", json.dumps(payload), None, "generic"]]])
endpoint = "https://photos.google.com/u/0/_/PhotosUi/data/batchexecute"
headers = {
"User-Agent": _BROWSER_UA,
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Origin": "https://photos.google.com",
"Referer": f"https://photos.google.com/share/{keys.album_key}?key={keys.auth_key}",
}
async with session.post(
endpoint, params={"rpcids": "VrseUb", "source-path": f"/share/{keys.album_key}"},
headers=headers, data=f"f.req={quote(envelope)}", timeout=timeout,
allow_redirects=False,
) as response:
body = await _read_metadata_body(response, 1024 * 1024)
for line in body.splitlines():
try:
entries = json.loads(line)
except ValueError:
continue
if not isinstance(entries, list):
continue
for entry in entries:
if (
isinstance(entry, list) and len(entry) >= 3
and entry[:2] == ["wrb.fr", "VrseUb"] and isinstance(entry[2], str)
):
detail = json.loads(entry[2])
if _metadata_value(detail, 0, 0) != media_key:
raise ValueError("Google photo metadata identity mismatch")
return parse_photo_metadata(detail)
raise ValueError("Google photo metadata response unavailable")
async def _read_metadata_body(response, limit: int) -> str:
response.raise_for_status()
if response.status != 200:
raise ValueError("Unexpected Google metadata response")
chunks = []
length = 0
async for chunk in response.content.iter_chunked(65536):
length += len(chunk)
if length > limit:
raise ValueError("Google metadata response exceeds size limit")
chunks.append(chunk)
return b"".join(chunks).decode("utf-8")
def _metadata_value(value: Any, *indices: int) -> Any:
for index in indices:
if not isinstance(value, list) or index >= len(value):
return None
value = value[index]
return value
def parse_photo_metadata(detail: Any) -> dict[str, Any]:
"""Extract the camera and description fields verified in public responses."""
photo = _metadata_value(detail, 0)
if not isinstance(photo, list):
raise ValueError("Invalid Google photo metadata")
camera = _metadata_value(detail, 0, 1, 8, 4)
result: dict[str, Any] = {}
for field, index in (("camera_make", 0), ("camera_model", 1)):
value = _metadata_value(camera, index)
if isinstance(value, str) and value.strip():
result[field] = value.strip()[:128]
for field, index in (
("focal_length_mm", 3), ("aperture_f_number", 4),
("iso", 5), ("exposure_time_seconds", 6),
):
value = _metadata_value(camera, index)
if type(value) in (int, float) and math.isfinite(value) and value > 0:
if field != "iso" or float(value).is_integer():
result[field] = int(value) if field == "iso" else float(value)
for section in photo:
if not isinstance(section, dict):
continue
description = _metadata_value(section.get("396644657", section.get(396644657)), 0)
if isinstance(description, str) and description.strip():
result["description"] = description.strip()
break
return result
# -- Internals ---------------------------------------------------------------
async def _fetch_album_keys(
@@ -398,13 +540,13 @@ def _parse_album_item(raw: Any) -> MediaItem | None:
)
# Plausible epoch-ms range: 2000-01-01 to 2100-01-01.
_MIN_TS_MS = 946_684_800_000
# Plausible epoch-ms range: 1800-01-01 to 2100-01-01.
_MIN_TS_MS = -5_364_662_400_000
_MAX_TS_MS = 4_102_444_800_000
def _looks_like_timestamp_ms(value: Any) -> bool:
return isinstance(value, int) and _MIN_TS_MS <= value <= _MAX_TS_MS
return type(value) is int and _MIN_TS_MS <= value <= _MAX_TS_MS
# -- AF block parsing (for the initial 300 items embedded in the HTML) ------
@@ -2,8 +2,10 @@ from __future__ import annotations
import io
import logging
from dataclasses import dataclass
from typing import NamedTuple
from PIL import Image, ImageColor, ImageFilter, ImageOps
from PIL import Image, ImageColor, ImageDraw, ImageFilter, ImageFont, ImageOps
from .coordinator import MediaItem
@@ -14,6 +16,42 @@ FILL_COVER = "cover"
FILL_CONTAIN = "contain"
FILL_BLUR = "blur"
class FaceBox(NamedTuple):
left: float
top: float
right: float
bottom: float
weight: float
selected: bool = False
@dataclass(frozen=True)
class CropHints:
"""Per-photo inputs for face-aware cropping and the debug overlay.
``faces`` is None when no face data is known for the photo (not an
Immich source, or not scanned yet) and an empty tuple when the photo
was scanned and has no faces.
"""
faces: tuple[FaceBox, ...] | None = None
debug: bool = False
name: str | None = None
# Padding around a face box, as a fraction of the face's own size, so the
# crop keeps the whole head rather than just the tight face box.
_FACE_PAD_SIDE = 0.3
_FACE_PAD_TOP = 0.6
_FACE_PAD_BOTTOM = 0.3
# A face that is cut by the crop edge counts this much worse than one that is
# left out entirely: half a face looks like a mistake, a missing face doesn't.
_PARTIAL_PENALTY = 1.5
FACE_KEPT = "kept"
FACE_CUT = "cut"
FACE_DROPPED = "dropped"
# Absolute pixel ceiling. A 20000x20000 JPEG decodes to ~1.2 GB of RGB; Pillow
# raises DecompressionBombError above MAX_IMAGE_PIXELS. We set this high enough
# that 4K+ sources still decode, but reject anything absurd to protect
@@ -120,13 +158,20 @@ def resolve_output_size(
return (width, height)
def render_image(img: Image.Image, fill_mode: str, width: int, height: int) -> Image.Image:
def render_image(
img: Image.Image,
fill_mode: str,
width: int,
height: int,
hints: CropHints | None = None,
) -> Image.Image:
"""Render img into a (width x height) canvas using the given fill mode."""
hints = hints or CropHints()
if fill_mode == FILL_CONTAIN:
return _resize_contain(img, width, height)
return _resize_contain(img, width, height, hints=hints)
if fill_mode == FILL_BLUR:
return _blur_fill(img, width, height)
return _resize_cover(img, width, height)
return _blur_fill(img, width, height, hints=hints)
return _resize_cover(img, width, height, hints)
def pair_images(
@@ -139,6 +184,8 @@ def pair_images(
divider: int,
divider_fill: tuple[int, int, int] | tuple[int, int, int, int],
transparent_divider: bool,
hints1: CropHints | None = None,
hints2: CropHints | None = None,
) -> Image.Image:
canvas_mode = "RGBA" if transparent_divider else "RGB"
canvas = Image.new(canvas_mode, (target_w, target_h), divider_fill)
@@ -146,8 +193,8 @@ def pair_images(
if portrait_canvas:
top_h = max(1, (target_h - divider) // 2)
bottom_h = max(1, target_h - divider - top_h)
top_img = render_image(img1, fill_mode, target_w, top_h)
bottom_img = render_image(img2, fill_mode, target_w, bottom_h)
top_img = render_image(img1, fill_mode, target_w, top_h, hints1)
bottom_img = render_image(img2, fill_mode, target_w, bottom_h, hints2)
canvas.paste(top_img.convert(canvas_mode), (0, 0))
canvas.paste(bottom_img.convert(canvas_mode), (0, top_h + divider))
safe_close(top_img)
@@ -156,8 +203,8 @@ def pair_images(
left_w = max(1, (target_w - divider) // 2)
right_w = max(1, target_w - divider - left_w)
left_img = render_image(img1, fill_mode, left_w, target_h)
right_img = render_image(img2, fill_mode, right_w, target_h)
left_img = render_image(img1, fill_mode, left_w, target_h, hints1)
right_img = render_image(img2, fill_mode, right_w, target_h, hints2)
canvas.paste(left_img.convert(canvas_mode), (0, 0))
canvas.paste(right_img.convert(canvas_mode), (left_w + divider, 0))
safe_close(left_img)
@@ -252,7 +299,131 @@ def _parse_aspect_ratio(ratio: str) -> tuple[int, int]:
return (16, 9)
def _resize_cover(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
def _padded_face(face: FaceBox) -> tuple[float, float, float, float]:
x1, y1, x2, y2 = face[:4]
fw, fh = x2 - x1, y2 - y1
return (
max(0.0, x1 - fw * _FACE_PAD_SIDE),
max(0.0, y1 - fh * _FACE_PAD_TOP),
min(1.0, x2 + fw * _FACE_PAD_SIDE),
min(1.0, y2 + fh * _FACE_PAD_BOTTOM),
)
def _span_status(a: float, b: float, offset: float, window: float) -> str:
# One pixel of slack absorbs rounding when the image is scaled to fit
# an axis exactly.
if a >= offset - 1 and b <= offset + window + 1:
return FACE_KEPT
if b <= offset or a >= offset + window:
return FACE_DROPPED
return FACE_CUT
def choose_crop_offset(
spans: list[tuple[float, float, float]],
src_len: float,
window: float,
*,
selected: list[bool] | None = None,
padded_spans: list[tuple[float, float]] | None = None,
) -> float:
"""Pick where a crop window of ``window`` px starts along one axis.
``spans`` are unpadded ``(start, end, weight)`` face intervals. Selected
people rank ahead of all bystanders. Keep whole faces where possible,
retain visible face area otherwise, and use padding as a preference.
"""
max_offset = max(0.0, src_len - window)
centred = max_offset / 2
if max_offset <= 0 or not spans:
return centred
def clamp(value: float) -> float:
return max(0.0, min(max_offset, value))
priorities = selected if selected is not None else [False] * len(spans)
padding = padded_spans if padded_spans is not None else [span[:2] for span in spans]
def visible_fraction(start: float, end: float, offset: float) -> float:
overlap = max(0.0, min(end, offset + window) - max(start, offset))
return overlap / (end - start)
def score(offset: float) -> tuple[float, ...]:
tiers = []
for priority in (True, False):
kept_weight = cut_weight = visible_weight = 0.0
for (start, end, weight), is_selected in zip(spans, priorities):
if is_selected != priority:
continue
state = _span_status(start, end, offset, window)
if state == FACE_KEPT:
kept_weight += weight
elif state == FACE_CUT:
cut_weight += weight
visible_weight += weight * visible_fraction(start, end, offset)
tiers.extend((
kept_weight,
-_PARTIAL_PENALTY * cut_weight if kept_weight else visible_weight,
))
tiers.append(sum(
span[2] * visible_fraction(start, end, offset)
for span, (start, end) in zip(spans, padding)
))
return tuple(round(value, 12) for value in tiers)
candidates = {0.0, centred, max_offset}
for start, end in [span[:2] for span in spans] + padding:
for offset in (start, end - window, end, start - window, (start + end - window) / 2):
candidates.add(clamp(offset))
best = max(candidates, key=lambda offset: (score(offset), -abs(offset - centred), -offset))
kept = [
index for index, (start, end, _weight) in enumerate(spans)
if _span_status(start, end, best, window) == FACE_KEPT
]
if not kept:
return best
group_start = min(padding[index][0] for index in kept)
group_end = max(padding[index][1] for index in kept)
if group_end - group_start > window:
group_start = min(spans[index][0] for index in kept)
group_end = max(spans[index][1] for index in kept)
target = clamp((group_start + group_end - window) / 2)
candidates.add(target)
return max(candidates, key=lambda offset: (score(offset), -abs(offset - target), -offset))
def _face_statuses(
faces: tuple[FaceBox, ...],
new_w: int,
new_h: int,
left: int,
top: int,
target_w: int,
target_h: int,
) -> list[str]:
statuses = []
for face in faces:
px1, py1, px2, py2 = face[:4]
x = _span_status(px1 * new_w, px2 * new_w, left, target_w)
y = _span_status(py1 * new_h, py2 * new_h, top, target_h)
if FACE_DROPPED in (x, y):
statuses.append(FACE_DROPPED)
elif FACE_CUT in (x, y):
statuses.append(FACE_CUT)
else:
statuses.append(FACE_KEPT)
return statuses
def _resize_cover(
img: Image.Image,
target_w: int,
target_h: int,
hints: CropHints | None = None,
) -> Image.Image:
hints = hints or CropHints()
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
return img.resize((target_w, target_h))
@@ -260,15 +431,137 @@ def _resize_cover(img: Image.Image, target_w: int, target_h: int) -> Image.Image
new_w = max(1, int(round(src_w * scale)))
new_h = max(1, int(round(src_h * scale)))
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
left = max(0, int(round((new_w - target_w) / 2)))
top = max(0, int(round((new_h - target_h) / 2)))
faces = tuple(FaceBox(*face) for face in hints.faces or ())
padded = [_padded_face(face) for face in faces]
left = choose_crop_offset(
[(face.left * new_w, face.right * new_w, face.weight) for face in faces],
new_w,
target_w,
selected=[face.selected for face in faces],
padded_spans=[(box[0] * new_w, box[2] * new_w) for box in padded],
)
top = choose_crop_offset(
[(face.top * new_h, face.bottom * new_h, face.weight) for face in faces],
new_h,
target_h,
selected=[face.selected for face in faces],
padded_spans=[(box[1] * new_h, box[3] * new_h) for box in padded],
)
left = max(0, min(max(0, new_w - target_w), int(round(left))))
top = max(0, min(max(0, new_h - target_h), int(round(top))))
statuses = _face_statuses(faces, new_w, new_h, left, top, target_w, target_h)
summary = _face_summary(hints.faces, statuses)
if faces:
_LOGGER.debug("Crop %s: %s", hints.name or "photo", summary)
if hints.debug:
_draw_debug_marks(resized, faces, statuses)
cropped = resized.crop((left, top, left + target_w, top + target_h))
if cropped is not resized:
safe_close(resized)
if hints.debug:
_draw_offscreen_centre(cropped, new_w / 2 - left, new_h / 2 - top)
_draw_debug_label(cropped, summary)
return cropped
def _resize_contain(img: Image.Image, target_w: int, target_h: int, bg=(0, 0, 0)) -> Image.Image:
def _face_summary(faces: tuple[FaceBox, ...] | None, statuses: list[str]) -> str:
if faces is None:
return "no face data"
return (
f"faces {len(statuses)} · kept {statuses.count(FACE_KEPT)} · "
f"cut {statuses.count(FACE_CUT)} · dropped {statuses.count(FACE_DROPPED)}"
)
_DEBUG_COLORS = {
FACE_KEPT: (0, 220, 0),
FACE_CUT: (255, 40, 40),
FACE_DROPPED: (160, 160, 160),
}
_CROSSHAIR_COLOR = (255, 220, 0)
def _draw_debug_marks(
img: Image.Image, faces: tuple[FaceBox, ...], statuses: list[str]
) -> None:
"""Draw face boxes and a crosshair on the photo's own centre.
Drawn before cropping, so the marks get cut exactly like the photo: a
crosshair that is off-centre or missing shows how much was cropped away.
"""
w, h = img.size
draw = ImageDraw.Draw(img)
line = max(2, round(min(w, h) / 200))
for face, status in zip(faces, statuses):
color = _DEBUG_COLORS[status]
x1, y1, x2, y2 = face[:4]
draw.rectangle((x1 * w, y1 * h, x2 * w, y2 * h), outline=color, width=line)
px1, py1, px2, py2 = _padded_face(face)
draw.rectangle(
(px1 * w, py1 * h, px2 * w, py2 * h),
outline=color,
width=max(1, line // 2),
)
cx, cy = w / 2, h / 2
arm = max(12, round(min(w, h) / 12))
for width, color in ((line + 2, (0, 0, 0)), (line, _CROSSHAIR_COLOR)):
draw.line((cx - arm, cy, cx + arm, cy), fill=color, width=width)
draw.line((cx, cy - arm, cx, cy + arm), fill=color, width=width)
radius = arm / 3
draw.ellipse(
(cx - radius, cy - radius, cx + radius, cy + radius),
outline=_CROSSHAIR_COLOR,
width=line,
)
def _draw_offscreen_centre(img: Image.Image, cx: float, cy: float) -> None:
"""When the photo's centre was cropped away, point at it from the edge."""
w, h = img.size
if 0 <= cx <= w and 0 <= cy <= h:
return
size = max(10, round(min(w, h) / 20))
x = max(size, min(w - size, cx))
y = max(size, min(h - size, cy))
if cx < 0:
points = [(0, y), (size, y - size), (size, y + size)]
elif cx > w:
points = [(w, y), (w - size, y - size), (w - size, y + size)]
elif cy < 0:
points = [(x, 0), (x - size, size), (x + size, size)]
else:
points = [(x, h), (x - size, h - size), (x + size, h - size)]
ImageDraw.Draw(img).polygon(points, fill=_CROSSHAIR_COLOR, outline=(0, 0, 0))
def _debug_font(size: int):
try:
return ImageFont.load_default(size=size)
except TypeError: # Pillow < 10.1 has a fixed-size default font
return ImageFont.load_default()
def _draw_debug_label(img: Image.Image, text: str) -> None:
w, h = img.size
draw = ImageDraw.Draw(img)
size = max(12, round(min(w, h) / 30))
font = _debug_font(size)
pad = max(4, size // 3)
x1, y1, x2, y2 = draw.textbbox((pad, pad), text, font=font)
draw.rectangle((x1 - pad, y1 - pad, x2 + pad, y2 + pad), fill=(0, 0, 0))
draw.text((pad, pad), text, fill=(255, 255, 255), font=font)
def _resize_contain(
img: Image.Image,
target_w: int,
target_h: int,
bg=(0, 0, 0),
hints: CropHints | None = None,
) -> Image.Image:
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
return img.resize((target_w, target_h))
@@ -276,6 +569,7 @@ def _resize_contain(img: Image.Image, target_w: int, target_h: int, bg=(0, 0, 0)
new_w = max(1, int(src_w * scale))
new_h = max(1, int(src_h * scale))
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
_debug_uncropped(resized, hints)
canvas = Image.new("RGB", (target_w, target_h), bg)
rgb_resized = resized if resized.mode == "RGB" else resized.convert("RGB")
canvas.paste(rgb_resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
@@ -285,7 +579,22 @@ def _resize_contain(img: Image.Image, target_w: int, target_h: int, bg=(0, 0, 0)
return canvas
def _blur_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
def _debug_uncropped(img: Image.Image, hints: CropHints | None) -> None:
"""Debug marks for fill modes that never crop: every face is kept."""
if hints is None or not hints.debug:
return
faces = hints.faces or ()
statuses = [FACE_KEPT] * len(faces)
_draw_debug_marks(img, faces, statuses)
_draw_debug_label(img, _face_summary(hints.faces, statuses))
def _blur_fill(
img: Image.Image,
target_w: int,
target_h: int,
hints: CropHints | None = None,
) -> Image.Image:
bg = _resize_cover(img, target_w, target_h).filter(ImageFilter.GaussianBlur(radius=24))
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
@@ -294,10 +603,10 @@ def _blur_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
new_w = max(1, int(src_w * scale))
new_h = max(1, int(src_h * scale))
fg = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
_debug_uncropped(fg, hints)
rgb_fg = fg if fg.mode == "RGB" else fg.convert("RGB")
bg.paste(rgb_fg, ((target_w - new_w) // 2, (target_h - new_h) // 2))
if rgb_fg is not fg:
safe_close(rgb_fg)
safe_close(fg)
return bg
+207
View File
@@ -14,12 +14,15 @@ API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
``originalFileName`` but NOT ``exifInfo``.
- ``GET /api/assets/{id}`` -> full asset incl ``exifInfo`` (lat/long, city,
country, description) - used to enrich location/description per asset.
- ``GET /api/faces?id={id}`` -> recognised faces and bounding boxes - used to
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).
"""
from __future__ import annotations
import json
import math
from datetime import datetime, timezone
from typing import Any
@@ -241,6 +244,198 @@ def parse_asset_exif(asset: Any) -> dict[str, Any]:
return out
def selected_person_ids(
selection_type: str | None, selection_id: str | None
) -> set[str]:
"""Return the people explicitly selected for an Immich source.
Current entries use a composite JSON selection, while older entries can
still carry the legacy ``person``/``people`` shapes. Album-only, favorite,
random and custom-search sources intentionally return an empty set: there
is no user-selected face to prioritise for those sources.
"""
if selection_type == "person":
return {selection_id} if selection_id else set()
if selection_type == "people":
return {person_id for person_id in (selection_id or "").split(",") if person_id}
if selection_type == "composite":
return set(parse_composite_selection(selection_id)["people"])
return set()
def _normalised_face_box(
face: Any,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> tuple[float, float, float, float] | None:
"""Return a face's bounding box normalised to 0..1, or None if invalid."""
if not isinstance(face, dict):
return None
width = face.get("imageWidth")
height = face.get("imageHeight")
coords = (
face.get("boundingBoxX1"),
face.get("boundingBoxY1"),
face.get("boundingBoxX2"),
face.get("boundingBoxY2"),
)
if (
any(isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) for value in (width, height, *coords))
or width <= 0
or height <= 0
):
return None
x1, y1, x2, y2 = coords
if x2 <= x1 or y2 <= y1:
return None
box = _original_face_box(
(x1 / width, y1 / height, x2 / width, y2 / height),
edits or [], original_size,
)
box = tuple(max(0.0, min(1.0, value)) for value in box)
return box if box[2] > box[0] and box[3] > box[1] else None
def parse_face_boxes(
faces: Any,
person_ids: set[str] | None = None,
*,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> list[list[float | bool]]:
"""Return normalised ``[x1, y1, x2, y2, area, selected]`` face boxes.
Immich reports face boxes in the coordinate space described by each
face's ``imageWidth``/``imageHeight``. Normalising each box makes it
independent of whether Album Slideshow downloads a preview, full-size
derivative or original. Faces Immich has not linked to a named person
are included. Explicit person selection is a separate priority from area.
"""
if not isinstance(faces, list):
return []
boxes: list[list[float | bool]] = []
for face in faces:
box = _normalised_face_box(face, edits, original_size)
if box is None:
continue
x1, y1, x2, y2 = box
weight = (x2 - x1) * (y2 - y1)
person = face.get("person")
selected = bool(
person_ids and isinstance(person, dict) and person.get("id") in person_ids
)
boxes.append([x1, y1, x2, y2, weight, selected])
return boxes
def _original_face_box(
box: tuple[float, float, float, float],
edits: list[dict[str, Any]],
original_size: tuple[float, float] | None,
) -> tuple[float, float, float, float]:
points = [(box[0], box[1]), (box[2], box[3])]
crop = None
for edit in reversed(edits):
if not isinstance(edit, dict) or not isinstance(edit.get("parameters"), dict):
raise ValueError("Invalid Immich edit metadata")
params = edit["parameters"]
action = edit.get("action")
if action == "rotate":
angle = params.get("angle")
if angle == 90:
points = [(vertical, 1 - horizontal) for horizontal, vertical in points]
elif angle == 180:
points = [(1 - horizontal, 1 - vertical) for horizontal, vertical in points]
elif angle == 270:
points = [(1 - vertical, horizontal) for horizontal, vertical in points]
elif angle != 0:
raise ValueError("Unsupported Immich rotation")
elif action == "mirror":
axis = params.get("axis")
if axis == "horizontal":
points = [(horizontal, 1 - vertical) for horizontal, vertical in points]
elif axis == "vertical":
points = [(1 - horizontal, vertical) for horizontal, vertical in points]
else:
raise ValueError("Unsupported Immich mirror axis")
elif action == "crop" and crop is None:
crop = params
else:
raise ValueError("Unsupported Immich edit")
if crop is not None:
values = [crop.get(name) for name in ("x", "y", "width", "height")]
if original_size is None or any(
isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) for value in values
):
raise ValueError("Missing Immich crop dimensions")
crop_left, crop_top, crop_width, crop_height = values
original_width, original_height = original_size
if (
crop_left < 0 or crop_top < 0 or crop_width <= 0 or crop_height <= 0
or crop_left + crop_width > original_width
or crop_top + crop_height > original_height
):
raise ValueError("Invalid Immich crop dimensions")
points = [
((horizontal * crop_width + crop_left) / original_width,
(vertical * crop_height + crop_top) / original_height)
for horizontal, vertical in points
]
return (
min(point[0] for point in points), min(point[1] for point in points),
max(point[0] for point in points), max(point[1] for point in points),
)
def original_image_size(asset: Any) -> tuple[float, float] | None:
info = asset.get("exifInfo") if isinstance(asset, dict) else None
if not isinstance(info, dict):
return None
width, height = info.get("exifImageWidth"), info.get("exifImageHeight")
if any(
isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) or value <= 0 for value in (width, height)
):
return None
if str(info.get("orientation")) in {"5", "6", "7", "8", "-90", "90"}:
return (float(height), float(width))
return (float(width), float(height))
def parse_face_focus(
faces: Any,
person_ids: set[str],
*,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> tuple[float, float] | None:
"""Return a normalised crop focus for the selected people in an asset.
Immich reports face boxes in the coordinate space described by each
face's ``imageWidth``/``imageHeight``. Normalising each box makes the
focus independent of resolution. Edited coordinates are mapped back to the
unedited image before clamping. When several selected people appear in the
same photo, focus on the centre of their combined region.
"""
if not isinstance(faces, list) or not person_ids:
return None
selected_faces = [
face for face in faces
if isinstance(face, dict) and isinstance(face.get("person"), dict)
and face["person"].get("id") in person_ids
]
boxes = parse_face_boxes(selected_faces, edits=edits, original_size=original_size)
if not boxes:
return None
left = min(box[0] for box in boxes)
top = min(box[1] for box in boxes)
right = max(box[2] for box in boxes)
bottom = max(box[3] for box in boxes)
return ((left + right) / 2, (top + bottom) / 2)
class ImmichClient:
"""Thin async wrapper over the Immich REST API."""
@@ -373,3 +568,15 @@ 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_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):
raise ValueError("Invalid Immich face response")
return data
async def async_get_asset_edits(self, asset_id: str) -> list[dict[str, Any]]:
data = await self._get(f"/api/assets/{asset_id}/edits")
if not isinstance(data, dict) or not isinstance(data.get("edits"), list):
raise ValueError("Invalid Immich edit response")
return data["edits"]
@@ -7,6 +7,6 @@
"documentation": "https://github.com/eyalgal/album_slideshow",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow", "PyNaCl>=1.5.0"],
"version": "1.11.0"
"requirements": ["PyNaCl>=1.5.0"],
"version": "1.13.0"
}
+63 -1
View File
@@ -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
from .const import DOMAIN, MAX_CUSTOM_LOOKBACK_DAYS
from .coordinator import AlbumCoordinator
from .store import SlideshowStore
@@ -23,6 +23,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
PairMinGapPercentNumber(entry, store),
NavigationBufferSizeNumber(entry, store),
ImageCacheMbNumber(entry, store),
CustomLookbackDaysNumber(entry, store),
ShuffleAgeBiasNumber(entry, store),
]
)
@@ -49,6 +51,66 @@ class _BaseNumber(NumberEntity, RestoreEntity):
}
class CustomLookbackDaysNumber(_BaseNumber):
_attr_icon = "mdi:calendar-range"
_attr_native_min_value = 1
_attr_native_max_value = MAX_CUSTOM_LOOKBACK_DAYS
_attr_native_step = 1
_attr_native_unit_of_measurement = "d"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_custom_lookback_days"
self._attr_name = "Custom lookback days"
@property
def native_value(self):
return int(self.store.custom_lookback_days)
async def async_set_native_value(self, value: float) -> None:
self.store.custom_lookback_days = max(1, min(MAX_CUSTOM_LOOKBACK_DAYS, 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 ShuffleAgeBiasNumber(_BaseNumber):
_attr_icon = "mdi:shuffle-variant"
_attr_native_min_value = -100
_attr_native_max_value = 100
_attr_native_step = 1
_attr_mode = NumberMode.SLIDER
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_shuffle_age_bias"
self._attr_name = "Shuffle age bias"
@property
def native_value(self):
return int(self.store.shuffle_age_bias)
async def async_set_native_value(self, value: float) -> None:
self.store.shuffle_age_bias = max(-100, min(100, 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
+45 -1
View File
@@ -7,9 +7,11 @@ implementation. Operates on ``MediaItem``-like objects that expose
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import math
from typing import Iterable, TypeVar
from .const import (
DATE_FILTER_CUSTOM,
DATE_FILTER_LAST_7,
DATE_FILTER_LAST_30,
DATE_FILTER_LAST_365,
@@ -17,7 +19,9 @@ from .const import (
DATE_FILTER_ON_THIS_DAY,
DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR,
DEFAULT_CUSTOM_LOOKBACK_DAYS,
DEFAULT_MISSING_DATE_MODE,
MAX_CUSTOM_LOOKBACK_DAYS,
MISSING_DATE_EXCLUDE,
MISSING_DATE_USE_UPLOADED,
ORDER_ALBUM,
@@ -31,6 +35,39 @@ from .const import (
T = TypeVar("T")
def age_weights(
items: list[T],
bias: float,
*,
missing_date: str = DEFAULT_MISSING_DATE_MODE,
) -> list[float]:
"""Return relative draw weights, from 1 to 10, favoring newer (+) or older (-)."""
if isinstance(bias, bool) or not isinstance(bias, (int, float)) or not math.isfinite(bias):
bias = 0
bias = max(-100, min(100, bias))
if not bias:
return [1.0] * len(items)
timestamps = []
for item in items:
timestamp = getattr(item, "captured_at", None)
if type(timestamp) is not int and missing_date == MISSING_DATE_USE_UPLOADED:
timestamp = getattr(item, "uploaded_at", None)
timestamps.append(timestamp if type(timestamp) is int else None)
dated = [timestamp for timestamp in timestamps if timestamp is not None]
if not dated or min(dated) == max(dated):
return [1.0] * len(items)
oldest, newest = min(dated), max(dated)
strength = 9 * abs(bias) / 100
weights = []
for timestamp in timestamps:
if timestamp is None:
weights.append(1.0)
continue
position = (timestamp - oldest) / (newest - oldest)
weights.append(1 + strength * (position if bias > 0 else 1 - position))
return weights
def order_items(items: list[T], order_mode: str) -> list[T]:
"""Return a new list ordered per ``order_mode``.
@@ -76,6 +113,7 @@ def filter_items(
*,
mode: str,
missing_date: str = DEFAULT_MISSING_DATE_MODE,
lookback_days: int = DEFAULT_CUSTOM_LOOKBACK_DAYS,
now: datetime | None = None,
) -> list[T]:
"""Filter items by date according to ``mode``.
@@ -97,7 +135,7 @@ def filter_items(
today_utc = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
pred, strict = _build_predicate(mode, today_utc)
pred, strict = _build_predicate(mode, today_utc, lookback_days)
if pred is None:
return list(items)
@@ -130,8 +168,14 @@ def filter_items(
def _build_predicate(
mode: str,
today_utc: datetime,
lookback_days: int = DEFAULT_CUSTOM_LOOKBACK_DAYS,
):
"""Return (predicate, strict). ``strict`` drops items without timestamps."""
if mode == DATE_FILTER_CUSTOM:
days = lookback_days if type(lookback_days) is int else DEFAULT_CUSTOM_LOOKBACK_DAYS
days = max(1, min(MAX_CUSTOM_LOOKBACK_DAYS, days))
cutoff = int((today_utc - timedelta(days=days)).timestamp() * 1000)
return (lambda timestamp: timestamp >= cutoff), False
if mode == DATE_FILTER_LAST_7:
cutoff = int((today_utc - timedelta(days=7)).timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
+5 -1
View File
@@ -22,7 +22,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
CacheUsageSensor(entry, coordinator),
HiddenPhotoCountSensor(entry, coordinator),
]
if coordinator.provider in ENRICHING_PROVIDERS:
if (
coordinator.provider in ENRICHING_PROVIDERS
or coordinator.google_metadata_enabled
or coordinator.google_location_enabled
):
# Diagnostic surface for the background enrichment pass (per-photo
# metadata reads and/or reverse-geocode). Providers with nothing to
# enrich omit it to keep the device screen tidy.
@@ -13,6 +13,7 @@ from .const import (
DEFAULT_FILL_MODE,
DEFAULT_ORIENTATION_MISMATCH_MODE,
DEFAULT_ORDER_MODE,
DEFAULT_SHUFFLE_AGE_BIAS,
DEFAULT_ASPECT_RATIO,
DEFAULT_PAIR_DIVIDER_PX,
DEFAULT_PAIR_DIVIDER_COLOR,
@@ -21,6 +22,7 @@ from .const import (
DEFAULT_NAVIGATION_BUFFER_SIZE,
DEFAULT_MAX_RESOLUTION,
DEFAULT_DATE_FILTER,
DEFAULT_CUSTOM_LOOKBACK_DAYS,
DEFAULT_MISSING_DATE_MODE,
)
@@ -35,6 +37,7 @@ class SlideshowStore:
fill_mode: str = DEFAULT_FILL_MODE
portrait_mode: str = DEFAULT_ORIENTATION_MISMATCH_MODE
order_mode: str = DEFAULT_ORDER_MODE
shuffle_age_bias: int = DEFAULT_SHUFFLE_AGE_BIAS
aspect_ratio: str = DEFAULT_ASPECT_RATIO
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
@@ -45,6 +48,7 @@ class SlideshowStore:
# Date filter mode (preset windows like this_year / on_this_day).
date_filter: str = DEFAULT_DATE_FILTER
custom_lookback_days: int = DEFAULT_CUSTOM_LOOKBACK_DAYS
# How the date filter treats photos with no EXIF capture date.
missing_date_mode: str = DEFAULT_MISSING_DATE_MODE
@@ -52,6 +56,9 @@ class SlideshowStore:
# Pause toggle - when True, the slideshow holds on the current frame.
paused: bool = False
# Draw face boxes, a centre crosshair and a crop summary on each slide.
face_debug: bool = False
# In-memory last rendered frame. Not user-configurable; used to re-serve
# the previous slide instantly across a camera reload.
last_frame: bytes | None = None
+38 -2
View File
@@ -172,19 +172,55 @@
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #."
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #.",
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
}
},
"options": {
"step": {
"google_metadata": {
"title": "Google Photos metadata and privacy",
"description": "Photo descriptions, camera details, and exposure metadata use Google's undocumented public endpoint without a Google login. Results are cached; unavailable metadata does not prevent the slideshow from loading.\n\nOriginal photo GPS is a separate opt-in, off by default. It reads up to 256 KB per photo. Google only includes GPS in shared originals when location sharing is turned on for the album; anyone with the album link can then see precise GPS. After changing the album's location sharing, turn this off and back on to rescan. Coordinates are cached in Home Assistant and exposed in camera attributes. Google-added or estimated locations are not included.\n\nPlace-name lookup is also off by default. It requires original GPS reading and sends coordinates to the public OpenStreetMap Nominatim service. Disabling original GPS hides coordinates and place names and stops both lookups; it does not erase previous history or backups.",
"data": {
"google_metadata": "Fetch photo metadata (experimental)",
"google_location": "Read original photo GPS (opt-in)",
"reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)"
}
},
"init": {
"title": "Location & privacy options",
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
"data": {
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
}
},
"immich_select": {
"title": "Immich source",
"description": "Change which albums, people and favorites this slideshow shows. Saving reloads the slideshow; its entities, settings and hidden photos are kept.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality",
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap"
}
},
"connection": {
"title": "Immich connection",
"description": "The stored Immich URL or API key no longer works. Update them to continue.",
"data": {
"immich_url": "Immich URL (e.g. http://192.168.1.10:2283)",
"immich_api_key": "API key"
}
}
},
"error": {
"immich_cache_clear_failed": "Could not clear the previous photo source cache. No source settings were changed. Please try again.",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
"immich_invalid_auth": "Immich rejected the API key. Check that it is valid and that its permissions include server.about, album.read and person.read.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples."
}
}
}
+45 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -12,7 +13,7 @@ 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)])
async_add_entities([PauseSwitch(entry, store), CropDebugSwitch(entry, store)])
class PauseSwitch(SwitchEntity, RestoreEntity):
@@ -53,3 +54,46 @@ class PauseSwitch(SwitchEntity, RestoreEntity):
if old is not None and old.state in ("on", "off"):
self.store.paused = old.state == "on"
self.store.notify()
class CropDebugSwitch(SwitchEntity, RestoreEntity):
"""Overlay face boxes, a centre crosshair and a crop summary on slides."""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_icon = "mdi:face-recognition"
_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}_crop_debug"
self._attr_name = "Crop debug overlay"
@property
def is_on(self) -> bool:
return bool(self.store.face_debug)
@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.face_debug = True
self.store.notify()
async def async_turn_off(self, **kwargs) -> None:
self.store.face_debug = 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.face_debug = old.state == "on"
self.store.notify()
@@ -172,19 +172,55 @@
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #."
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #.",
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
}
},
"options": {
"step": {
"google_metadata": {
"title": "Google Photos metadata and privacy",
"description": "Photo descriptions, camera details, and exposure metadata use Google's undocumented public endpoint without a Google login. Results are cached; unavailable metadata does not prevent the slideshow from loading.\n\nOriginal photo GPS is a separate opt-in, off by default. It reads up to 256 KB per photo. Google only includes GPS in shared originals when location sharing is turned on for the album; anyone with the album link can then see precise GPS. After changing the album's location sharing, turn this off and back on to rescan. Coordinates are cached in Home Assistant and exposed in camera attributes. Google-added or estimated locations are not included.\n\nPlace-name lookup is also off by default. It requires original GPS reading and sends coordinates to the public OpenStreetMap Nominatim service. Disabling original GPS hides coordinates and place names and stops both lookups; it does not erase previous history or backups.",
"data": {
"google_metadata": "Fetch photo metadata (experimental)",
"google_location": "Read original photo GPS (opt-in)",
"reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)"
}
},
"init": {
"title": "Location & privacy options",
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
"data": {
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
}
},
"immich_select": {
"title": "Immich source",
"description": "Change which albums, people and favorites this slideshow shows. Saving reloads the slideshow; its entities, settings and hidden photos are kept.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality",
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap"
}
},
"connection": {
"title": "Immich connection",
"description": "The stored Immich URL or API key no longer works. Update them to continue.",
"data": {
"immich_url": "Immich URL (e.g. http://192.168.1.10:2283)",
"immich_api_key": "API key"
}
}
},
"error": {
"immich_cache_clear_failed": "Could not clear the previous photo source cache. No source settings were changed. Please try again.",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
"immich_invalid_auth": "Immich rejected the API key. Check that it is valid and that its permissions include server.about, album.read and person.read.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples."
}
}
}
File diff suppressed because it is too large Load Diff