203 files
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
2026.9.2
|
||||
2026.9.3
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.9.2", "start_ts": 1789746086.7330012}
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.9.3", "start_ts": 1789839023.3776524}
|
||||
+32
-32
@@ -81,117 +81,117 @@
|
||||
},
|
||||
{
|
||||
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
|
||||
"url": "/taskmate/taskmate-attr-resolver.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-attr-resolver.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0796c92931dd458e81dc9c2677248274",
|
||||
"url": "/taskmate/taskmate-localize.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-localize.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2e281920ac7409583eafff1f1db69bd",
|
||||
"url": "/taskmate/taskmate-design.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-design.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9eae9632f0734480be5edd784b030673",
|
||||
"url": "/taskmate/taskmate-badges-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-badges-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5df49cd8a4eb420497b5df22cb31362c",
|
||||
"url": "/taskmate/taskmate-child-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-child-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
|
||||
"url": "/taskmate/taskmate-rewards-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-rewards-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "787a96f9c30548708165a48457c99feb",
|
||||
"url": "/taskmate/taskmate-approvals-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-approvals-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "84031597de424ad68e7c2bc66d06b72e",
|
||||
"url": "/taskmate/taskmate-points-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-points-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "02613225844746b699bfbfac9642eca2",
|
||||
"url": "/taskmate/taskmate-reorder-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-reorder-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "7a06a07e17ba4b31b712bc91b77360b4",
|
||||
"url": "/taskmate/taskmate-overview-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-overview-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "08b42b53b33b4fa88db932455cf8d5aa",
|
||||
"url": "/taskmate/taskmate-activity-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-activity-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "15907eff51784acdba88ff694b574509",
|
||||
"url": "/taskmate/taskmate-streak-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-streak-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
|
||||
"url": "/taskmate/taskmate-weekly-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-weekly-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "289ce5943f5a4972a593697db4c96ece",
|
||||
"url": "/taskmate/taskmate-graph-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-graph-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2904acee4c74a398dcc7b996c23e7e7",
|
||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "46d378736fd54a26be253f2dfb27202e",
|
||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "db11ae36457842fa826c7991e1d9c6f4",
|
||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
|
||||
"url": "/taskmate/taskmate-penalties-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-penalties-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1959e1629de2422daa8adaf87069c288",
|
||||
"url": "/taskmate/taskmate-bonuses-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-bonuses-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "2548524b269c4959bc7e0e967df943af",
|
||||
"url": "/taskmate/taskmate-points-display-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-points-display-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "36ada49368db4187a283fbbbef45454f",
|
||||
"url": "/taskmate/taskmate-calendar-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-calendar-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "67c32687acd2497b90030ef141f4b6db",
|
||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
|
||||
"url": "/taskmate/taskmate-family-goal-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-family-goal-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -206,7 +206,7 @@
|
||||
},
|
||||
{
|
||||
"id": "d3c3e144a91247ce8ac524814737d7f8",
|
||||
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164663",
|
||||
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164670",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -276,7 +276,7 @@
|
||||
},
|
||||
{
|
||||
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
||||
"url": "/taskmate/taskmate-routine-card.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-routine-card.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -304,11 +304,6 @@
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6f76aeefdbb14d87a0da4c28957efb47",
|
||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.9.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "aafb0bb764874be384629a0ca0a18c53",
|
||||
"url": "/my_music_library/my-music-library-card.js?v=3.14.0",
|
||||
@@ -316,11 +311,16 @@
|
||||
},
|
||||
{
|
||||
"id": "1a4939ce1d2b47f89502176d3c5ac6e7",
|
||||
"url": "/taskmate/taskmate-sounds.js?v=5.6.1",
|
||||
"url": "/taskmate/taskmate-sounds.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6669c4f2fe44455b96d5e652e39bea01",
|
||||
"id": "2dfca9e286bb4c5fb92ac3a46332fb3b",
|
||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.10.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "2e1a9bdf3bfd46859371e8f3389286a9",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,29 @@ _SKIP_SEARCH_LIMIT = 30
|
||||
|
||||
_MAX_RENDER_ATTEMPTS = 10
|
||||
|
||||
# Fixed floor (in items) for the pairing cooldown exclusion zone, applied on
|
||||
# each side of the current index in addition to the percentage-based radius.
|
||||
# Keeps tiny albums from pairing consecutive/near-consecutive shots even when
|
||||
# a small percentage rounds down to zero.
|
||||
_PAIR_GAP_FLOOR = 2
|
||||
|
||||
|
||||
def _pair_exclusion_radius(n: int, min_gap_percent: float) -> int:
|
||||
"""Items to exclude on each side of the current index for pairing.
|
||||
|
||||
Combines ``_PAIR_GAP_FLOOR`` with a percentage of the album size so tiny
|
||||
albums still get some separation while large albums scale
|
||||
proportionally. A ``min_gap_percent`` of 0 disables the cooldown
|
||||
entirely. The result is capped well below ``n / 2`` so eligible
|
||||
candidates always remain, even for small albums (a ring buffer of
|
||||
``n`` items only has ``n - 1`` non-self candidates to begin with).
|
||||
"""
|
||||
if n <= 3 or min_gap_percent <= 0:
|
||||
return 0
|
||||
radius = max(_PAIR_GAP_FLOOR, round(n * (min_gap_percent / 100.0)))
|
||||
max_radius = (n - 3) // 2
|
||||
return max(0, min(radius, max_radius))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _NavigationCursor:
|
||||
@@ -315,6 +338,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
"aspect_ratio": self.store.aspect_ratio,
|
||||
"pair_divider_px": int(self.store.pair_divider_px),
|
||||
"pair_divider_color": self.store.pair_divider_color,
|
||||
"pair_min_gap_percent": int(self.store.pair_min_gap_percent),
|
||||
"frame_id": self._frame_id,
|
||||
"navigation_buffer_size": self._buffer_depth,
|
||||
"previous_frames_cached": len(self._previous_frames),
|
||||
@@ -1129,6 +1153,27 @@ class AlbumSlideshowCamera(Camera):
|
||||
) -> tuple[Image.Image, MediaItem] | None:
|
||||
"""Find an image with the opposite orientation of the canvas.
|
||||
|
||||
By default (``pair_min_gap_percent`` = 0) this is the original
|
||||
search: walk forward from the very next item, nearest candidate
|
||||
wins, fully deterministic. Setting ``pair_min_gap_percent`` above 0
|
||||
opts into a cooldown search instead - candidates within that
|
||||
percentage of the current index (wrapping on both sides, since the
|
||||
album is a ring buffer) are excluded so the partner comes from a
|
||||
meaningfully different part of the collection, and the remaining
|
||||
candidates are shuffled before scanning so the same nearby partner
|
||||
isn't picked deterministically every time. Small albums where the
|
||||
cooldown would leave no candidates fall back to the deterministic
|
||||
search (see ``_pair_exclusion_radius``); no eligible partner at all
|
||||
(orientation, recent-repeat, or download failures) is a normal
|
||||
outcome and simply returns ``None``, which the caller renders as a
|
||||
single unpaired image.
|
||||
|
||||
This is a pure function of ``self._index``, ``self._recent_urls``
|
||||
and ``self._rng``'s state, which is exactly what the navigation
|
||||
buffer's cursor snapshots capture - so replaying a cached cursor
|
||||
(Previous/Next) always reproduces the same paired frame; only
|
||||
rendering a slide for the first time consumes new randomness.
|
||||
|
||||
Uses metadata wherever possible - only candidates without width/height
|
||||
metadata are downloaded and decoded for their orientation. The returned
|
||||
PIL image is the caller's to close. The matching ``MediaItem`` is
|
||||
@@ -1137,12 +1182,22 @@ class AlbumSlideshowCamera(Camera):
|
||||
if not items:
|
||||
return None
|
||||
n = len(items)
|
||||
radius = _pair_exclusion_radius(n, self.store.pair_min_gap_percent)
|
||||
if radius > 0:
|
||||
excluded = {(self._index + d) % n for d in range(-radius, radius + 1)}
|
||||
candidates = [idx for idx in range(n) if idx not in excluded]
|
||||
self._rng.shuffle(candidates)
|
||||
else:
|
||||
candidates = (
|
||||
(self._index + offset) % n
|
||||
for offset in range(1, min(n, limit + 1))
|
||||
)
|
||||
|
||||
tries = 0
|
||||
offset = 1
|
||||
while tries < limit and offset < n:
|
||||
idx = (self._index + offset) % n
|
||||
for idx in candidates:
|
||||
if tries >= limit:
|
||||
break
|
||||
it = items[idx]
|
||||
offset += 1
|
||||
tries += 1
|
||||
|
||||
if it.url in self._recent_urls:
|
||||
|
||||
@@ -1254,7 +1254,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="ente", data_schema=schema, errors=errors
|
||||
step_id="ente",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"example_url": "https://albums.ente.io/?t=TOKEN#KEY"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -290,6 +290,13 @@ DEFAULT_ORDER_MODE = ORDER_RANDOM
|
||||
DEFAULT_ASPECT_RATIO = "16:9"
|
||||
DEFAULT_PAIR_DIVIDER_PX = 8
|
||||
DEFAULT_PAIR_DIVIDER_COLOR = "#FFFFFF"
|
||||
# Minimum separation (as a percentage of album size) enforced between the
|
||||
# current slide's index and a candidate orientation-mismatch pairing
|
||||
# partner. Helps avoid nearby photos; does not detect photo sessions. Opt-in: 0
|
||||
# (the default) keeps the original deterministic "nearest candidate first"
|
||||
# search; a positive value switches to a shuffled search that excludes the
|
||||
# cooldown zone around the current index.
|
||||
DEFAULT_PAIR_MIN_GAP_PERCENT = 0
|
||||
DEFAULT_RECURSIVE = True
|
||||
# Per-album download cache. Multiple albums add up: 4 × 150 MB = 600 MB
|
||||
# of just-in-case downloaded JPEGs. 75 MB caches roughly 10-20 photos
|
||||
|
||||
@@ -9,8 +9,8 @@ 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
|
||||
``330`` redirect carrying an ``X-Apple-MMe-Host`` header pointing at the
|
||||
correct partition host; retry there.
|
||||
``330`` redirect carrying ``X-Apple-MMe-Host`` in the JSON body (or a
|
||||
response header) pointing at the correct partition host; retry there.
|
||||
- ``POST {base}/webasseturls`` ``{"photoGuids": [...]}`` -> ``{items:
|
||||
{<checksum>: {url_location, url_path, url_expiry}}}``. Build the image URL
|
||||
as ``https://{url_location}{url_path}``; it is a signed CDN link that
|
||||
@@ -31,14 +31,35 @@ Apple strips GPS from shared-album web data, so there is no location.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from asyncio import sleep as _sleep
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from aiohttp import (
|
||||
ClientConnectionError,
|
||||
ClientError,
|
||||
ClientPayloadError,
|
||||
ClientSSLError,
|
||||
ClientTimeout,
|
||||
)
|
||||
import async_timeout
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# CloudKit retains its existing timeout. Legacy listing/URL resolution can be
|
||||
# slow for large albums; keep connection, idle-read and total time bounded.
|
||||
_TIMEOUT = 30
|
||||
_LEGACY_TIMEOUT = ClientTimeout(total=90, connect=15, sock_read=60)
|
||||
_REQUEST_ATTEMPTS = 2
|
||||
_RETRY_DELAY_SECONDS = 1.0
|
||||
_RETRYABLE_STATUSES = {408, 500, 502, 503, 504}
|
||||
_PARTITION_HOST_RE = re.compile(r"p[0-9]+-sharedstreams\.icloud\.com")
|
||||
_MAX_ASSETS = 20_000
|
||||
# webasseturls request batch size. A single call handled 40+ guids fine in
|
||||
# testing; chunking keeps request bodies bounded for very large albums.
|
||||
@@ -277,6 +298,10 @@ def parse_photo_meta(photo: dict[str, Any]) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
class IcloudRequestError(RuntimeError):
|
||||
"""A request failure safe to surface without share URLs or response data."""
|
||||
|
||||
|
||||
class IcloudClient:
|
||||
"""Thin async wrapper over the iCloud shared-streams web API."""
|
||||
|
||||
@@ -291,62 +316,139 @@ class IcloudClient:
|
||||
def base_url(self) -> str:
|
||||
return base_url(self.token, self._host)
|
||||
|
||||
async def _post(self, path: str, body: dict[str, Any]) -> tuple[int, dict[str, str], bytes]:
|
||||
async def _post(
|
||||
self, path: str, body: dict[str, Any], *, operation: str
|
||||
) -> dict[str, Any]:
|
||||
"""Read JSON with a bounded retry and at most one trusted redirect.
|
||||
|
||||
All calls are read-only despite using POST. Retry only the failing
|
||||
request, not previously completed URL batches. Never include a share
|
||||
token, response body, or raw transport exception in diagnostics.
|
||||
"""
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with async_timeout.timeout(_TIMEOUT):
|
||||
async with session.post(
|
||||
self.base_url + path, json=body, headers=_API_HEADERS
|
||||
) as resp:
|
||||
return resp.status, dict(resp.headers), await resp.read()
|
||||
started = monotonic()
|
||||
attempt = 1
|
||||
redirects = 0
|
||||
while True:
|
||||
request_started = monotonic()
|
||||
host = self._host or partition_host(self.token)
|
||||
phase = "waiting for response headers"
|
||||
retryable = False
|
||||
try:
|
||||
async with session.post(
|
||||
self.base_url + path,
|
||||
json=body,
|
||||
headers=_API_HEADERS,
|
||||
timeout=_LEGACY_TIMEOUT,
|
||||
allow_redirects=False,
|
||||
) as resp:
|
||||
status = resp.status
|
||||
if status not in (200, 330):
|
||||
detail = f"HTTP {status}"
|
||||
retryable = status in _RETRYABLE_STATUSES
|
||||
else:
|
||||
# A usable 330 header needs no body. Do not let a
|
||||
# stalled redirect body prevent reaching the new host.
|
||||
target = resp.headers.get("X-Apple-MMe-Host") if status == 330 else None
|
||||
payload = None
|
||||
if not target:
|
||||
phase = "reading response body"
|
||||
raw = await resp.read()
|
||||
phase = "decoding JSON"
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
payload = None
|
||||
if status == 330:
|
||||
phase = "handling partition redirect"
|
||||
if not target and isinstance(payload, dict):
|
||||
target = payload.get("X-Apple-MMe-Host")
|
||||
target = target.strip().lower() if isinstance(target, str) else ""
|
||||
if not _PARTITION_HOST_RE.fullmatch(target):
|
||||
detail = "HTTP 330 with invalid or missing partition host"
|
||||
elif redirects or target == host:
|
||||
detail = "HTTP 330 partition redirect loop"
|
||||
else:
|
||||
# The share token must never be sent to a
|
||||
# caller-supplied or lookalike redirect host.
|
||||
self._host = target
|
||||
redirects += 1
|
||||
_LOGGER.debug(
|
||||
"iCloud %s: partition redirect %s -> %s",
|
||||
operation, host, target,
|
||||
)
|
||||
continue
|
||||
elif isinstance(payload, dict):
|
||||
_LOGGER.debug(
|
||||
"iCloud %s succeeded (host=%s; endpoint=%s; "
|
||||
"attempt=%d/%d; elapsed=%.1fs; total_elapsed=%.1fs)",
|
||||
operation, host, path, attempt, _REQUEST_ATTEMPTS,
|
||||
monotonic() - request_started, monotonic() - started,
|
||||
)
|
||||
return payload
|
||||
else:
|
||||
detail = "Invalid JSON response (expected an object)"
|
||||
except TimeoutError:
|
||||
detail = (
|
||||
"TimeoutError "
|
||||
f"(connect={_LEGACY_TIMEOUT.connect}s, "
|
||||
f"read={_LEGACY_TIMEOUT.sock_read}s, "
|
||||
f"total={_LEGACY_TIMEOUT.total}s)"
|
||||
)
|
||||
retryable = True
|
||||
except ClientSSLError as err:
|
||||
detail = type(err).__name__
|
||||
except (ClientConnectionError, ClientPayloadError) as err:
|
||||
detail = type(err).__name__
|
||||
retryable = True
|
||||
except ClientError as err:
|
||||
detail = type(err).__name__
|
||||
|
||||
error = IcloudRequestError(
|
||||
f"iCloud {operation} failed while {phase} "
|
||||
f"(host={host}; endpoint={path}; attempt={attempt}/{_REQUEST_ATTEMPTS}; "
|
||||
f"elapsed={monotonic() - request_started:.1f}s; "
|
||||
f"total_elapsed={monotonic() - started:.1f}s): {detail}"
|
||||
)
|
||||
if not retryable or attempt >= _REQUEST_ATTEMPTS:
|
||||
# Raw aiohttp errors can contain the full album URL. Suppress
|
||||
# that context even when HA logs a traceback of this error.
|
||||
raise error from None
|
||||
_LOGGER.warning("%s; retrying in %.1fs", error, _RETRY_DELAY_SECONDS)
|
||||
await _sleep(_RETRY_DELAY_SECONDS)
|
||||
attempt += 1
|
||||
|
||||
async def async_get_photos(self) -> list[dict[str, Any]]:
|
||||
"""Fetch the album's photo list, following a partition redirect once."""
|
||||
import json as _json
|
||||
|
||||
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
|
||||
redirect_host = headers.get("X-Apple-MMe-Host")
|
||||
if redirect_host and redirect_host != self._host:
|
||||
# Our partition guess was wrong; Apple told us the right host.
|
||||
self._host = redirect_host
|
||||
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
payload = await self._post(
|
||||
"/webstream", {"streamCtag": None}, operation="photo list"
|
||||
)
|
||||
return parse_webstream(payload)[:_MAX_ASSETS]
|
||||
|
||||
async def async_get_asset_urls(
|
||||
self, guids: list[str]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Resolve signed asset URLs for photo guids, keyed by checksum."""
|
||||
import json as _json
|
||||
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
batch_count = (len(guids) + _URL_BATCH - 1) // _URL_BATCH
|
||||
for start in range(0, len(guids), _URL_BATCH):
|
||||
chunk = guids[start : start + _URL_BATCH]
|
||||
status, _headers, raw = await self._post(
|
||||
"/webasseturls", {"photoGuids": chunk}
|
||||
batch_number = start // _URL_BATCH + 1
|
||||
payload = await self._post(
|
||||
"/webasseturls", {"photoGuids": chunk},
|
||||
operation=f"image URLs batch {batch_number}/{batch_count} ({len(chunk)} photos)",
|
||||
)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud webasseturls failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
items = payload.get("items")
|
||||
if isinstance(items, dict):
|
||||
out.update(items)
|
||||
return out
|
||||
|
||||
async def async_validate(self) -> str | None:
|
||||
"""Return the album name if the token works, else raise."""
|
||||
import json as _json
|
||||
|
||||
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
|
||||
redirect_host = headers.get("X-Apple-MMe-Host")
|
||||
if redirect_host and redirect_host != self._host:
|
||||
self._host = redirect_host
|
||||
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
return payload.get("streamName") if isinstance(payload, dict) else None
|
||||
payload = await self._post(
|
||||
"/webstream", {"streamCtag": None}, operation="link validation"
|
||||
)
|
||||
return payload.get("streamName")
|
||||
|
||||
|
||||
# --- CloudKit backend helpers ---------------------------------------------
|
||||
|
||||
@@ -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.1"
|
||||
"version": "1.10.0"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
||||
SlideIntervalNumber(entry, store),
|
||||
RefreshHoursNumber(entry, store, coordinator),
|
||||
PairDividerWidthNumber(entry, store),
|
||||
PairMinGapPercentNumber(entry, store),
|
||||
NavigationBufferSizeNumber(entry, store),
|
||||
ImageCacheMbNumber(entry, store),
|
||||
]
|
||||
@@ -144,6 +145,37 @@ class PairDividerWidthNumber(_BaseNumber):
|
||||
return
|
||||
|
||||
|
||||
class PairMinGapPercentNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:arrow-expand-horizontal"
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 50
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_pair_min_gap_percent"
|
||||
self._attr_name = "Pair minimum gap (% of album)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.pair_min_gap_percent)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.pair_min_gap_percent = min(50, max(0, int(value)))
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
self.store.pair_min_gap_percent = min(50, max(0, int(float(old.state))))
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class NavigationBufferSizeNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:page-previous-outline"
|
||||
_attr_native_min_value = 0
|
||||
|
||||
@@ -12,6 +12,7 @@ from .const import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
DEFAULT_PAIR_DIVIDER_PX,
|
||||
DEFAULT_PAIR_DIVIDER_COLOR,
|
||||
DEFAULT_PAIR_MIN_GAP_PERCENT,
|
||||
DEFAULT_IMAGE_CACHE_MB,
|
||||
DEFAULT_NAVIGATION_BUFFER_SIZE,
|
||||
DEFAULT_MAX_RESOLUTION,
|
||||
@@ -33,6 +34,7 @@ class SlideshowStore:
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO
|
||||
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
|
||||
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
|
||||
pair_min_gap_percent: int = DEFAULT_PAIR_MIN_GAP_PERCENT
|
||||
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
|
||||
navigation_buffer_size: int = DEFAULT_NAVIGATION_BUFFER_SIZE
|
||||
max_resolution: str = DEFAULT_MAX_RESOLUTION
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"ente": {
|
||||
"title": "Ente Photos public album",
|
||||
"description": "Paste a public Ente album link, for example https://albums.ente.io/?t=TOKEN#KEY. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
|
||||
"description": "Paste a public Ente album link, for example {example_url}. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"ente_url": "Album share link",
|
||||
@@ -172,7 +172,7 @@
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
|
||||
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
|
||||
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
|
||||
"invalid_ente_url": "That does not look like an Ente public album link. It should look like https://albums.ente.io/?t=TOKEN#KEY, including the part after the #."
|
||||
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #."
|
||||
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"ente": {
|
||||
"title": "Ente Photos public album",
|
||||
"description": "Paste a public Ente album link, for example https://albums.ente.io/?t=TOKEN#KEY. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
|
||||
"description": "Paste a public Ente album link, for example {example_url}. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"ente_url": "Album share link",
|
||||
@@ -172,7 +172,7 @@
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
|
||||
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
|
||||
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
|
||||
"invalid_ente_url": "That does not look like an Ente public album link. It should look like https://albums.ente.io/?t=TOKEN#KEY, including the part after the #."
|
||||
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #."
|
||||
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.9.1";
|
||||
const VERSION = "1.10.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
@@ -969,6 +969,7 @@ const LIVE_FIELDS = [
|
||||
"slide_interval",
|
||||
"pair_divider_px",
|
||||
"pair_divider_color",
|
||||
"pair_min_gap_percent",
|
||||
];
|
||||
|
||||
const LIVE_SUFFIX = {
|
||||
@@ -980,6 +981,7 @@ const LIVE_SUFFIX = {
|
||||
slide_interval: "_interval",
|
||||
pair_divider_px: "_pair_divider_px",
|
||||
pair_divider_color: "_pair_divider_color",
|
||||
pair_min_gap_percent: "_pair_min_gap_percent",
|
||||
previous_button: "_previous_button",
|
||||
next_button: "_next_button",
|
||||
refresh_button: "_refresh_button",
|
||||
@@ -994,6 +996,7 @@ const LIVE_LABELS = {
|
||||
live_slide_interval: "Slide interval (seconds)",
|
||||
live_pair_divider_px: "Pair divider size (px)",
|
||||
live_pair_divider_color: "Pair divider color",
|
||||
live_pair_min_gap_percent: "Pair minimum gap (% of album)",
|
||||
};
|
||||
|
||||
function humanizeOption(value) {
|
||||
@@ -1189,6 +1192,19 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
if (s.pair_divider_color) {
|
||||
items.push({ name: "live_pair_divider_color", selector: { text: {} } });
|
||||
}
|
||||
if (s.pair_min_gap_percent) {
|
||||
items.push({
|
||||
name: "live_pair_min_gap_percent",
|
||||
selector: {
|
||||
number: this._liveNumberConfig(s.pair_min_gap_percent, {
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -1401,7 +1417,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
out[`live_${f}`] = e ? e.state : "";
|
||||
}
|
||||
}
|
||||
for (const f of ["slide_interval", "pair_divider_px"]) {
|
||||
for (const f of ["slide_interval", "pair_divider_px", "pair_min_gap_percent"]) {
|
||||
if (s[f]) {
|
||||
const e = st(s[f]);
|
||||
out[`live_${f}`] = e ? Number(e.state) : null;
|
||||
@@ -1594,7 +1610,11 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
entity_id: id,
|
||||
option: value,
|
||||
});
|
||||
} else if (field === "slide_interval" || field === "pair_divider_px") {
|
||||
} else if (
|
||||
field === "slide_interval" ||
|
||||
field === "pair_divider_px" ||
|
||||
field === "pair_min_gap_percent"
|
||||
) {
|
||||
hass.callService("number", "set_value", {
|
||||
entity_id: id,
|
||||
value: Number(value),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user