291 files

This commit is contained in:
Home Assistant Version Control
2026-10-14 18:10:18 +00:00
parent c4e0178d8a
commit 280501d2e8
291 changed files with 32540 additions and 3280 deletions
+238 -99
View File
@@ -34,6 +34,8 @@ from .const import (
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_HIDE_HOME_COUNTRY,
DEFAULT_HIDE_HOME_COUNTRY,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
@@ -157,6 +159,9 @@ class MediaItem:
face_scanned: bool = False
camera_metadata: dict[str, Any] | None = None
google_location_scanned: bool = False
# True when ``location`` came from our OpenStreetMap lookup rather than
# the provider, so it can be re-formatted from the place cache.
location_geocoded: bool = False
def __post_init__(self) -> None:
if self.photo_id is None:
@@ -483,6 +488,22 @@ _HEIF_EXIF_RE = re.compile(rb"Exif\x00\x00(?:MM\x00\x2a|II\x2a\x00)")
_NOMINATIM_ENDPOINT = "https://nominatim.openstreetmap.org/reverse"
_NOMINATIM_MIN_INTERVAL_S = 1.1
_NOMINATIM_TIMEOUT_S = 20
# Next free request time, shared so several albums together stay within the limit.
_nominatim_next_slot = 0.0
# Nominatim address parts used for place labels, most specific first.
_LOCALITY_KEYS = (
"city", "town", "village", "hamlet", "city_district", "suburb", "municipality",
)
_REGION_KEYS = ("state", "province", "region")
_ADDRESS_KEYS = (*_LOCALITY_KEYS, *_REGION_KEYS, "country", "country_code")
_PLACE_KEYS = (*_ADDRESS_KEYS, "display_name")
# Administrative prefixes some areas carry in OpenStreetMap, longest first.
_NAME_PREFIXES = (
"regional municipality of ", "rural municipality of ", "municipal district of ",
"municipality of ", "township of ", "district of ", "borough of ",
"village of ", "town of ", "city of ",
)
# How many EXIF reads to perform between cache flushes. Bigger batches
# mean fewer Store writes but more rework if HA is killed mid-scan.
@@ -882,41 +903,68 @@ def _jpeg_exif_segment(data: bytes) -> bytes | None:
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.
def _place_name(value: Any) -> str | None:
"""A town or city name without prefixes like "City of"; parishes don't count."""
if not isinstance(value, str) or not value.strip():
return None
name = value.strip()
folded = name.casefold()
if folded.endswith(" parish") or folded.startswith("parish of "):
return None
for prefix in _NAME_PREFIXES:
if folded.startswith(prefix) and len(name) > len(prefix):
return name[len(prefix):].strip()
return name
Prefers ``city`` > ``town`` > ``village`` > ``municipality`` >
``county`` for the locality portion, plus ``country`` when present.
Falls back to Nominatim's pre-formatted ``display_name`` (truncated
to the first two comma-separated parts) when no recognisable
locality fields are present.
def _nominatim_place(payload: Any) -> dict[str, str] | None:
"""Keep the parts of a Nominatim reverse-geocode response that labels use.
Caching these instead of a finished label lets label options change
without new lookups.
"""
if not isinstance(payload, dict):
return None
address = payload.get("address") if isinstance(payload.get("address"), dict) else {}
locality = None
for key in ("city", "town", "village", "hamlet", "municipality", "county", "state"):
val = address.get(key)
if isinstance(val, str) and val.strip():
locality = val.strip()
break
country = address.get("country") if isinstance(address.get("country"), str) else None
if locality and country:
return f"{locality}, {country}"
if locality:
return locality
if isinstance(country, str) and country.strip():
return country.strip()
place = {
key: value.strip()
for key in _ADDRESS_KEYS
if isinstance(value := address.get(key), str) and value.strip()
}
display = payload.get("display_name")
if isinstance(display, str) and display.strip():
parts = [p.strip() for p in display.split(",") if p.strip()]
if len(parts) >= 2:
return ", ".join(parts[:2])
if parts:
return parts[0]
return None
if not place and isinstance(display, str) and display.strip():
place["display_name"] = display.strip()
return place or None
def _format_place(place: dict[str, str], home_country: str | None = None) -> str | None:
"""Short label such as ``"Lisbon, Portugal"`` from cached address parts.
Uses the town or city, else the province/state, then the country, which
is left out when its code matches ``home_country``.
"""
area = next(
(name for key in _LOCALITY_KEYS if (name := _place_name(place.get(key)))), None
) or next((place[key] for key in _REGION_KEYS if place.get(key)), None)
country = place.get("country")
if home_country and place.get("country_code", "").upper() == home_country.upper():
country = None
parts = [part for part in (area, country) if part]
if len(parts) == 2 and parts[0].casefold() == parts[1].casefold():
parts.pop()
if parts:
return ", ".join(parts)
shown = [part.strip() for part in place.get("display_name", "").split(",") if part.strip()]
return ", ".join(shown[:2]) or None
async def _wait_for_nominatim_slot() -> None:
global _nominatim_next_slot
now = asyncio.get_running_loop().time()
slot = max(now, _nominatim_next_slot)
_nominatim_next_slot = slot + _NOMINATIM_MIN_INTERVAL_S
if slot > now:
await asyncio.sleep(slot - now)
async def _nominatim_lookup(
@@ -924,18 +972,21 @@ async def _nominatim_lookup(
lat: float,
lon: float,
user_agent: str,
) -> str | None:
) -> dict[str, str] | None:
"""Reverse-geocode a coordinate pair via Nominatim.
Returns a human-readable label or ``None`` if the lookup failed or
the response had no useful address. Never raises - geocoding is
best-effort and a failure must never stop the slideshow.
Returns the cached address parts (see :func:`_nominatim_place`) or
``None`` if the lookup failed or the response had no useful address.
Never raises - geocoding is best-effort and a failure must never stop
the slideshow.
"""
params = {
"format": "jsonv2",
"lat": f"{lat:.5f}",
"lon": f"{lon:.5f}",
"zoom": "10", # Roughly city-level - we don't need street precision.
# Neighbourhood level: coarser levels can return an administrative
# area such as a parish instead of the town.
"zoom": "14",
"addressdetails": "1",
}
headers = {
@@ -969,7 +1020,7 @@ async def _nominatim_lookup(
_LOGGER.debug("Nominatim lookup failed (%s, %s): %s", lat, lon, err)
return None
return _format_nominatim_location(payload)
return _nominatim_place(payload)
def _read_manifest_version(integration_dir: Path) -> str:
@@ -1061,6 +1112,7 @@ def _merge_prior_enrichment(
item.longitude = prev.longitude
if prev.location and not item.location:
item.location = prev.location
item.location_geocoded = prev.location_geocoded
if prev.description and not item.description:
item.description = prev.description
if prev.faces is not None and item.faces is None:
@@ -1126,15 +1178,19 @@ class AlbumCoordinator(DataUpdateCoordinator):
self._items_cache_loaded: bool = False
# Reverse-geocode cache. Coordinates rounded to ~100 m via
# ``_geocode_cache_key``; mapping value -> label string. Shared
# across all items in the album, persisted independently of the
# items cache so changes to one don't invalidate the other.
# ``_geocode_cache_key``; mapping value -> Nominatim address parts
# (see ``_nominatim_place``). Shared across all items in the album,
# persisted independently of the items cache so changes to one don't
# invalidate the other.
self._geocode_cache_store: Store = Store(
hass,
self._GEOCODE_CACHE_VERSION,
f"{DOMAIN}.{entry.entry_id}.geocode",
)
self._geocode_cache: dict[str, str] = {}
self._geocode_cache: dict[str, dict[str, str]] = {}
# Finished labels from the older label-only cache, kept until each
# coordinate has been looked up again.
self._geocode_legacy: dict[str, str] = {}
self._geocode_cache_loaded: bool = False
# Per-coordinator User-Agent so OSM operators can track us if we
# ever misbehave. Resolved lazily because __init__ is sync.
@@ -1224,16 +1280,28 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.latitude = previous.latitude
item.longitude = previous.longitude
item.location = previous.location
item.location_geocoded = previous.location_geocoded
item.google_location_scanned = True
self._apply_google_location_privacy(items)
await self._relabel_from_place_cache(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.
prior_items = (self.data or {}).get("items") if isinstance(self.data, dict) else None
# scanned, this session or (after a restart) in the items cache
# on disk; new items get filled in by the background worker
# below. Session items come last so they win over the cache.
prior_items = list((self.data or {}).get("items") or []) if isinstance(self.data, dict) else []
if not self._items_cache_loaded:
cached = await self._load_cached_items()
prior_items = (cached or {}).get("items", []) + prior_items
self._items_cache_loaded = True
if prior_items:
_merge_prior_enrichment(items, prior_items)
_LOGGER.debug(
"Album cache: %d of %d items already have their details",
sum(1 for it in items if not self._needs_enrichment(it)), len(items),
)
await self._relabel_from_place_cache(items)
self._schedule_enrichment(data)
if items:
@@ -1294,9 +1362,71 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.latitude = None
item.longitude = None
item.location = None
item.location_geocoded = False
item.google_location_scanned = False
elif not self.google_reverse_geocode_enabled:
item.location = None
item.location_geocoded = False
@property
def reverse_geocode_enabled(self) -> bool:
if getattr(self, "provider", None) == PROVIDER_GOOGLE_SHARED:
return self.google_reverse_geocode_enabled
options = getattr(getattr(self, "entry", None), "options", None) or {}
return bool(options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE))
@property
def _hidden_home_country(self) -> str | None:
"""Country code to leave out of place labels, if the user asked for that."""
options = getattr(getattr(self, "entry", None), "options", None) or {}
if not options.get(CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY):
return None
return getattr(getattr(self.hass, "config", None), "country", None) or None
def _needs_geocode_lookup(self, item: MediaItem) -> bool:
"""True for items with GPS whose place isn't cached yet."""
if item.latitude is None or item.longitude is None:
return False
if item.location and not item.location_geocoded:
return False # The provider's own label.
return _geocode_cache_key(item.latitude, item.longitude) not in self._geocode_cache
def _label_items_from_geocode_cache(self, items: list[MediaItem]) -> int:
"""Set OpenStreetMap labels from cached places; return how many changed.
Labels from the older label-only cache are adopted as ours, so they
stay visible until a new lookup replaces them.
"""
home = self._hidden_home_country
changed = 0
for item in items:
if item.latitude is None or item.longitude is None:
continue
key = _geocode_cache_key(item.latitude, item.longitude)
legacy = self._geocode_legacy.get(key)
if item.location and not item.location_geocoded:
if item.location != legacy:
continue
item.location_geocoded = True
place = self._geocode_cache.get(key)
if place is None:
if legacy and not item.location:
item.location, item.location_geocoded = legacy, True
changed += 1
continue
label = _format_place(place, home)
if label != item.location or not item.location_geocoded:
item.location, item.location_geocoded = label, True
changed += 1
return changed
async def _relabel_from_place_cache(self, items: list[MediaItem]) -> None:
"""Apply current label options to known places without new lookups."""
if self.reverse_geocode_enabled and any(
it.latitude is not None and it.longitude is not None for it in items
):
await self._ensure_geocode_cache_loaded()
self._label_items_from_geocode_cache(items)
def _needs_enrichment(self, item: MediaItem) -> bool:
if self.provider == PROVIDER_GOOGLE_SHARED:
@@ -1313,11 +1443,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
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 = (
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
needs_geocode = self.reverse_geocode_enabled and any(
self._needs_geocode_lookup(it) for it in items
)
if not unscanned and not needs_geocode:
# Even with nothing to do, mark the phase as ``done`` so the
@@ -1330,7 +1457,12 @@ class AlbumCoordinator(DataUpdateCoordinator):
"geocode_total": 0,
"geocode_done": 0,
}
_LOGGER.debug("Album enrichment: all %d items have their details", len(items))
return
_LOGGER.debug(
"Album enrichment: starting background pass for %d of %d items%s",
len(unscanned), len(items), " plus place names" if needs_geocode else "",
)
self._enrich_progress = {
"phase": "exif",
"exif_total": len(items),
@@ -1351,14 +1483,17 @@ class AlbumCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Album cache: failed to load (%s)", err)
return None
if not isinstance(payload, dict):
_LOGGER.debug("Album cache: nothing saved yet")
return None
cached_source = payload.get("immich_source")
if cached_source is not None and cached_source != getattr(self, "_immich_cache_source", None):
_LOGGER.debug("Album cache: ignoring saved items, the Immich source changed")
return None
raw_items = payload.get("items")
if not isinstance(raw_items, list):
_LOGGER.debug("Album cache: ignoring saved data without an item list")
return None
items: list[MediaItem] = []
@@ -1390,10 +1525,15 @@ class AlbumCoordinator(DataUpdateCoordinator):
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)),
location_geocoded=bool(raw.get("location_geocoded", False)),
))
except Exception:
continue
_LOGGER.debug(
"Album cache: loaded %d saved items (%d skipped as unreadable)",
len(items), len(raw_items) - len(items),
)
self._apply_google_location_privacy(items)
return {
"title": payload.get("title"),
@@ -1429,6 +1569,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
"face_scanned": it.face_scanned,
"camera_metadata": it.camera_metadata,
"google_location_scanned": it.google_location_scanned,
"location_geocoded": it.location_geocoded,
}
for it in items
],
@@ -1439,6 +1580,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
await self._items_cache_store.async_save(payload)
except Exception as err: # pragma: no cover - storage layer
_LOGGER.debug("Album cache: failed to save (%s)", err)
else:
_LOGGER.debug("Album cache: saved %d items", len(items))
async def _update_local_folder(self) -> dict[str, Any]:
if not self.local_path:
@@ -2496,6 +2639,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.longitude = info["longitude"]
if "location" in info:
item.location = info["location"]
item.location_geocoded = False
if "description" in info:
item.description = info["description"]
item.exif_scanned = True
@@ -2577,6 +2721,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
else:
item.latitude = item.longitude = None
item.location = None
item.location_geocoded = False
item.google_location_scanned = True
else:
if keys is None:
@@ -2650,6 +2795,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.longitude = meta["longitude"]
if "location" in meta:
item.location = meta["location"]
item.location_geocoded = False
item.exif_scanned = True
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
@@ -2786,74 +2932,57 @@ class AlbumCoordinator(DataUpdateCoordinator):
self._enrich_progress["phase"] = "done"
async def _geocode_items_background(self, data: dict[str, Any]) -> None:
"""Reverse-geocode items with EXIF GPS but no location label yet.
"""Label items with GPS from the place cache, looking up new places.
Honours the ``reverse_geocode`` option (default on) so privacy-
conscious users can disable the Nominatim calls without losing
the GPS coordinates themselves. Successful labels are written
back into the items in-place and persisted via the items cache.
the GPS coordinates themselves. 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
else DEFAULT_REVERSE_GEOCODE
):
if not self.reverse_geocode_enabled:
_LOGGER.debug("Reverse geocode disabled via options; skipping")
return
items: list[MediaItem] = data.get("items") or []
candidates: list[MediaItem] = [
it
for it in items
if it.latitude is not None
and it.longitude is not None
and not it.location
]
if not candidates:
if not any(it.latitude is not None and it.longitude is not None for it in items):
return
await self._ensure_geocode_cache_loaded()
if self._label_items_from_geocode_cache(items):
self.async_set_updated_data(data)
pending: dict[str, list[MediaItem]] = {}
for item in items:
if self._needs_geocode_lookup(item):
key = _geocode_cache_key(item.latitude, item.longitude)
pending.setdefault(key, []).append(item)
if not pending:
return
self._enrich_progress["phase"] = "geocoding"
self._enrich_progress["geocode_total"] = len(candidates)
self._enrich_progress["geocode_total"] = sum(len(group) for group in pending.values())
self._enrich_progress["geocode_done"] = 0
session = async_get_clientsession(self.hass)
user_agent = await self._async_user_agent()
# Last network call wall-time; used to throttle Nominatim to the
# 1 req/sec policy without sleeping for cached lookups.
last_call: float = 0.0
loop = asyncio.get_event_loop()
home = self._hidden_home_country
unsaved = 0
try:
for item in candidates:
key = _geocode_cache_key(item.latitude, item.longitude)
cached_label = self._geocode_cache.get(key)
if cached_label:
item.location = cached_label
self._enrich_progress["geocode_done"] += 1
continue
# Respect the 1 req/sec Nominatim usage policy.
elapsed = loop.time() - last_call
if elapsed < _NOMINATIM_MIN_INTERVAL_S:
await asyncio.sleep(_NOMINATIM_MIN_INTERVAL_S - elapsed)
label = await _nominatim_lookup(
session, item.latitude, item.longitude, user_agent
for key, group in pending.items():
await _wait_for_nominatim_slot()
place = await _nominatim_lookup(
session, group[0].latitude, group[0].longitude, user_agent
)
last_call = loop.time()
if label:
self._geocode_cache[key] = label
item.location = label
if place:
self._geocode_cache[key] = place
label = _format_place(place, home)
for item in group:
item.location, item.location_geocoded = label, True
unsaved += 1
self._enrich_progress["geocode_done"] += 1
self._enrich_progress["geocode_done"] += len(group)
if unsaved >= _GEOCODE_BATCH_SAVE:
unsaved = 0
@@ -2883,18 +3012,28 @@ class AlbumCoordinator(DataUpdateCoordinator):
if isinstance(payload, dict):
entries = payload.get("entries")
if isinstance(entries, dict):
self._geocode_cache = {
str(k): str(v)
for k, v in entries.items()
if isinstance(v, str) and v
}
for key, value in entries.items():
if isinstance(value, dict):
place = {
k: v for k, v in value.items()
if k in _PLACE_KEYS and isinstance(v, str) and v
}
if place:
self._geocode_cache[str(key)] = place
elif isinstance(value, str) and value:
self._geocode_legacy[str(key)] = value
self._geocode_cache_loaded = True
async def _save_geocode_cache(self) -> None:
# Old labels stay until their coordinate is looked up again, so an
# unfinished upgrade still has them after a restart.
entries: dict[str, Any] = {
key: label for key, label in self._geocode_legacy.items()
if key not in self._geocode_cache
}
entries.update(self._geocode_cache)
try:
await self._geocode_cache_store.async_save(
{"entries": self._geocode_cache}
)
await self._geocode_cache_store.async_save({"entries": entries})
except Exception as err: # pragma: no cover - storage layer
_LOGGER.debug("Geocode cache: failed to save (%s)", err)