This commit is contained in:
Home Assistant Version Control committed 2026-09-11 12:05:04 +00:00
1 parent f6b9dc47d6
commit ff4156dc89
11 files changed
+202 -70

No files matched your search

+17 -3
View File
@@ -39,8 +39,9 @@ _LOGGER = logging.getLogger(__name__)
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway. # JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024 _MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
# Only these content types are accepted as image bodies. If a server returns # Image content types are accepted, with a narrow binary-image exception for
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early. # Apple's CDN below. HTML (captive portal, 404 page rendered as 200, etc.) is
# still rejected early.
_ACCEPTED_IMAGE_PREFIX = ("image/",) _ACCEPTED_IMAGE_PREFIX = ("image/",)
# Max candidates we'll scan when searching for a mismatched-orientation # Max candidates we'll scan when searching for a mismatched-orientation
@@ -1262,7 +1263,20 @@ class AlbumSlideshowCamera(Camera):
content_type = resp.headers.get("Content-Type", "") content_type = resp.headers.get("Content-Type", "")
primary = content_type.split(";", 1)[0].strip().lower() primary = content_type.split(";", 1)[0].strip().lower()
if primary and not primary.startswith(_ACCEPTED_IMAGE_PREFIX): icloud_binary_image = False
if primary == "application/octet-stream":
# Check the final response host after redirects, never
# a substring of the original URL's path or query.
host = (resp.url.host or "").lower()
icloud_binary_image = (
host == "icloud-content.com"
or host.endswith(".icloud-content.com")
)
if (
primary
and not primary.startswith(_ACCEPTED_IMAGE_PREFIX)
and not icloud_binary_image
):
_LOGGER.debug( _LOGGER.debug(
"Album Slideshow: rejecting %s, content-type %r is not an image", "Album Slideshow: rejecting %s, content-type %r is not an image",
url, primary, url, primary,
@@ -141,6 +141,14 @@ def _describe_error(err: BaseException) -> str:
return f"HTTP {status}: {detail}" if status else f"{type(err).__name__}: {detail}" return f"HTTP {status}: {detail}" if status else f"{type(err).__name__}: {detail}"
def _redact_token(token: str | None) -> str:
"""Show enough of a share token to debug it without publishing the album."""
if not token:
return "<empty>"
shape = "".join("-" if c in "-_" else "x" for c in token)
return f"{token[:3]}...({len(token)} chars, shape {shape})"
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
@@ -549,7 +557,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await client.async_validate() await client.async_validate()
albums = await client.async_list_albums() albums = await client.async_list_albums()
people = await client.async_list_people() people = await client.async_list_people()
except Exception: # noqa: BLE001 - any failure means bad URL/creds except Exception as err: # noqa: BLE001 - any failure means bad URL/creds
_LOGGER.warning(
"PhotoPrism validation failed for %s: %s",
client.base_url,
_describe_error(err),
)
errors["base"] = "photoprism_cannot_connect" errors["base"] = "photoprism_cannot_connect"
else: else:
self._pp_url = client.base_url self._pp_url = client.base_url
@@ -729,7 +742,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
client = icloud_api.IcloudClient(self.hass, token) client = icloud_api.IcloudClient(self.hass, token)
try: try:
await client.async_validate() await client.async_validate()
except Exception: # noqa: BLE001 - any failure means bad/expired link except Exception as err: # noqa: BLE001 - any failure means bad/expired link
_LOGGER.warning(
"iCloud validation failed for token %s (%s backend): %s",
_redact_token(token),
backend,
_describe_error(err),
)
errors["base"] = "icloud_cannot_connect" errors["base"] = "icloud_cannot_connect"
else: else:
await self.async_set_unique_id( await self.async_set_unique_id(
@@ -791,7 +810,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await client.async_login(otp_code=otp or None) await client.async_login(otp_code=otp or None)
except syn_api.SynologyOtpRequired: except syn_api.SynologyOtpRequired:
errors["otp_code"] = "synology_otp_required" errors["otp_code"] = "synology_otp_required"
except Exception: # noqa: BLE001 - any failure means bad URL/creds except Exception as err: # noqa: BLE001 - any failure means bad URL/creds
_LOGGER.warning(
"Synology login failed for %s: %s", url, _describe_error(err)
)
errors["base"] = "synology_cannot_connect" errors["base"] = "synology_cannot_connect"
if not errors: if not errors:
@@ -811,7 +833,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
places = await client.async_list_places() places = await client.async_list_places()
tags = await client.async_list_tags() tags = await client.async_list_tags()
subjects = await client.async_list_subjects() subjects = await client.async_list_subjects()
except Exception: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Synology listing failed for space %s: %s",
space,
_describe_error(err),
)
if space == SYNOLOGY_SPACE_SHARED: if space == SYNOLOGY_SPACE_SHARED:
errors["base"] = "synology_shared_unavailable" errors["base"] = "synology_shared_unavailable"
else: else:
@@ -1039,7 +1066,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
try: try:
await client.async_validate() await client.async_validate()
except Exception: # noqa: BLE001 - any failure means bad URL/creds/folder except Exception as err: # noqa: BLE001 - any failure means bad URL/creds/folder
_LOGGER.warning(
"Nextcloud validation failed for %s (folder %r): %s",
client.base_url,
client.folder,
_describe_error(err),
)
errors["base"] = "nextcloud_cannot_connect" errors["base"] = "nextcloud_cannot_connect"
else: else:
await self.async_set_unique_id( await self.async_set_unique_id(
@@ -1111,7 +1144,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
client = nc_api.NextcloudPublicClient(self.hass, base_url, token) client = nc_api.NextcloudPublicClient(self.hass, base_url, token)
try: try:
await client.async_validate() await client.async_validate()
except Exception: # noqa: BLE001 - any failure means bad/expired link except Exception as err: # noqa: BLE001 - any failure means bad/expired link
_LOGGER.warning(
"Nextcloud public album validation failed for %s: %s",
base_url,
_describe_error(err),
)
errors["base"] = "nextcloud_public_cannot_connect" errors["base"] = "nextcloud_public_cannot_connect"
else: else:
await self.async_set_unique_id( await self.async_set_unique_id(
@@ -61,6 +61,17 @@ PROVIDER_SYNOLOGY = "synology"
PROVIDER_NEXTCLOUD = "nextcloud" PROVIDER_NEXTCLOUD = "nextcloud"
PROVIDER_ENTE = "ente" PROVIDER_ENTE = "ente"
# Providers whose coordinator runs a background enrichment pass: per-photo
# metadata reads, reverse-geocoding, or both. These are the entries that get
# the Enrichment progress diagnostic sensor. Keep this as the single source of
# truth so the sensor can't drift away from what the coordinator actually does.
ENRICHING_PROVIDERS = (
PROVIDER_LOCAL_FOLDER,
PROVIDER_IMMICH,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
)
# Nextcloud provider - two auth modes against the same PROVIDER_NEXTCLOUD id: # Nextcloud provider - two auth modes against the same PROVIDER_NEXTCLOUD id:
# #
# - "folder": authenticated WebDAV folder. Points at any folder in a user's # - "folder": authenticated WebDAV folder. Points at any folder in a user's
@@ -81,6 +81,7 @@ from .const import (
ENTE_IMAGE_PREVIEW, ENTE_IMAGE_PREVIEW,
DEFAULT_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE,
DOMAIN, DOMAIN,
ENRICHING_PROVIDERS,
PROVIDER_GOOGLE_SHARED, PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER, PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE, PROVIDER_MEDIA_SOURCE,
@@ -356,7 +357,9 @@ def _find_largest_item_list(obj: Any) -> list[dict[str, Any]]:
def _looks_like_video(raw: dict[str, Any]) -> bool: def _looks_like_video(raw: dict[str, Any]) -> bool:
mime = raw.get("mimeType") # publicalbum.org sends lowercase ``mimetype``; Google's own shapes use
# ``mimeType``.
mime = raw.get("mimeType") or raw.get("mimetype")
if isinstance(mime, str) and mime.startswith("video/"): if isinstance(mime, str) and mime.startswith("video/"):
return True return True
@@ -1090,12 +1093,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise raise
items = data.get("items") or [] items = data.get("items") or []
if self.provider in ( if self.provider in ENRICHING_PROVIDERS and items:
PROVIDER_LOCAL_FOLDER,
PROVIDER_IMMICH,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
) and items:
# Carry forward EXIF/geocode metadata for items we've already # Carry forward EXIF/geocode metadata for items we've already
# scanned this session; new items get filled in by the # scanned this session; new items get filled in by the
# background worker below. # background worker below.
@@ -2505,10 +2503,13 @@ class AlbumCoordinator(DataUpdateCoordinator):
# to bypass publicalbum.org's ~300 cap. # to bypass publicalbum.org's ~300 cap.
scraped_items: list[MediaItem] = [] scraped_items: list[MediaItem] = []
scraped_title: str | None = None scraped_title: str | None = None
# Media keys the scraper identified as video. publicalbum.org returns
# no media type at all, so this is what lets us drop them there too.
video_keys: set[str] = set()
try: try:
from . import google_scraper from . import google_scraper
scraped_title, scraped_items = await google_scraper.fetch_album( scraped_title, scraped_items, video_keys = await google_scraper.fetch_album(
session, self.album_url session, self.album_url
) )
except Exception as err: # never let scrape failure break the integration except Exception as err: # never let scrape failure break the integration
@@ -2550,12 +2551,19 @@ class AlbumCoordinator(DataUpdateCoordinator):
page_items = _find_largest_item_list(result) or _find_largest_item_list(data) page_items = _find_largest_item_list(result) or _find_largest_item_list(data)
api_items: list[MediaItem] = [] api_items: list[MediaItem] = []
dropped_videos = 0
if page_items: if page_items:
seen_urls: set[str] = set() seen_urls: set[str] = set()
for raw in page_items: for raw in page_items:
if _looks_like_video(raw): if _looks_like_video(raw):
continue continue
# publicalbum.org gives no media type, so fall back to the
# scraper's verdict for the same photo (matched on media key).
if raw.get("id") in video_keys:
dropped_videos += 1
continue
url = _pick_url(raw) url = _pick_url(raw)
if not url: if not url:
continue continue
@@ -2625,8 +2633,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise UpdateFailed("No photos returned from Google shared album") raise UpdateFailed("No photos returned from Google shared album")
_LOGGER.info( _LOGGER.info(
"Album scraper: source=publicalbum items=%d (batchexecute=%d)", "Album scraper: source=publicalbum items=%d (batchexecute=%d, "
len(api_items), len(scraped_items), "videos dropped via scraper=%d)",
len(api_items), len(scraped_items), dropped_videos,
) )
return { return {
"title": result.get("title") or self.entry.title, "title": result.get("title") or self.entry.title,
@@ -71,16 +71,23 @@ class _AlbumKeys:
self.auth_key = auth_key self.auth_key = auth_key
async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tuple[str | None, list[MediaItem]]: async def fetch_album(
"""Fetch a shared album in full. Returns (title, items). session, share_url: str, *, timeout: float = 30.0
) -> tuple[str | None, list[MediaItem], set[str]]:
"""Fetch a shared album in full. Returns (title, items, video_keys).
The HTML page is fetched once - just to recover the album/auth keys and The HTML page is fetched once - just to recover the album/auth keys and
title. All actual photo enumeration goes through Google's ``batchexecute`` title. All actual photo enumeration goes through Google's ``batchexecute``
endpoint, which is the only way to reach photos beyond the first ~300. endpoint, which is the only way to reach photos beyond the first ~300.
``video_keys`` holds the media keys of items skipped as video. The
publicalbum.org source returns no media type at all, so it relies on these
to drop the same videos (see #26).
""" """
video_keys: set[str] = set()
keys, title = await _fetch_album_keys(session, share_url, timeout=timeout) keys, title = await _fetch_album_keys(session, share_url, timeout=timeout)
if keys is None: if keys is None:
return None, [] return None, [], video_keys
items: list[MediaItem] = [] items: list[MediaItem] = []
seen_urls: set[str] = set() seen_urls: set[str] = set()
@@ -90,7 +97,7 @@ async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tupl
page_no += 1 page_no += 1
try: try:
page_items, page_id = await _fetch_album_page( page_items, page_id = await _fetch_album_page(
session, keys, page_id, timeout=timeout session, keys, page_id, timeout=timeout, video_keys=video_keys
) )
except Exception as err: except Exception as err:
_LOGGER.warning( _LOGGER.warning(
@@ -119,7 +126,7 @@ async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tupl
"Album scraper: batchexecute fetched %d photos in %d page(s)", "Album scraper: batchexecute fetched %d photos in %d page(s)",
len(items), page_no, len(items), page_no,
) )
return title, items return title, items, video_keys
# -- Internals --------------------------------------------------------------- # -- Internals ---------------------------------------------------------------
@@ -173,11 +180,14 @@ def _extract_title(html: str) -> str | None:
return title or None return title or None
def _extract_first_page_items(html: str) -> list[MediaItem]: def _extract_first_page_items(
html: str, video_keys: set[str] | None = None
) -> list[MediaItem]:
"""Pull the first 300 items out of the AF_initDataCallback blocks. """Pull the first 300 items out of the AF_initDataCallback blocks.
Returns an empty list if the embedded data can't be parsed - the caller Returns an empty list if the embedded data can't be parsed - the caller
will still get the rest via batchexecute pagination. will still get the rest via batchexecute pagination. Media keys of items
skipped as video are added to ``video_keys`` when one is supplied.
""" """
candidates: list[list[Any]] = [] candidates: list[list[Any]] = []
for blob in _iter_af_data_blobs(html): for blob in _iter_af_data_blobs(html):
@@ -195,6 +205,7 @@ def _extract_first_page_items(html: str) -> list[MediaItem]:
seen: set[str] = set() seen: set[str] = set()
for raw in best: for raw in best:
if _is_video_item(raw): if _is_video_item(raw):
_record_video_key(raw, video_keys)
continue continue
item = _parse_album_item(raw) item = _parse_album_item(raw)
if item is None or item.url in seen: if item is None or item.url in seen:
@@ -226,6 +237,7 @@ async def _fetch_album_page(
page_id: str | None, page_id: str | None,
*, *,
timeout: float, timeout: float,
video_keys: set[str] | None = None,
) -> tuple[list[MediaItem], str | None]: ) -> tuple[list[MediaItem], str | None]:
"""Call snAcKc once. ``page_id=""`` is treated as ``None`` (initial fetch).""" """Call snAcKc once. ``page_id=""`` is treated as ``None`` (initial fetch)."""
pid = page_id or None pid = page_id or None
@@ -247,10 +259,12 @@ async def _fetch_album_page(
async with session.post(url, data=form, headers=headers, timeout=timeout) as resp: async with session.post(url, data=form, headers=headers, timeout=timeout) as resp:
resp.raise_for_status() resp.raise_for_status()
body = await resp.text() body = await resp.text()
return _parse_batchexecute_album_page(body) return _parse_batchexecute_album_page(body, video_keys)
def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | None]: def _parse_batchexecute_album_page(
body: str, video_keys: set[str] | None = None
) -> tuple[list[MediaItem], str | None]:
"""Parse a batchexecute response for one snAcKc call. """Parse a batchexecute response for one snAcKc call.
Format (per line, after the XSSI prefix): Format (per line, after the XSSI prefix):
@@ -293,6 +307,7 @@ def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | No
items: list[MediaItem] = [] items: list[MediaItem] = []
for raw in raw_items: for raw in raw_items:
if _is_video_item(raw): if _is_video_item(raw):
_record_video_key(raw, video_keys)
continue continue
item = _parse_album_item(raw) item = _parse_album_item(raw)
if item is not None: if item is not None:
@@ -300,6 +315,22 @@ def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | No
return items, next_page return items, next_page
def _record_video_key(raw: Any, sink: set[str] | None) -> None:
"""Remember a rejected video so other sources can drop it by id too."""
if sink is None:
return
key = _media_key(raw)
if key:
sink.add(key)
def _media_key(raw: Any) -> str | None:
"""Google's stable per-photo id, which publicalbum.org echoes as item ``id``."""
if isinstance(raw, list) and raw and isinstance(raw[0], str):
return raw[0]
return None
def _is_video_item(raw: Any) -> bool: def _is_video_item(raw: Any) -> bool:
"""Return ``True`` when an album item carries a video duration. """Return ``True`` when an album item carries a video duration.
+19 -3
View File
@@ -50,6 +50,10 @@ _BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
# also contain ``-`` and ``_`` (e.g. ``045YeI20-8u3X31bBPD5z9B_A``). # also contain ``-`` and ``_`` (e.g. ``045YeI20-8u3X31bBPD5z9B_A``).
_TOKEN_CHARS = frozenset(_BASE62 + "-_") _TOKEN_CHARS = frozenset(_BASE62 + "-_")
# Used when a token's leading characters aren't base62 and the partition can't
# be derived. Apple answers with an X-Apple-MMe-Host redirect to the right one.
_DEFAULT_PARTITION_HOST = "p01-sharedstreams.icloud.com"
# Which iCloud backend serves an album. These string values match the # Which iCloud backend serves an album. These string values match the
# ``ICLOUD_BACKEND_*`` constants in ``const.py``; they are duplicated here to # ``ICLOUD_BACKEND_*`` constants in ``const.py``; they are duplicated here to
# keep this module free of a hard dependency on ``const``. # keep this module free of a hard dependency on ``const``.
@@ -95,10 +99,18 @@ _CK_BROWSER_SAFE_ORIGINAL = {"public.jpeg", "public.png"}
_CK_VIDEO_HINTS = ("movie", "video", "mpeg-4", "quicktime") _CK_VIDEO_HINTS = ("movie", "video", "mpeg-4", "quicktime")
def _base62_to_int(value: str) -> int: def _base62_to_int(value: str) -> int | None:
"""Decode base62, or ``None`` if the text isn't purely base62.
Share tokens may contain ``-``/``_`` (CloudKit uses a URL-safe alphabet), so
this has to answer "not base62" rather than raise.
"""
result = 0 result = 0
for ch in value: for ch in value:
result = result * 62 + _BASE62.index(ch) index = _BASE62.find(ch)
if index < 0:
return None
result = result * 62 + index
return result return result
@@ -155,7 +167,9 @@ def partition_host(token: str) -> str:
"""Derive the shared-streams partition host for a token. """Derive the shared-streams partition host for a token.
Apple encodes the server partition in the first characters of the token: Apple encodes the server partition in the first characters of the token:
one char after a leading ``A``, otherwise the first two chars. one char after a leading ``A``, otherwise the first two chars. When those
characters aren't base62 the partition can't be computed, so fall back to
a valid host and let Apple redirect via ``X-Apple-MMe-Host``.
""" """
if not token: if not token:
return "" return ""
@@ -163,6 +177,8 @@ def partition_host(token: str) -> str:
partition = _base62_to_int(token[1:2]) partition = _base62_to_int(token[1:2])
else: else:
partition = _base62_to_int(token[1:3]) partition = _base62_to_int(token[1:3])
if partition is None:
return _DEFAULT_PARTITION_HOST
return f"p{partition:02d}-sharedstreams.icloud.com" return f"p{partition:02d}-sharedstreams.icloud.com"
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues", "issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow", "PyNaCl>=1.5.0"], "requirements": ["Pillow", "PyNaCl>=1.5.0"],
"version": "1.9.0" "version": "1.9.1"
} }
@@ -6,8 +6,9 @@ HTTP lives in ``PhotoprismClient``; the parsing/URL helpers are pure functions
so they can be unit-tested without a live server or aiohttp. so they can be unit-tested without a live server or aiohttp.
API shape (PhotoPrism ``/api/v1``, Bearer auth): API shape (PhotoPrism ``/api/v1``, Bearer auth):
- ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token, ...}`` - ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token,
(only needed for the username + password auth method). config: {previewToken, ...}, ...}`` (only needed for the username +
password auth method).
- ``GET /api/v1/photos`` ``?count&offset&order=newest&primary=true`` plus a - ``GET /api/v1/photos`` ``?count&offset&order=newest&primary=true`` plus a
filter (``s=<album_uid>``, ``q=subject:<uid>``, ``q=favorite:true``, or a filter (``s=<album_uid>``, ``q=subject:<uid>``, ``q=favorite:true``, or a
custom ``q=``) -> a JSON list of photos. Metadata is inline (``TakenAt``, custom ``q=``) -> a JSON list of photos. Metadata is inline (``TakenAt``,
@@ -23,6 +24,7 @@ API shape (PhotoPrism ``/api/v1``, Bearer auth):
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import Mapping
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@@ -224,8 +226,8 @@ class PhotoprismClient:
# Bearer token used for API calls: the app password directly, or the # Bearer token used for API calls: the app password directly, or the
# session access token obtained from username + password. # session access token obtained from username + password.
self._bearer: str | None = token if auth_method == "app_password" else None self._bearer: str | None = token if auth_method == "app_password" else None
# Preview token captured from the most recent search response, used to # Login config supplies a preview token even when search headers omit
# build thumbnail URLs. # it. Later search responses can refresh it for thumbnail URLs.
self.preview_token: str | None = None self.preview_token: str | None = None
@property @property
@@ -251,6 +253,12 @@ class PhotoprismClient:
if not token: if not token:
raise PhotoprismAuthError("session login returned no access_token") raise PhotoprismAuthError("session login returned no access_token")
self._bearer = token self._bearer = token
config = data.get("config")
preview_token = config.get("previewToken") if isinstance(config, dict) else None
# A new session replaces the old preview token, including on re-login.
self.preview_token = (
preview_token.strip() or None if isinstance(preview_token, str) else None
)
async def async_authenticate(self) -> None: async def async_authenticate(self) -> None:
"""Ensure a usable Bearer token is available.""" """Ensure a usable Bearer token is available."""
@@ -259,8 +267,10 @@ class PhotoprismClient:
elif not self._bearer: elif not self._bearer:
raise PhotoprismAuthError("no app password configured") raise PhotoprismAuthError("no app password configured")
async def _get(self, path: str, params: dict[str, str] | None = None) -> tuple[Any, dict[str, str]]: async def _get(
"""GET returning ``(json, headers)``, re-authenticating once on 401.""" self, path: str, params: dict[str, str] | None = None
) -> tuple[Any, Mapping[str, str]]:
"""GET JSON and case-insensitive headers, re-authenticating once on 401."""
if self._bearer is None: if self._bearer is None:
await self.async_authenticate() await self.async_authenticate()
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
@@ -274,7 +284,7 @@ class PhotoprismClient:
await self._login() await self._login()
continue continue
resp.raise_for_status() resp.raise_for_status()
return await resp.json(), dict(resp.headers) return await resp.json(), resp.headers
raise PhotoprismAuthError("unauthorized after re-authentication") raise PhotoprismAuthError("unauthorized after re-authentication")
async def async_validate(self) -> None: async def async_validate(self) -> None:
+9 -6
View File
@@ -6,7 +6,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER from .const import (
DOMAIN,
ENRICHING_PROVIDERS,
PROVIDER_GOOGLE_SHARED,
)
from .coordinator import AlbumCoordinator from .coordinator import AlbumCoordinator
@@ -17,11 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
AlbumTitleSensor(entry, coordinator), AlbumTitleSensor(entry, coordinator),
CacheUsageSensor(entry, coordinator), CacheUsageSensor(entry, coordinator),
] ]
if coordinator.provider == PROVIDER_LOCAL_FOLDER: if coordinator.provider in ENRICHING_PROVIDERS:
# Diagnostic surface for the local-folder background enrichment # Diagnostic surface for the background enrichment pass (per-photo
# (EXIF reads + reverse-geocode). For Google albums there's no # metadata reads and/or reverse-geocode). Providers with nothing to
# enrichment work, so this sensor is omitted to keep the device # enrich omit it to keep the device screen tidy.
# screen tidy.
entities.append(EnrichmentProgressSensor(entry, coordinator)) entities.append(EnrichmentProgressSensor(entry, coordinator))
async_add_entities(entities) async_add_entities(entities)
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info * tap_action: none # none | more-info
*/ */
const VERSION = "1.9.0"; const VERSION = "1.9.1";
const ANIMATED_TRANSITIONS = [ const ANIMATED_TRANSITIONS = [
"fade", "fade",
+24 -24
View File
@@ -4,10 +4,10 @@
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 119.1, "voltage": 118.5,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"ac_frequency": 60, "ac_frequency": 60,
"power_factor": 0.15, "power_factor": 0.11,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -22,15 +22,15 @@
}, },
"0xffffb40e0607af27": { "0xffffb40e0607af27": {
"state": "ON", "state": "ON",
"voltage": 118.4, "voltage": 117.8,
"ac_frequency": 60, "ac_frequency": 60,
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"power": 244.7, "power": 236.2,
"current": 2.29, "current": 2.04,
"energy": 40.38, "energy": 40.43,
"power_factor": 0.9, "power_factor": 0.98,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -45,10 +45,10 @@
"state": "ON", "state": "ON",
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 118.6, "voltage": 117.6,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"energy": 79.66, "energy": 79.66,
"power_factor": 0.31, "power_factor": 0.91,
"ac_frequency": 60, "ac_frequency": 60,
"update": { "update": {
"state": "idle", "state": "idle",
@@ -58,8 +58,8 @@
"latest_release_notes": null "latest_release_notes": null
}, },
"linkquality": 80, "linkquality": 80,
"power": 0.2, "power": 84.1,
"current": 0.01, "current": 0.83,
"power_on_behavior": "on" "power_on_behavior": "on"
}, },
"0xb40e060fffe031e3": { "0xb40e060fffe031e3": {
@@ -74,13 +74,13 @@
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"voltage": 118.4, "voltage": 116.6,
"state": "ON", "state": "ON",
"ac_frequency": 60, "ac_frequency": 60,
"energy": 162.1, "energy": 162.12,
"power": 0.8, "power": 129.7,
"current": 0.05, "current": 1.27,
"power_factor": 0.39, "power_factor": 0.94,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -95,13 +95,13 @@
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"voltage": 118.3, "voltage": 117.6,
"energy": 78.31, "energy": 78.32,
"state": "ON", "state": "ON",
"power": 24.2, "power": 30.8,
"current": 0.35, "current": 0.43,
"ac_frequency": 60, "ac_frequency": 60,
"power_factor": 0.63, "power_factor": 0.62,
"update": { "update": {
"state": "idle", "state": "idle",
"installed_version": 268513381, "installed_version": 268513381,
@@ -114,12 +114,12 @@
}, },
"0xffffb40e060895b3": { "0xffffb40e060895b3": {
"state": "ON", "state": "ON",
"voltage": 118.5, "voltage": 118.3,
"ac_frequency": 60, "ac_frequency": 60,
"energy": 11.09, "energy": 11.09,
"current": 0.01, "current": 0.01,
"power": 0.1, "power": 0.1,
"power_factor": 0.11, "power_factor": 0.16,
"linkquality": 105, "linkquality": 105,
"update": { "update": {
"state": "idle", "state": "idle",
@@ -136,7 +136,7 @@
"0xffffb40e0608864e": { "0xffffb40e0608864e": {
"led_brightness": 100, "led_brightness": 100,
"countdown_to_turn_off": 0, "countdown_to_turn_off": 0,
"voltage": 119.5, "voltage": 118.5,
"energy": 24.52, "energy": 24.52,
"countdown_to_turn_on": 0, "countdown_to_turn_on": 0,
"state": "ON", "state": "ON",