11 files
This commit is contained in:
@@ -52,6 +52,7 @@ from .const import (
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
CONF_ICLOUD_BACKEND,
|
||||
ICLOUD_IMAGE_FULL,
|
||||
ICLOUD_IMAGE_PREVIEW,
|
||||
CONF_SYNOLOGY_URL,
|
||||
@@ -219,7 +220,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if not ALBUM_URL_RE.match(url):
|
||||
errors[CONF_ALBUM_URL] = "invalid_album_url"
|
||||
else:
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}:{name}")
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
@@ -249,7 +250,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if not path:
|
||||
errors[CONF_LOCAL_PATH] = "invalid_path"
|
||||
else:
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}:{name}")
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
@@ -283,7 +284,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}"
|
||||
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}:{name}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
@@ -395,7 +396,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
sel_id = json.dumps(selection, sort_keys=True)
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
|
||||
f"composite:{sel_id}:{raw_filter}"
|
||||
f"composite:{sel_id}:{raw_filter}:{name}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -574,7 +575,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
sel_id = json.dumps(selection, sort_keys=True)
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
|
||||
f"composite:{sel_id}:{raw_filter}"
|
||||
f"composite:{sel_id}:{raw_filter}:{name}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -659,18 +660,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
from . import icloud as icloud_api
|
||||
|
||||
token = icloud_api.parse_share_link(raw_url)
|
||||
if not token:
|
||||
parsed = icloud_api.parse_share(raw_url)
|
||||
if not parsed:
|
||||
errors[CONF_ICLOUD_URL] = "invalid_icloud_url"
|
||||
else:
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
token, backend = parsed
|
||||
if backend == icloud_api.BACKEND_CLOUDKIT:
|
||||
client = icloud_api.IcloudCloudKitClient(self.hass, token)
|
||||
else:
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad/expired link
|
||||
errors["base"] = "icloud_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}"
|
||||
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}:{name}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
@@ -678,6 +683,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_ICLOUD,
|
||||
CONF_ICLOUD_TOKEN: token,
|
||||
CONF_ICLOUD_BACKEND: backend,
|
||||
CONF_ICLOUD_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
@@ -866,7 +872,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
sel_id = json.dumps(selection, sort_keys=True)
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
||||
f"{self._syn_space}:{sel_id}"
|
||||
f"{self._syn_space}:{sel_id}:{name}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -957,7 +963,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
|
||||
f"{username}:{client.folder}"
|
||||
f"{username}:{client.folder}:{name}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -129,6 +129,17 @@ CONF_ICLOUD_URL = "icloud_url"
|
||||
CONF_ICLOUD_TOKEN = "icloud_token"
|
||||
CONF_ICLOUD_IMAGE_SIZE = "icloud_image_size"
|
||||
|
||||
# Which iCloud backend serves the album. Older share links
|
||||
# (``www.icloud.com/sharedalbum/#TOKEN``) use the legacy "shared streams" web
|
||||
# API; iOS 26/macOS 26 and newer links
|
||||
# (``photos.icloud.com/shared/album/TOKEN``) use CloudKit Web Services. Both are
|
||||
# public and need no account. Entries created before this option existed default
|
||||
# to the legacy backend.
|
||||
CONF_ICLOUD_BACKEND = "icloud_backend"
|
||||
ICLOUD_BACKEND_SHAREDSTREAMS = "sharedstreams"
|
||||
ICLOUD_BACKEND_CLOUDKIT = "cloudkit"
|
||||
DEFAULT_ICLOUD_BACKEND = ICLOUD_BACKEND_SHAREDSTREAMS
|
||||
|
||||
# ``full`` picks the largest derivative Apple generated (best for a slideshow,
|
||||
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
|
||||
ICLOUD_IMAGE_FULL = "full"
|
||||
|
||||
@@ -44,6 +44,9 @@ from .const import (
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
CONF_ICLOUD_BACKEND,
|
||||
DEFAULT_ICLOUD_BACKEND,
|
||||
ICLOUD_BACKEND_CLOUDKIT,
|
||||
CONF_SYNOLOGY_URL,
|
||||
CONF_SYNOLOGY_USERNAME,
|
||||
CONF_SYNOLOGY_PASSWORD,
|
||||
@@ -1523,18 +1526,23 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
async def _update_icloud(self) -> dict[str, Any]:
|
||||
"""Fetch photos from a public iCloud Shared Album.
|
||||
|
||||
The webstream response carries capture date and caption inline, so
|
||||
there is no enrichment pass. Signed image URLs are resolved up front
|
||||
and expire after roughly a day, so they are refreshed on every album
|
||||
refresh (like Google Photos).
|
||||
Two public backends are supported: the legacy "shared streams" API and
|
||||
the newer CloudKit backend used by iOS 26/macOS 26 share links. Both
|
||||
carry capture date and caption inline, so there is no enrichment pass.
|
||||
Signed image URLs are resolved up front and expire after a while, so
|
||||
they are refreshed on every album refresh (like Google Photos).
|
||||
"""
|
||||
from . import icloud as icloud_api
|
||||
|
||||
token = self.entry.data.get(CONF_ICLOUD_TOKEN)
|
||||
size = self.entry.data.get(CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE)
|
||||
backend = self.entry.data.get(CONF_ICLOUD_BACKEND, DEFAULT_ICLOUD_BACKEND)
|
||||
if not token:
|
||||
raise UpdateFailed("iCloud provider is missing the album token")
|
||||
|
||||
if backend == ICLOUD_BACKEND_CLOUDKIT:
|
||||
return await self._update_icloud_cloudkit(icloud_api, token, size)
|
||||
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
try:
|
||||
photos = await client.async_get_photos()
|
||||
@@ -1581,6 +1589,43 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_icloud_cloudkit(
|
||||
self, icloud_api, token: str, size: str
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch photos from a CloudKit-backed iCloud shared album."""
|
||||
client = icloud_api.IcloudCloudKitClient(self.hass, token)
|
||||
try:
|
||||
photos = await client.async_get_items(size)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error querying iCloud album: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed("No images found in the iCloud album")
|
||||
|
||||
items: list[MediaItem] = [
|
||||
MediaItem(
|
||||
url=p["url"],
|
||||
width=p.get("width"),
|
||||
height=p.get("height"),
|
||||
mime_type=None,
|
||||
filename=None,
|
||||
captured_at=p.get("captured_at"),
|
||||
description=p.get("description"),
|
||||
source_id=p.get("source_id"),
|
||||
exif_scanned=True,
|
||||
)
|
||||
for p in photos
|
||||
if p.get("url")
|
||||
]
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("Could not resolve any iCloud image URLs")
|
||||
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_synology(self) -> dict[str, Any]:
|
||||
"""Fetch photos from a Synology Photos library via its web API.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""iCloud Shared Album client and pure parsing helpers.
|
||||
|
||||
Talks to Apple's public "shared streams" web API for a shared photo album -
|
||||
the same undocumented JSON endpoints the iCloud web album viewer uses. No
|
||||
account or password is involved; the album's share token (the part after
|
||||
``#`` in the share link) is the only credential.
|
||||
Reads a public iCloud shared photo album. No account or password is involved;
|
||||
the album's share token (from the share link) is the only credential. Apple
|
||||
serves shared albums through two different public backends depending on when
|
||||
the album was created, and this module speaks both:
|
||||
|
||||
API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com``):
|
||||
Legacy "shared streams" backend (``www.icloud.com/sharedalbum/#TOKEN``):
|
||||
- ``POST {base}/webstream`` ``{"streamCtag": null}`` -> ``{streamName, photos:
|
||||
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
|
||||
dateCreated, caption, width, height}]}``. May first answer with a
|
||||
@@ -16,13 +16,24 @@ API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com`
|
||||
as ``https://{url_location}{url_path}``; it is a signed CDN link that
|
||||
expires after roughly a day, so it is refreshed on every album refresh.
|
||||
|
||||
Metadata: capture date (``dateCreated``) and caption are inline. Apple strips
|
||||
GPS from shared-album web data, so there is no location.
|
||||
CloudKit backend (``photos.icloud.com/shared/album/TOKEN``, iOS 26/macOS 26+):
|
||||
- ``POST ckdatabasews.icloud.com/.../public/records/resolve?shortGUID=TOKEN``
|
||||
resolves the short link to a shared CloudKit zone and hands back an
|
||||
anonymous ``publicAccessAuthToken`` plus the partition host to talk to.
|
||||
- ``POST {partition}/.../shared/records/query`` with the anonymous token and
|
||||
``sharing_url_key`` returns ``CPLMaster``/``CPLAsset`` records; each
|
||||
``CPLMaster`` carries signed ``downloadURL`` derivatives directly.
|
||||
|
||||
Both backends are POST with ``Content-Type: text/plain`` and
|
||||
``Origin: https://www.icloud.com``. Metadata (capture date, caption) is inline;
|
||||
Apple strips GPS from shared-album web data, so there is no location.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import async_timeout
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -35,6 +46,12 @@ _URL_BATCH = 25
|
||||
|
||||
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
# 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``.
|
||||
BACKEND_SHAREDSTREAMS = "sharedstreams"
|
||||
BACKEND_CLOUDKIT = "cloudkit"
|
||||
|
||||
# Headers Apple's web endpoints expect for the shared-streams API.
|
||||
_API_HEADERS = {
|
||||
"Content-Type": "text/plain",
|
||||
@@ -42,6 +59,37 @@ _API_HEADERS = {
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# --- CloudKit backend constants -------------------------------------------
|
||||
_CK_RESOLVE_HOST = "https://ckdatabasews.icloud.com"
|
||||
_CK_CONTAINER = "com.apple.photos.cloud"
|
||||
_CK_BUILD = "2626"
|
||||
# Sorted, non-hidden, non-deleted assets. Returns both CPLMaster (image
|
||||
# resources) and CPLAsset (metadata) records in one query.
|
||||
_CK_RECORD_TYPE = "CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted"
|
||||
_CK_PAGE = 200
|
||||
|
||||
_CK_HEADERS = {
|
||||
"Content-Type": "text/plain",
|
||||
"Origin": "https://www.icloud.com",
|
||||
"Referer": "https://www.icloud.com/",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# Image resource fields on a CPLMaster, in ascending size order, paired with
|
||||
# their width/height sibling fields.
|
||||
_CK_IMAGE_RES = (
|
||||
("resJPEGThumbRes", "resJPEGThumbWidth", "resJPEGThumbHeight"),
|
||||
("resJPEGMedRes", "resJPEGMedWidth", "resJPEGMedHeight"),
|
||||
("resJPEGLargeRes", "resJPEGLargeWidth", "resJPEGLargeHeight"),
|
||||
("resOriginalRes", "resOriginalWidth", "resOriginalHeight"),
|
||||
)
|
||||
# Original item types a browser can display directly. The JPEG derivatives are
|
||||
# always browser-safe; the original is only used as a fallback when it is one
|
||||
# of these.
|
||||
_CK_BROWSER_SAFE_ORIGINAL = {"public.jpeg", "public.png"}
|
||||
# itemType substrings that mark a master as a video (skipped in a slideshow).
|
||||
_CK_VIDEO_HINTS = ("movie", "video", "mpeg-4", "quicktime")
|
||||
|
||||
|
||||
def _base62_to_int(value: str) -> int:
|
||||
result = 0
|
||||
@@ -53,23 +101,51 @@ def _base62_to_int(value: str) -> int:
|
||||
def parse_share_link(url: str) -> str | None:
|
||||
"""Extract the album share token from a pasted iCloud link.
|
||||
|
||||
Accepts a full ``https://www.icloud.com/sharedalbum/#TOKEN`` link or a
|
||||
bare token. Returns ``None`` if nothing token-like is found.
|
||||
Accepts a legacy ``https://www.icloud.com/sharedalbum/#TOKEN`` link, a new
|
||||
``https://photos.icloud.com/shared/album/TOKEN`` link, or a bare token.
|
||||
Returns ``None`` if nothing token-like is found.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
text = url.strip()
|
||||
# Legacy links carry the token in the fragment; new links carry it as the
|
||||
# last path segment.
|
||||
if "#" in text:
|
||||
text = text.rsplit("#", 1)[1]
|
||||
elif "/" in text:
|
||||
text = text.rstrip("/").rsplit("/", 1)[1]
|
||||
# Tokens are base62 and start with an uppercase letter (A, B, ...).
|
||||
# Drop any leftover query string.
|
||||
text = text.split("?", 1)[0]
|
||||
token = text.strip()
|
||||
# Tokens are base62. Legacy tokens start with an uppercase letter; the new
|
||||
# CloudKit short GUIDs may start with a digit, so no leading-char check.
|
||||
if token and all(ch in _BASE62 for ch in token):
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def detect_backend(url: str) -> str:
|
||||
"""Return which iCloud backend a pasted share link belongs to.
|
||||
|
||||
New ``photos.icloud.com/shared/album/...`` links use the CloudKit backend;
|
||||
everything else (including bare tokens) defaults to the legacy shared
|
||||
streams backend, preserving behaviour for links created before CloudKit.
|
||||
"""
|
||||
text = (url or "").lower()
|
||||
if "/shared/album/" in text or "photos.icloud.com" in text:
|
||||
return BACKEND_CLOUDKIT
|
||||
return BACKEND_SHAREDSTREAMS
|
||||
|
||||
|
||||
def parse_share(url: str) -> tuple[str, str] | None:
|
||||
"""Parse a share link into ``(token, backend)`` or ``None`` if invalid."""
|
||||
token = parse_share_link(url)
|
||||
if not token:
|
||||
return None
|
||||
return token, detect_backend(url)
|
||||
|
||||
|
||||
|
||||
def partition_host(token: str) -> str:
|
||||
"""Derive the shared-streams partition host for a token.
|
||||
|
||||
@@ -250,3 +326,248 @@ class IcloudClient:
|
||||
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
return payload.get("streamName") if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
# --- CloudKit backend helpers ---------------------------------------------
|
||||
|
||||
|
||||
def _ck_value(fields: dict[str, Any], name: str) -> Any:
|
||||
"""Return the inner ``value`` of a CloudKit field, or ``None``."""
|
||||
field = fields.get(name) if isinstance(fields, dict) else None
|
||||
return field.get("value") if isinstance(field, dict) else None
|
||||
|
||||
|
||||
def _ck_decode_filename(fields: dict[str, Any]) -> str | None:
|
||||
"""Decode the (base64) ``filenameEnc`` field of a CPLMaster, if present."""
|
||||
raw = _ck_value(fields, "filenameEnc")
|
||||
if isinstance(raw, str) and raw:
|
||||
try:
|
||||
return base64.b64decode(raw).decode("utf-8", "replace")
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ck_is_video(master_fields: dict[str, Any]) -> bool:
|
||||
"""Return ``True`` when a CPLMaster describes a video (not a still image).
|
||||
|
||||
Only the ``itemType`` is used: Live Photos keep an image ``itemType`` (and
|
||||
carry a non-zero asset duration), so they are treated as stills and shown.
|
||||
"""
|
||||
item_type = _ck_value(master_fields, "itemType")
|
||||
if isinstance(item_type, str):
|
||||
lowered = item_type.lower()
|
||||
return any(hint in lowered for hint in _CK_VIDEO_HINTS)
|
||||
return False
|
||||
|
||||
|
||||
def pick_ck_resource(
|
||||
master_fields: dict[str, Any], size: str
|
||||
) -> tuple[str, Any, Any] | None:
|
||||
"""Pick a downloadable image derivative for the requested display ``size``.
|
||||
|
||||
Returns ``(download_url, width, height)``. ``full`` selects the largest
|
||||
available derivative (best for a slideshow); ``preview`` selects the
|
||||
smallest. JPEG derivatives are always browser-safe; the original is only
|
||||
considered when it is itself a browser-displayable format.
|
||||
"""
|
||||
if not isinstance(master_fields, dict):
|
||||
return None
|
||||
item_type = _ck_value(master_fields, "itemType")
|
||||
item_type = item_type.lower() if isinstance(item_type, str) else ""
|
||||
original_safe = item_type in _CK_BROWSER_SAFE_ORIGINAL
|
||||
|
||||
candidates: list[tuple[int, str, Any, Any]] = []
|
||||
for res_key, w_key, h_key in _CK_IMAGE_RES:
|
||||
res = master_fields.get(res_key)
|
||||
if not isinstance(res, dict):
|
||||
continue
|
||||
val = res.get("value")
|
||||
if not isinstance(val, dict):
|
||||
continue
|
||||
download_url = val.get("downloadURL")
|
||||
if not download_url:
|
||||
continue
|
||||
if res_key == "resOriginalRes" and not original_safe:
|
||||
continue
|
||||
width = _ck_value(master_fields, w_key)
|
||||
height = _ck_value(master_fields, h_key)
|
||||
try:
|
||||
rank = int(width)
|
||||
except (TypeError, ValueError):
|
||||
rank = 0
|
||||
candidates.append((rank, download_url, width, height))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda c: c[0])
|
||||
_rank, download_url, width, height = candidates[0 if size == "preview" else -1]
|
||||
return download_url, width, height
|
||||
|
||||
|
||||
def build_ck_image_url(download_url: str | None, filename: str | None = None) -> str | None:
|
||||
"""Fill the ``${f}`` filename placeholder in a CloudKit download URL."""
|
||||
if not download_url:
|
||||
return None
|
||||
return download_url.replace("${f}", quote(filename or "image", safe=""))
|
||||
|
||||
|
||||
def _ck_share_title(resolve_result: dict[str, Any]) -> str | None:
|
||||
"""Return the album title from a resolve result's share record, if any."""
|
||||
share = resolve_result.get("share") if isinstance(resolve_result, dict) else None
|
||||
fields = share.get("fields") if isinstance(share, dict) else None
|
||||
title = _ck_value(fields, "cloudkit.title") if isinstance(fields, dict) else None
|
||||
if isinstance(title, str) and title.strip():
|
||||
return title.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int | None:
|
||||
return int(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def parse_ck_records(records: list[Any], size: str) -> list[dict[str, Any]]:
|
||||
"""Join CPLMaster + CPLAsset records into normalized photo dicts.
|
||||
|
||||
Each returned dict has ``source_id``, ``url``, ``width``, ``height``,
|
||||
``captured_at`` (epoch ms) and ``description``.
|
||||
"""
|
||||
masters: dict[str, dict[str, Any]] = {}
|
||||
assets: list[dict[str, Any]] = []
|
||||
for rec in records:
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
if rec.get("recordType") == "CPLMaster":
|
||||
masters[rec.get("recordName")] = rec.get("fields") or {}
|
||||
elif rec.get("recordType") == "CPLAsset":
|
||||
assets.append(rec)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for asset in assets:
|
||||
af = asset.get("fields") or {}
|
||||
if _ck_value(af, "isHidden") or _ck_value(af, "trashReason"):
|
||||
continue
|
||||
ref = af.get("masterRef")
|
||||
ref_val = ref.get("value") if isinstance(ref, dict) else None
|
||||
master_name = ref_val.get("recordName") if isinstance(ref_val, dict) else None
|
||||
master_fields = masters.get(master_name)
|
||||
if not master_fields or _ck_is_video(master_fields):
|
||||
continue
|
||||
picked = pick_ck_resource(master_fields, size)
|
||||
if not picked:
|
||||
continue
|
||||
download_url, width, height = picked
|
||||
url = build_ck_image_url(download_url, _ck_decode_filename(master_fields))
|
||||
if not url:
|
||||
continue
|
||||
source_id = master_name or asset.get("recordName")
|
||||
if not source_id or source_id in seen:
|
||||
continue
|
||||
seen.add(source_id)
|
||||
out.append(
|
||||
{
|
||||
"source_id": source_id,
|
||||
"url": url,
|
||||
"width": _to_int(width),
|
||||
"height": _to_int(height),
|
||||
"captured_at": _to_int(_ck_value(af, "assetDate")),
|
||||
"description": None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class IcloudCloudKitClient:
|
||||
"""Async client for the CloudKit-backed iCloud shared album backend.
|
||||
|
||||
Everything is anonymous: a ``resolve`` call turns the short share token
|
||||
into a shared CloudKit zone plus a short-lived anonymous access token, and
|
||||
a ``records/query`` call returns the album's photos with signed image URLs
|
||||
already embedded.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, token: str) -> None:
|
||||
self.hass = hass
|
||||
self.token = token
|
||||
# Cached (zone, base_url, access_token, title) from the resolve call.
|
||||
self._resolved: tuple[dict[str, Any], str, str, str | None] | None = None
|
||||
|
||||
async def _post(self, url: str, body: dict[str, Any]) -> tuple[int, bytes]:
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with async_timeout.timeout(_TIMEOUT):
|
||||
async with session.post(url, json=body, headers=_CK_HEADERS) as resp:
|
||||
return resp.status, await resp.read()
|
||||
|
||||
async def _resolve(self) -> tuple[dict[str, Any], str, str, str | None]:
|
||||
if self._resolved is not None:
|
||||
return self._resolved
|
||||
import json as _json
|
||||
|
||||
url = (
|
||||
f"{_CK_RESOLVE_HOST}/database/1/{_CK_CONTAINER}/production"
|
||||
f"/public/records/resolve?ckjsBuildVersion={_CK_BUILD}"
|
||||
f"&shortGUID={self.token}"
|
||||
)
|
||||
status, raw = await self._post(url, {"shortGUIDs": [{"value": self.token}]})
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud resolve failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
results = payload.get("results") if isinstance(payload, dict) else None
|
||||
if not results:
|
||||
raise RuntimeError("iCloud share link did not resolve to an album")
|
||||
result = results[0]
|
||||
zone = result.get("zoneID")
|
||||
access = result.get("anonymousPublicAccess") or {}
|
||||
access_token = access.get("token")
|
||||
partition = access.get("databasePartition")
|
||||
if not zone or not access_token or not partition:
|
||||
raise RuntimeError("iCloud shared album is not publicly accessible")
|
||||
base = f"{partition}/database/1/{_CK_CONTAINER}/production"
|
||||
self._resolved = (zone, base, access_token, _ck_share_title(result))
|
||||
return self._resolved
|
||||
|
||||
def _query_url(self, base: str, access_token: str) -> str:
|
||||
query = urlencode(
|
||||
{
|
||||
"remapEnums": "true",
|
||||
"getCurrentSyncToken": "true",
|
||||
"sharing_url_key": self.token,
|
||||
"publicAccessAuthToken": access_token,
|
||||
}
|
||||
)
|
||||
return f"{base}/shared/records/query?{query}"
|
||||
|
||||
async def async_validate(self) -> str | None:
|
||||
"""Return the album title if the share link resolves, else raise."""
|
||||
_zone, _base, _token, title = await self._resolve()
|
||||
return title
|
||||
|
||||
async def async_get_items(self, size: str) -> list[dict[str, Any]]:
|
||||
"""Return normalized photo dicts for the album (see ``parse_ck_records``)."""
|
||||
import json as _json
|
||||
|
||||
zone, base, access_token, _title = await self._resolve()
|
||||
url = self._query_url(base, access_token)
|
||||
records: list[Any] = []
|
||||
continuation: str | None = None
|
||||
while len(records) < _MAX_ASSETS:
|
||||
body: dict[str, Any] = {
|
||||
"zoneID": zone,
|
||||
"query": {"recordType": _CK_RECORD_TYPE},
|
||||
"resultsLimit": _CK_PAGE,
|
||||
}
|
||||
if continuation:
|
||||
body["continuationMarker"] = continuation
|
||||
status, raw = await self._post(url, body)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud records query failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
batch = payload.get("records") if isinstance(payload, dict) else None
|
||||
if not batch:
|
||||
break
|
||||
records.extend(batch)
|
||||
continuation = payload.get("continuationMarker") if isinstance(payload, dict) else None
|
||||
if not continuation:
|
||||
break
|
||||
return parse_ck_records(records, size)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.6.2"
|
||||
"version": "1.7.0"
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
},
|
||||
"icloud": {
|
||||
"title": "iCloud Shared Album",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). No Apple ID or password is needed - the link itself is the credential. Capture date and captions come through; Apple does not include location in shared albums.",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). Both the older icloud.com/sharedalbum links and the newer photos.icloud.com/shared/album links work. No Apple ID or password is needed - the link itself is the credential. Capture date comes through; Apple does not include location in shared albums.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"icloud_url": "Shared album link",
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
},
|
||||
"icloud": {
|
||||
"title": "iCloud Shared Album",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). No Apple ID or password is needed - the link itself is the credential. Capture date and captions come through; Apple does not include location in shared albums.",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). Both the older icloud.com/sharedalbum links and the newer photos.icloud.com/shared/album links work. No Apple ID or password is needed - the link itself is the credential. Capture date comes through; Apple does not include location in shared albums.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"icloud_url": "Shared album link",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.6.2";
|
||||
const VERSION = "1.7.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+28
-28
@@ -4,7 +4,7 @@
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 121.5,
|
||||
"voltage": 120.9,
|
||||
"countdown_to_turn_on": 0,
|
||||
"ac_frequency": 60,
|
||||
"power_factor": 0.11,
|
||||
@@ -22,15 +22,15 @@
|
||||
},
|
||||
"0xffffb40e0607af27": {
|
||||
"state": "ON",
|
||||
"voltage": 121.7,
|
||||
"voltage": 121.3,
|
||||
"ac_frequency": 60,
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"power": 2.1,
|
||||
"current": 0.12,
|
||||
"energy": 24.05,
|
||||
"power_factor": 0.14,
|
||||
"power": 4,
|
||||
"current": 0.13,
|
||||
"energy": 24.1,
|
||||
"power_factor": 0.25,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -47,7 +47,7 @@
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 120.7,
|
||||
"countdown_to_turn_on": 0,
|
||||
"energy": 44.18,
|
||||
"energy": 44.22,
|
||||
"power_factor": 0.2,
|
||||
"ac_frequency": 60,
|
||||
"update": {
|
||||
@@ -58,8 +58,8 @@
|
||||
"latest_release_notes": null
|
||||
},
|
||||
"linkquality": 83,
|
||||
"power": 83.7,
|
||||
"current": 0.82,
|
||||
"power": 0.2,
|
||||
"current": 0.02,
|
||||
"power_on_behavior": "on"
|
||||
},
|
||||
"0xb40e060fffe031e3": {
|
||||
@@ -74,13 +74,13 @@
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"voltage": 120.3,
|
||||
"voltage": 120,
|
||||
"state": "ON",
|
||||
"ac_frequency": 60,
|
||||
"energy": 89.25,
|
||||
"power": 0.8,
|
||||
"energy": 89.31,
|
||||
"power": 3.7,
|
||||
"current": 0.05,
|
||||
"power_factor": 0.38,
|
||||
"power_factor": 0.41,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -95,13 +95,13 @@
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"voltage": 121.4,
|
||||
"energy": 36.66,
|
||||
"voltage": 120.7,
|
||||
"energy": 36.73,
|
||||
"state": "ON",
|
||||
"power": 64.6,
|
||||
"current": 0.63,
|
||||
"power": 77.4,
|
||||
"current": 0.84,
|
||||
"ac_frequency": 60,
|
||||
"power_factor": 0.85,
|
||||
"power_factor": 0.8,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -114,12 +114,12 @@
|
||||
},
|
||||
"0xffffb40e060895b3": {
|
||||
"state": "ON",
|
||||
"voltage": 121,
|
||||
"voltage": 120.8,
|
||||
"ac_frequency": 60,
|
||||
"energy": 5.74,
|
||||
"current": 0.02,
|
||||
"current": 0.01,
|
||||
"power": 0.1,
|
||||
"power_factor": 0.22,
|
||||
"power_factor": 0.11,
|
||||
"linkquality": 109,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
@@ -136,14 +136,14 @@
|
||||
"0xffffb40e0608864e": {
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 121.9,
|
||||
"voltage": 121.4,
|
||||
"energy": 16.08,
|
||||
"countdown_to_turn_on": 0,
|
||||
"state": "ON",
|
||||
"current": 0.02,
|
||||
"ac_frequency": 60,
|
||||
"power": 0.4,
|
||||
"power_factor": 0.14,
|
||||
"power_factor": 0.2,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -172,7 +172,7 @@
|
||||
"0xffffb40e060893d8": {
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"voltage": 122.1,
|
||||
"voltage": 121.7,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"energy": 2.98,
|
||||
@@ -187,12 +187,12 @@
|
||||
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||
"latest_release_notes": null
|
||||
},
|
||||
"power_factor": 0.06,
|
||||
"power": 0.1
|
||||
"power_factor": 0,
|
||||
"power": 0.3
|
||||
},
|
||||
"0xa4c1380d0679ffff": {
|
||||
"battery": 100,
|
||||
"temperature": 26.8,
|
||||
"temperature": 26.9,
|
||||
"temperature_units": "celsius",
|
||||
"temperature_calibration": 0,
|
||||
"update": {
|
||||
@@ -245,7 +245,7 @@
|
||||
},
|
||||
"0xb40e060fffe717c5": {
|
||||
"battery": 100,
|
||||
"occupancy": true,
|
||||
"occupancy": false,
|
||||
"tamper": false,
|
||||
"battery_low": false,
|
||||
"linkquality": 65,
|
||||
|
||||
Reference in New Issue
Block a user