11 files
This commit is contained in:
1 parent
f6b9dc47d6
commit
ff4156dc89
11 files changed
+202
-70
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user