updated apps
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user