This commit is contained in:
Home Assistant Version Control
2026-09-11 12:05:04 +00:00
parent f6b9dc47d6
commit ff4156dc89
11 changed files with 202 additions and 70 deletions
+17 -3
View File
@@ -39,8 +39,9 @@ _LOGGER = logging.getLogger(__name__)
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
# Only these content types are accepted as image bodies. If a server returns
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early.
# Image content types are accepted, with a narrow binary-image exception for
# Apple's CDN below. HTML (captive portal, 404 page rendered as 200, etc.) is
# still rejected early.
_ACCEPTED_IMAGE_PREFIX = ("image/",)
# 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", "")
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(
"Album Slideshow: rejecting %s, content-type %r is not an image",
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}"
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):
VERSION = 1
@@ -549,7 +557,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await client.async_validate()
albums = await client.async_list_albums()
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"
else:
self._pp_url = client.base_url
@@ -729,7 +742,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
client = icloud_api.IcloudClient(self.hass, token)
try:
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"
else:
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)
except syn_api.SynologyOtpRequired:
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"
if not errors:
@@ -811,7 +833,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
places = await client.async_list_places()
tags = await client.async_list_tags()
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:
errors["base"] = "synology_shared_unavailable"
else:
@@ -1039,7 +1066,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
try:
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"
else:
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)
try:
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"
else:
await self.async_set_unique_id(
@@ -61,6 +61,17 @@ PROVIDER_SYNOLOGY = "synology"
PROVIDER_NEXTCLOUD = "nextcloud"
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:
#
# - "folder": authenticated WebDAV folder. Points at any folder in a user's
@@ -81,6 +81,7 @@ from .const import (
ENTE_IMAGE_PREVIEW,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
ENRICHING_PROVIDERS,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
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:
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/"):
return True
@@ -1090,12 +1093,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise
items = data.get("items") or []
if self.provider in (
PROVIDER_LOCAL_FOLDER,
PROVIDER_IMMICH,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
) and items:
if 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.
@@ -2505,10 +2503,13 @@ class AlbumCoordinator(DataUpdateCoordinator):
# to bypass publicalbum.org's ~300 cap.
scraped_items: list[MediaItem] = []
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:
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
)
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)
api_items: list[MediaItem] = []
dropped_videos = 0
if page_items:
seen_urls: set[str] = set()
for raw in page_items:
if _looks_like_video(raw):
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)
if not url:
continue
@@ -2625,8 +2633,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise UpdateFailed("No photos returned from Google shared album")
_LOGGER.info(
"Album scraper: source=publicalbum items=%d (batchexecute=%d)",
len(api_items), len(scraped_items),
"Album scraper: source=publicalbum items=%d (batchexecute=%d, "
"videos dropped via scraper=%d)",
len(api_items), len(scraped_items), dropped_videos,
)
return {
"title": result.get("title") or self.entry.title,
@@ -71,16 +71,23 @@ class _AlbumKeys:
self.auth_key = auth_key
async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tuple[str | None, list[MediaItem]]:
"""Fetch a shared album in full. Returns (title, items).
async def fetch_album(
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
title. All actual photo enumeration goes through Google's ``batchexecute``
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)
if keys is None:
return None, []
return None, [], video_keys
items: list[MediaItem] = []
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
try:
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:
_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)",
len(items), page_no,
)
return title, items
return title, items, video_keys
# -- Internals ---------------------------------------------------------------
@@ -173,11 +180,14 @@ def _extract_title(html: str) -> str | 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.
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]] = []
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()
for raw in best:
if _is_video_item(raw):
_record_video_key(raw, video_keys)
continue
item = _parse_album_item(raw)
if item is None or item.url in seen:
@@ -226,6 +237,7 @@ async def _fetch_album_page(
page_id: str | None,
*,
timeout: float,
video_keys: set[str] | None = None,
) -> tuple[list[MediaItem], str | None]:
"""Call snAcKc once. ``page_id=""`` is treated as ``None`` (initial fetch)."""
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:
resp.raise_for_status()
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.
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] = []
for raw in raw_items:
if _is_video_item(raw):
_record_video_key(raw, video_keys)
continue
item = _parse_album_item(raw)
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
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:
"""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``).
_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
# ``ICLOUD_BACKEND_*`` constants in ``const.py``; they are duplicated here to
# 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")
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
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
@@ -155,7 +167,9 @@ def partition_host(token: str) -> str:
"""Derive the shared-streams partition host for a 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:
return ""
@@ -163,6 +177,8 @@ def partition_host(token: str) -> str:
partition = _base62_to_int(token[1:2])
else:
partition = _base62_to_int(token[1:3])
if partition is None:
return _DEFAULT_PARTITION_HOST
return f"p{partition:02d}-sharedstreams.icloud.com"
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"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.
API shape (PhotoPrism ``/api/v1``, Bearer auth):
- ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token, ...}``
(only needed for the username + password auth method).
- ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token,
config: {previewToken, ...}, ...}`` (only needed for the username +
password auth method).
- ``GET /api/v1/photos`` ``?count&offset&order=newest&primary=true`` plus a
filter (``s=<album_uid>``, ``q=subject:<uid>``, ``q=favorite:true``, or a
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
import json
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any
@@ -224,8 +226,8 @@ class PhotoprismClient:
# Bearer token used for API calls: the app password directly, or the
# session access token obtained from username + password.
self._bearer: str | None = token if auth_method == "app_password" else None
# Preview token captured from the most recent search response, used to
# build thumbnail URLs.
# Login config supplies a preview token even when search headers omit
# it. Later search responses can refresh it for thumbnail URLs.
self.preview_token: str | None = None
@property
@@ -251,6 +253,12 @@ class PhotoprismClient:
if not token:
raise PhotoprismAuthError("session login returned no access_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:
"""Ensure a usable Bearer token is available."""
@@ -259,8 +267,10 @@ class PhotoprismClient:
elif not self._bearer:
raise PhotoprismAuthError("no app password configured")
async def _get(self, path: str, params: dict[str, str] | None = None) -> tuple[Any, dict[str, str]]:
"""GET returning ``(json, headers)``, re-authenticating once on 401."""
async def _get(
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:
await self.async_authenticate()
session = async_get_clientsession(self.hass)
@@ -274,7 +284,7 @@ class PhotoprismClient:
await self._login()
continue
resp.raise_for_status()
return await resp.json(), dict(resp.headers)
return await resp.json(), resp.headers
raise PhotoprismAuthError("unauthorized after re-authentication")
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_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
@@ -17,11 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
AlbumTitleSensor(entry, coordinator),
CacheUsageSensor(entry, coordinator),
]
if coordinator.provider == PROVIDER_LOCAL_FOLDER:
# Diagnostic surface for the local-folder background enrichment
# (EXIF reads + reverse-geocode). For Google albums there's no
# enrichment work, so this sensor is omitted to keep the device
# screen tidy.
if coordinator.provider in ENRICHING_PROVIDERS:
# Diagnostic surface for the background enrichment pass (per-photo
# metadata reads and/or reverse-geocode). Providers with nothing to
# enrich omit it to keep the device screen tidy.
entities.append(EnrichmentProgressSensor(entry, coordinator))
async_add_entities(entities)
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.9.0";
const VERSION = "1.9.1";
const ANIMATED_TRANSITIONS = [
"fade",
+24 -24
View File
@@ -4,10 +4,10 @@
"state": "ON",
"led_brightness": 100,
"countdown_to_turn_off": 0,
"voltage": 119.1,
"voltage": 118.5,
"countdown_to_turn_on": 0,
"ac_frequency": 60,
"power_factor": 0.15,
"power_factor": 0.11,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -22,15 +22,15 @@
},
"0xffffb40e0607af27": {
"state": "ON",
"voltage": 118.4,
"voltage": 117.8,
"ac_frequency": 60,
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"power": 244.7,
"current": 2.29,
"energy": 40.38,
"power_factor": 0.9,
"power": 236.2,
"current": 2.04,
"energy": 40.43,
"power_factor": 0.98,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -45,10 +45,10 @@
"state": "ON",
"led_brightness": 100,
"countdown_to_turn_off": 0,
"voltage": 118.6,
"voltage": 117.6,
"countdown_to_turn_on": 0,
"energy": 79.66,
"power_factor": 0.31,
"power_factor": 0.91,
"ac_frequency": 60,
"update": {
"state": "idle",
@@ -58,8 +58,8 @@
"latest_release_notes": null
},
"linkquality": 80,
"power": 0.2,
"current": 0.01,
"power": 84.1,
"current": 0.83,
"power_on_behavior": "on"
},
"0xb40e060fffe031e3": {
@@ -74,13 +74,13 @@
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"voltage": 118.4,
"voltage": 116.6,
"state": "ON",
"ac_frequency": 60,
"energy": 162.1,
"power": 0.8,
"current": 0.05,
"power_factor": 0.39,
"energy": 162.12,
"power": 129.7,
"current": 1.27,
"power_factor": 0.94,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -95,13 +95,13 @@
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"voltage": 118.3,
"energy": 78.31,
"voltage": 117.6,
"energy": 78.32,
"state": "ON",
"power": 24.2,
"current": 0.35,
"power": 30.8,
"current": 0.43,
"ac_frequency": 60,
"power_factor": 0.63,
"power_factor": 0.62,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -114,12 +114,12 @@
},
"0xffffb40e060895b3": {
"state": "ON",
"voltage": 118.5,
"voltage": 118.3,
"ac_frequency": 60,
"energy": 11.09,
"current": 0.01,
"power": 0.1,
"power_factor": 0.11,
"power_factor": 0.16,
"linkquality": 105,
"update": {
"state": "idle",
@@ -136,7 +136,7 @@
"0xffffb40e0608864e": {
"led_brightness": 100,
"countdown_to_turn_off": 0,
"voltage": 119.5,
"voltage": 118.5,
"energy": 24.52,
"countdown_to_turn_on": 0,
"state": "ON",