updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
+595 -12
View File
@@ -6,6 +6,7 @@ from datetime import timedelta
import json
import logging
from pathlib import Path
import re
from typing import Any
import async_timeout
@@ -20,12 +21,22 @@ from .const import (
CONF_PROVIDER,
CONF_ALBUM_URL,
CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
)
from .store import SlideshowStore
@@ -53,6 +64,13 @@ class MediaItem:
latitude: float | None = None
longitude: float | None = None
location: str | None = None
# Free-text photo description / caption (local-folder provider only),
# read from EXIF ImageDescription, IPTC Caption-Abstract, or XMP
# dc:description. Used by the card's caption overlay when enabled.
description: str | None = None
# Provider-specific source identifier (e.g. the Immich asset id) used by
# background enrichment to fetch per-item metadata.
source_id: str | 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
@@ -63,6 +81,73 @@ class MediaItem:
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
_VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".3gp", ".mts", ".m2ts"}
# Media Source browsing limits: cap total collected images and recursion
# depth so a huge or self-referential tree can't hang the coordinator or
# exhaust memory.
_MEDIA_SOURCE_MAX_ITEMS = 5000
_MEDIA_SOURCE_MAX_DEPTH = 8
# System/metadata folders and files that some sources (notably Synology)
# expose but which are never user photos.
_SKIP_MEDIA_TITLES = {"@eadir", ".ds_store", "thumbs.db", "@syno", "#recycle"}
# Image formats a browser cannot render inline; skip so we don't queue
# guaranteed fetch failures.
_NON_WEB_IMAGE_EXTS = {
".psd", ".tif", ".tiff", ".heic", ".heif",
".cr2", ".nef", ".arw", ".dng", ".raw", ".orf", ".rw2",
}
def _is_junk_media_title(title: Any) -> bool:
"""Return True for system folders / non-renderable files to skip."""
if not isinstance(title, str):
return False
t = title.strip().lower()
if not t:
return False
if t in _SKIP_MEDIA_TITLES:
return True
if t.startswith("@") or t.startswith("."):
return True
dot = t.rfind(".")
if dot != -1 and t[dot:] in _NON_WEB_IMAGE_EXTS:
return True
return False
def _media_node_is_image(media_class: Any, media_content_type: Any) -> bool:
"""Return True if a browsed media node looks like a still image.
``media_class`` is Home Assistant's coarse category (e.g. ``image``,
``video``, ``directory``); ``media_content_type`` is the MIME type when
known. We accept a node when either signals an image, and explicitly
reject anything that declares a video type.
"""
mc = str(media_class or "").lower()
mime = str(media_content_type or "").lower()
if mc == "video" or mime.startswith("video/"):
return False
if mc == "image" or mime.startswith("image/"):
return True
return False
def _normalize_resolved_url(url: str, base_url: str) -> str:
"""Make a resolved media URL absolute so the fetch layer can load it.
``async_resolve_media`` returns either an absolute ``http(s)`` URL or a
site-relative path such as ``/media/local/...`` (often already signed via
an ``authSig`` query param). Relative paths are prefixed with the
instance's internal base URL; absolute URLs pass through unchanged.
"""
if not isinstance(url, str) or not url:
return url
if url.startswith("http://") or url.startswith("https://"):
return url
if url.startswith("/") and base_url:
return f"{base_url.rstrip('/')}{url}"
return url
_SKIP_DIR_PREFIXES = (".", "@", "#")
@@ -74,6 +159,66 @@ def _pick_url(item: dict[str, Any]) -> str | None:
return None
# Matches Google's ``=w1920-h1080`` (and variants) size suffix so two URLs for
# the same photo at different sizes collapse to one stable key.
_PHOTO_SIZE_SUFFIX_RE = re.compile(r"=[a-z0-9-]+$", re.IGNORECASE)
def _photo_base_key(url: str | None) -> str | None:
"""Return a stable per-photo key from a Google CDN URL.
Both album sources (``batchexecute`` and publicalbum.org) hand back
``lh3.googleusercontent.com/<id>=w...-h...`` URLs for the same photo, just
at different sizes and sometimes with query params. Dropping the query
string and the size suffix leaves the shared ``<id>`` portion, which lets
us match a publicalbum item to its dated batchexecute twin.
"""
if not isinstance(url, str) or not url:
return None
base = url.split("?", 1)[0]
base = _PHOTO_SIZE_SUFFIX_RE.sub("", base)
return base or None
def _enrich_missing_dates(
api_items: list["MediaItem"], scraped_items: list["MediaItem"]
) -> int:
"""Backfill missing dates on ``api_items`` from dated ``scraped_items``.
Matches photos across the two Google album sources by their stable
per-photo URL key and fills in any ``captured_at`` / ``uploaded_at`` that
the publicalbum item is missing. Mutates ``api_items`` in place and returns
how many items were touched. See issue #18.
"""
if not api_items or not scraped_items:
return 0
dates_by_key: dict[str, tuple[int | None, int | None]] = {}
for it in scraped_items:
key = _photo_base_key(it.url)
if key and (it.captured_at is not None or it.uploaded_at is not None):
dates_by_key.setdefault(key, (it.captured_at, it.uploaded_at))
if not dates_by_key:
return 0
enriched = 0
for it in api_items:
if it.captured_at is not None and it.uploaded_at is not None:
continue
twin = dates_by_key.get(_photo_base_key(it.url))
if not twin:
continue
cap, up = twin
touched = False
if it.captured_at is None and cap is not None:
it.captured_at = cap
touched = True
if it.uploaded_at is None and up is not None:
it.uploaded_at = up
touched = True
if touched:
enriched += 1
return enriched
def _pick_int(d: dict[str, Any], *path: str) -> int | None:
cur: Any = d
for p in path:
@@ -231,6 +376,7 @@ def _looks_like_video(raw: dict[str, Any]) -> bool:
_EXIF_TAG_DATETIME_ORIGINAL = 36867 # DateTimeOriginal
_EXIF_TAG_OFFSET_TIME_ORIGINAL = 36881 # OffsetTimeOriginal (e.g. "+02:00")
_EXIF_TAG_DATETIME = 306 # DateTime (modification time)
_EXIF_TAG_IMAGE_DESCRIPTION = 270 # ImageDescription (free text)
_EXIF_TAG_GPS_IFD = 34853 # Pointer to the GPS IFD
_EXIF_GPS_LAT_REF = 1 # "N" / "S"
_EXIF_GPS_LAT = 2 # rational tuple
@@ -351,6 +497,128 @@ def _parse_exif_datetime(raw: Any, offset_raw: Any) -> int | None:
return None
def _clean_description(value: Any) -> str | None:
"""Normalise a raw description value to trimmed text or ``None``."""
if isinstance(value, bytes):
try:
value = value.decode("utf-8", errors="ignore")
except Exception:
return None
if not isinstance(value, str):
return None
text = value.strip().replace("\x00", "")
return text or None
def _read_photo_description(img: Any, exif: Any) -> str | None:
"""Extract a photo description from EXIF, IPTC, or XMP metadata.
Tries, in order: EXIF ``ImageDescription``, IPTC ``Caption-Abstract``
(record 2, dataset 120), and XMP ``dc:description``. Returns the first
non-empty value, or ``None``. All lookups are defensive because these
metadata blocks are frequently absent or malformed.
"""
try:
desc = _clean_description(exif.get(_EXIF_TAG_IMAGE_DESCRIPTION))
if desc:
return desc
except Exception:
pass
try:
from PIL import IptcImagePlugin
iptc = IptcImagePlugin.getiptcinfo(img)
if iptc:
desc = _clean_description(iptc.get((2, 120)))
if desc:
return desc
except Exception:
pass
try:
xmp = img.getxmp()
except Exception:
xmp = None
if isinstance(xmp, dict):
found = _find_xmp_description(xmp)
if found:
return found
return None
def _find_xmp_description(node: Any) -> str | None:
"""Recursively search a parsed XMP tree for the ``dc:description`` text.
Pillow's ``getxmp()`` returns nested dicts. The relevant path is
``xmpmeta -> RDF -> Description -> description -> Alt -> li``. We match
the ``dc:description`` *field* by its exact lowercase localname
``description`` (case-sensitive) so we don't accidentally match the
``rdf:Description`` *container* (capital ``D``) that wraps it.
"""
if isinstance(node, dict):
for key, value in node.items():
# Case-sensitive: dc:description is lowercase; the rdf:Description
# container is capitalised and must be skipped here.
if isinstance(key, str) and key.split(":")[-1] == "description":
text = _extract_xmp_text(value)
if text:
return text
for value in node.values():
found = _find_xmp_description(value)
if found:
return found
elif isinstance(node, list):
for value in node:
found = _find_xmp_description(value)
if found:
return found
return None
def _extract_xmp_text(value: Any) -> str | None:
"""Pull plain text out of an XMP language-alternative structure.
Handles the shapes Pillow produces for ``dc:description``:
- a plain string
- ``{"Alt"/"Bag"/"Seq": {"li": ...}}`` containers
- an ``li`` that is a string, a ``{"lang", "text"}`` dict, or a list of
such dicts (multiple languages)
Prefers the ``x-default`` language entry when several are present.
"""
if isinstance(value, str):
return _clean_description(value)
if isinstance(value, dict):
# A language-alternative leaf: ``{"lang": ..., "text": ...}``.
if "text" in value:
return _clean_description(value.get("text"))
# Container wrappers: descend only through the known XMP keys so we
# never grab a sibling attribute value (e.g. a ``lang`` code).
for container in ("Alt", "Bag", "Seq"):
if container in value:
text = _extract_xmp_text(value[container])
if text:
return text
if "li" in value:
return _extract_xmp_text(value["li"])
return None
if isinstance(value, list):
# Multiple ``rdf:li`` entries: prefer x-default, else the first
# non-empty one.
default = None
first = None
for v in value:
if isinstance(v, dict) and v.get("lang") == "x-default":
default = _clean_description(v.get("text"))
if default:
return default
if first is None:
first = _extract_xmp_text(v)
return first
return None
def _read_local_exif(path: Path) -> dict[str, Any]:
"""Read EXIF metadata for a local file.
@@ -382,17 +650,25 @@ def _read_local_exif(path: Path) -> dict[str, Any]:
try:
with Image.open(path) as img:
exif = img.getexif()
if exif:
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
_EXIF_TAG_DATETIME
)
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
parsed = _parse_exif_datetime(dt_raw, offset_raw)
if parsed is not None:
out["captured_at"] = parsed
# Description can come from IPTC / XMP even when the file has no
# EXIF IFD, so this runs regardless of ``exif`` being present.
description = _read_photo_description(img, exif)
if description:
out["description"] = description
if not exif:
return out
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
_EXIF_TAG_DATETIME
)
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
parsed = _parse_exif_datetime(dt_raw, offset_raw)
if parsed is not None:
out["captured_at"] = parsed
gps = None
try:
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
@@ -550,13 +826,18 @@ def _merge_prior_enrichment(
item.longitude = prev.longitude
if prev.location and not item.location:
item.location = prev.location
if prev.description and not item.description:
item.description = prev.description
if prev.exif_scanned:
item.exif_scanned = True
class AlbumCoordinator(DataUpdateCoordinator):
# Bump when the persisted item shape changes incompatibly.
_ITEM_CACHE_VERSION = 2
# v3: added ``description``; forces a re-scan so already-cached items
# (exif_scanned=True) get their description read instead of being
# skipped forever.
_ITEM_CACHE_VERSION = 3
# Bump independently of the items cache - the geocode cache is
# keyed by coordinate and is safe to keep across item-shape changes.
_GEOCODE_CACHE_VERSION = 1
@@ -570,6 +851,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
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))
self.media_content_id: str | None = entry.data.get(CONF_MEDIA_CONTENT_ID)
# Extra headers the camera must send when fetching image bytes
# (Immich API key). Empty for providers that need no auth.
self.image_request_headers: dict[str, str] = {}
# Persist the most recent successful album fetch so that a transient
# network/Google failure doesn't blank the slideshow on restart.
@@ -631,6 +916,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_local_folder()
elif self.provider == PROVIDER_GOOGLE_SHARED:
data = await self._update_google_shared()
elif self.provider == PROVIDER_MEDIA_SOURCE:
data = await self._update_media_source()
elif self.provider == PROVIDER_IMMICH:
data = await self._update_immich()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -644,9 +933,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise
items = data.get("items") or []
if self.provider == PROVIDER_LOCAL_FOLDER and items:
# Carry forward EXIF/geocode metadata for files we've already
# scanned this session; new files get filled in by the
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) 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
if prior_items:
@@ -740,6 +1029,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
latitude=raw.get("latitude"),
longitude=raw.get("longitude"),
location=raw.get("location"),
description=raw.get("description"),
source_id=raw.get("source_id"),
exif_scanned=bool(raw.get("exif_scanned", False)),
))
except Exception:
@@ -770,6 +1061,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
"latitude": it.latitude,
"longitude": it.longitude,
"location": it.location,
"description": it.description,
"source_id": it.source_id,
"exif_scanned": it.exif_scanned,
}
for it in items
@@ -839,6 +1132,261 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_media_source(self) -> dict[str, Any]:
"""Build the item list from a Home Assistant Media Source node.
Browses the configured ``media-source://`` content id recursively,
collecting image children, then resolves each to a playable URL.
Media Source exposes no per-photo EXIF, so date/GPS/description
features do not apply here (same as the Google provider).
"""
content_id = self.media_content_id
if not content_id:
raise UpdateFailed("Missing media source content id")
try:
from homeassistant.components import media_source
except Exception as err: # pragma: no cover - core ships media_source
raise UpdateFailed("media_source integration is not available") from err
collected: list[tuple[str, str | None]] = []
try:
await self._browse_media_source(media_source, content_id, collected, 0)
except UpdateFailed:
raise
except Exception as err:
raise UpdateFailed(f"Error browsing media source: {err}") from err
if not collected:
raise UpdateFailed("No images found in the selected media source")
base_url = self._internal_base_url()
items: list[MediaItem] = []
for cid, title in collected:
resolved = await self._resolve_media(media_source, cid)
if resolved is None:
continue
raw = resolved[0]
# ``async_resolve_media`` returns an UNSIGNED ``/media/...`` path
# when called directly (the frontend's websocket handler is what
# normally signs it). Fetching an unsigned local-media path
# server-side gets a 401, so sign it ourselves before use.
if isinstance(raw, str) and raw.startswith("/"):
raw = self._sign_media_path(raw)
url = _normalize_resolved_url(raw, base_url)
if not url:
continue
items.append(
MediaItem(
url=url,
width=None,
height=None,
mime_type=resolved[1],
filename=title,
)
)
if not items:
raise UpdateFailed("Could not resolve any media source images")
return {
"title": self.entry.title,
"items": items,
}
def _sign_media_path(self, path: str) -> str:
"""Sign a relative ``/media/...`` path so it can be fetched server-side.
Mirrors what Home Assistant's ``media_source/resolve_media`` websocket
handler does: quote the path and append an ``authSig`` signature. Uses
the content user so signing works from a background task with no
request context (the same mechanism that lets Cast devices fetch local
media). Signs for a window comfortably longer than the album refresh
interval; every refresh re-signs, so URLs stay fresh.
"""
from urllib.parse import quote
try:
from homeassistant.components.http.auth import async_sign_path
except Exception: # pragma: no cover - http always present
return path
quoted = quote(path)
expiration = timedelta(hours=max(48, int(self.store.refresh_hours) * 2 + 1))
for kwargs in ({"use_content_user": True}, {}):
try:
return async_sign_path(self.hass, quoted, expiration, **kwargs)
except TypeError:
# Older/newer signature without ``use_content_user``.
continue
except Exception as err:
_LOGGER.debug("media_source: failed to sign %s: %s", path, err)
return path
return path
async def _browse_media_source(
self,
media_source,
content_id: str,
collected: list[tuple[str, str | None]],
depth: int,
) -> None:
"""Recursively walk a media source tree collecting image leaves."""
if len(collected) >= _MEDIA_SOURCE_MAX_ITEMS or depth > _MEDIA_SOURCE_MAX_DEPTH:
return
browsed = await media_source.async_browse_media(self.hass, content_id)
children = getattr(browsed, "children", None) or []
for child in children:
if len(collected) >= _MEDIA_SOURCE_MAX_ITEMS:
break
child_id = getattr(child, "media_content_id", None)
if not child_id:
continue
title = getattr(child, "title", None)
if _is_junk_media_title(title):
continue
if _media_node_is_image(
getattr(child, "media_class", None),
getattr(child, "media_content_type", None),
):
collected.append((child_id, title))
elif getattr(child, "can_expand", False):
await self._browse_media_source(
media_source, child_id, collected, depth + 1
)
async def _resolve_media(
self, media_source, content_id: str
) -> tuple[str, str | None] | None:
"""Resolve a media content id to ``(url, mime_type)`` or ``None``."""
try:
try:
play = await media_source.async_resolve_media(
self.hass, content_id, None
)
except TypeError:
# Older cores: async_resolve_media(hass, content_id).
play = await media_source.async_resolve_media(self.hass, content_id)
except Exception as err:
_LOGGER.debug("media_source: failed to resolve %s: %s", content_id, err)
return None
url = getattr(play, "url", None)
if not url:
return None
return url, getattr(play, "mime_type", None)
def _internal_base_url(self) -> str:
"""Best-effort internal base URL for site-relative media URLs."""
try:
from homeassistant.helpers.network import get_url
return get_url(self.hass, prefer_external=False, allow_ip=True)
except Exception:
return ""
async def _update_immich(self) -> dict[str, Any]:
"""Build the item list from an Immich album or person via its API.
Unlike Media Source, the Immich API exposes per-photo metadata, so
capture date, GPS/location, and description all work. Dates come from
the asset list up front; location and description are filled in by the
background enrichment worker (one asset-detail call each, cached).
"""
from . import immich as immich_api
url = self.entry.data.get(CONF_IMMICH_URL)
api_key = self.entry.data.get(CONF_IMMICH_API_KEY)
sel_type = self.entry.data.get(CONF_IMMICH_SELECTION_TYPE)
sel_id = self.entry.data.get(CONF_IMMICH_SELECTION_ID)
size = self.entry.data.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
if not url or not api_key or not sel_type:
raise UpdateFailed("Immich provider is missing URL, API key, or selection")
# ``album``/``albums`` and ``person``/``people`` need a target id;
# composite/favorites/all/random/search do not (an empty composite
# means "all photos").
if sel_type in ("album", "albums", "person", "people") and not sel_id:
raise UpdateFailed("Immich provider is missing the album/person id")
filter_body = None
raw_filter = self.entry.data.get(CONF_IMMICH_FILTER)
if sel_type in ("search", "composite") and raw_filter:
try:
parsed = json.loads(raw_filter)
if isinstance(parsed, dict):
filter_body = parsed
except (ValueError, TypeError):
raise UpdateFailed("Immich search filter is not valid JSON")
client = immich_api.ImmichClient(self.hass, url, api_key)
# Auth header the camera must send when fetching image bytes. Sent
# server-side only, so the key never reaches the browser or the
# ``current_url`` attribute.
self.image_request_headers = dict(client.image_headers)
try:
assets = await client.async_collect_assets(sel_type, sel_id, filter_body)
except Exception as err:
raise UpdateFailed(f"Error querying Immich: {err}") from err
if not assets:
raise UpdateFailed("No images found for the selected Immich source")
items: list[MediaItem] = []
for a in assets:
aid = a.get("id")
if not aid:
continue
captured = immich_api._to_epoch_ms(
a.get("localDateTime")
) or immich_api._to_epoch_ms(a.get("fileCreatedAt"))
w = a.get("width")
h = a.get("height")
items.append(
MediaItem(
url=immich_api.build_image_url(client.base_url, aid, size),
width=w if isinstance(w, int) else None,
height=h if isinstance(h, int) else None,
mime_type=None,
filename=a.get("originalFileName"),
captured_at=captured,
source_id=aid,
)
)
return {
"title": self.entry.title,
"items": items,
}
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
from . import immich as immich_api
url = self.entry.data.get(CONF_IMMICH_URL)
api_key = self.entry.data.get(CONF_IMMICH_API_KEY)
if not item.source_id or not url or not api_key:
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
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
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
"""Read EXIF for unscanned local files, then reverse-geocode.
@@ -865,6 +1413,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
await asyncio.sleep(0)
continue
if self.provider == PROVIDER_IMMICH:
try:
await self._enrich_immich_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("Immich enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
continue
url = item.url
if not url.startswith("file://"):
item.exif_scanned = True
@@ -883,6 +1449,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "description" in info:
item.description = info["description"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
@@ -1202,6 +1770,21 @@ class AlbumCoordinator(DataUpdateCoordinator):
byte_size=byte_size,
))
# Cross-source date enrichment: publicalbum.org often returns a fuller
# item list but with no (or partial) date metadata, while batchexecute
# returns dated items. Where the same photo appears in both, backfill
# the publicalbum item's captured_at / uploaded_at from its dated
# batchexecute twin (matched by the stable per-photo URL key). This
# keeps the larger item count while restoring the dates the date
# filter needs. See issue #18.
enriched = _enrich_missing_dates(api_items, scraped_items)
if enriched:
_LOGGER.info(
"Album scraper: enriched %d publicalbum item(s) with "
"batchexecute dates",
enriched,
)
# Pick the source with more items; prefer publicalbum.org on a tie
# because its URLs come pre-decorated with size hints.
if len(scraped_items) > len(api_items):