App Updates
This commit is contained in:
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.
@@ -48,12 +48,19 @@ from .const import (
|
||||
PHOTOPRISM_AUTH_APP_PASSWORD,
|
||||
PHOTOPRISM_AUTH_USER_PASSWORD,
|
||||
PHOTOPRISM_SELECTION_COMPOSITE,
|
||||
CONF_ICLOUD_URL,
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
ICLOUD_IMAGE_FULL,
|
||||
ICLOUD_IMAGE_PREVIEW,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_MEDIA_SOURCE,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -134,6 +141,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_immich()
|
||||
if self._provider == PROVIDER_PHOTOPRISM:
|
||||
return await self.async_step_photoprism()
|
||||
if self._provider == PROVIDER_ICLOUD:
|
||||
return await self.async_step_icloud()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -143,6 +152,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_LOCAL_FOLDER: "Local Folder",
|
||||
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
|
||||
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
||||
PROVIDER_ICLOUD: "iCloud Shared Album",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -586,6 +596,61 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="photoprism_select", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_icloud(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect and validate an iCloud Shared Album link."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
raw_url = user_input[CONF_ICLOUD_URL].strip()
|
||||
size = user_input.get(CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE)
|
||||
|
||||
from . import icloud as icloud_api
|
||||
|
||||
token = icloud_api.parse_share_link(raw_url)
|
||||
if not token:
|
||||
errors[CONF_ICLOUD_URL] = "invalid_icloud_url"
|
||||
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}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_ICLOUD,
|
||||
CONF_ICLOUD_TOKEN: token,
|
||||
CONF_ICLOUD_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_ICLOUD_URL): str,
|
||||
vol.Optional(
|
||||
CONF_ICLOUD_IMAGE_SIZE, default=DEFAULT_ICLOUD_IMAGE_SIZE
|
||||
): vol.In(
|
||||
{
|
||||
ICLOUD_IMAGE_FULL: "Full size (best for slideshow)",
|
||||
ICLOUD_IMAGE_PREVIEW: "Preview (thumbnail, fastest)",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="icloud", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Options for local-folder entries.
|
||||
|
||||
@@ -56,6 +56,21 @@ PROVIDER_LOCAL_FOLDER = "local_folder"
|
||||
PROVIDER_MEDIA_SOURCE = "media_source"
|
||||
PROVIDER_IMMICH = "immich"
|
||||
PROVIDER_PHOTOPRISM = "photoprism"
|
||||
PROVIDER_ICLOUD = "icloud"
|
||||
|
||||
# iCloud Shared Album provider. The share token in the pasted link is the only
|
||||
# credential; no account or password is involved.
|
||||
CONF_ICLOUD_URL = "icloud_url"
|
||||
CONF_ICLOUD_TOKEN = "icloud_token"
|
||||
CONF_ICLOUD_IMAGE_SIZE = "icloud_image_size"
|
||||
|
||||
# ``full`` picks the largest derivative Apple generated (best for a slideshow,
|
||||
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
|
||||
ICLOUD_IMAGE_FULL = "full"
|
||||
ICLOUD_IMAGE_PREVIEW = "preview"
|
||||
ICLOUD_IMAGE_SIZE_OPTIONS = [ICLOUD_IMAGE_FULL, ICLOUD_IMAGE_PREVIEW]
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE = ICLOUD_IMAGE_FULL
|
||||
|
||||
|
||||
# PhotoPrism (direct API) provider.
|
||||
CONF_PHOTOPRISM_URL = "photoprism_url"
|
||||
|
||||
@@ -41,6 +41,9 @@ from .const import (
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE,
|
||||
CONF_PHOTOPRISM_FILTER,
|
||||
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
@@ -48,6 +51,7 @@ from .const import (
|
||||
PROVIDER_MEDIA_SOURCE,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -933,6 +937,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_immich()
|
||||
elif self.provider == PROVIDER_PHOTOPRISM:
|
||||
data = await self._update_photoprism()
|
||||
elif self.provider == PROVIDER_ICLOUD:
|
||||
data = await self._update_icloud()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -1445,6 +1451,67 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
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).
|
||||
"""
|
||||
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)
|
||||
if not token:
|
||||
raise UpdateFailed("iCloud provider is missing the album token")
|
||||
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
try:
|
||||
photos = await client.async_get_photos()
|
||||
asset_urls = await client.async_get_asset_urls(
|
||||
[p["photoGuid"] for p in photos if p.get("photoGuid")]
|
||||
)
|
||||
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] = []
|
||||
for p in photos:
|
||||
guid = p.get("photoGuid")
|
||||
checksum = icloud_api.pick_checksum(p, size)
|
||||
if not guid or not checksum:
|
||||
continue
|
||||
url = icloud_api.build_image_url(asset_urls.get(checksum))
|
||||
if not url:
|
||||
continue
|
||||
meta = icloud_api.parse_photo_meta(p)
|
||||
w = p.get("width")
|
||||
h = p.get("height")
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=url,
|
||||
width=int(w) if str(w).isdigit() else None,
|
||||
height=int(h) if str(h).isdigit() else None,
|
||||
mime_type=None,
|
||||
filename=None,
|
||||
captured_at=meta.get("captured_at"),
|
||||
description=meta.get("description"),
|
||||
source_id=guid,
|
||||
exif_scanned=True,
|
||||
)
|
||||
)
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("Could not resolve any iCloud image URLs")
|
||||
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _enrich_immich_item(self, item: MediaItem) -> None:
|
||||
"""Fetch one Immich asset's detail and fill location/description."""
|
||||
from . import immich as immich_api
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""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.
|
||||
|
||||
API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com``):
|
||||
- ``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.
|
||||
- ``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
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_TIMEOUT = 30
|
||||
_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.
|
||||
_URL_BATCH = 25
|
||||
|
||||
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
# Headers Apple's web endpoints expect for the shared-streams API.
|
||||
_API_HEADERS = {
|
||||
"Content-Type": "text/plain",
|
||||
"Origin": "https://www.icloud.com",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _base62_to_int(value: str) -> int:
|
||||
result = 0
|
||||
for ch in value:
|
||||
result = result * 62 + _BASE62.index(ch)
|
||||
return result
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
text = url.strip()
|
||||
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, ...).
|
||||
token = text.strip()
|
||||
if token and all(ch in _BASE62 for ch in token):
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def partition_host(token: str) -> str:
|
||||
"""Derive the shared-streams partition host for a token.
|
||||
|
||||
Apple encodes the server partition in the first characters of the token:
|
||||
one char after a leading ``A``, otherwise the first two chars.
|
||||
"""
|
||||
if not token:
|
||||
return ""
|
||||
if token[0] == "A":
|
||||
partition = _base62_to_int(token[1:2])
|
||||
else:
|
||||
partition = _base62_to_int(token[1:3])
|
||||
return f"p{partition:02d}-sharedstreams.icloud.com"
|
||||
|
||||
|
||||
def base_url(token: str, host: str | None = None) -> str:
|
||||
"""Return the ``/sharedstreams/`` base URL for a token (and optional host)."""
|
||||
host = host or partition_host(token)
|
||||
return f"https://{host}/{token}/sharedstreams"
|
||||
|
||||
|
||||
def _to_epoch_ms(value: Any) -> int | None:
|
||||
"""Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
iso = value.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(iso)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
return int(dt.timestamp() * 1000)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_webstream(payload: Any) -> list[dict[str, Any]]:
|
||||
"""Return the list of photo dicts from a ``webstream`` response."""
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
photos = payload.get("photos")
|
||||
if not isinstance(photos, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in photos:
|
||||
if isinstance(p, dict) and p.get("photoGuid") and isinstance(
|
||||
p.get("derivatives"), dict
|
||||
) and p["derivatives"]:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def pick_checksum(photo: dict[str, Any], size: str) -> str | None:
|
||||
"""Pick the derivative checksum for the requested display ``size``.
|
||||
|
||||
Derivatives are keyed by their long edge (e.g. ``"342"``, ``"2049"``).
|
||||
``full`` selects the largest available (best for a slideshow); ``preview``
|
||||
selects the smallest (fastest / least bandwidth).
|
||||
"""
|
||||
derivatives = photo.get("derivatives")
|
||||
if not isinstance(derivatives, dict) or not derivatives:
|
||||
return None
|
||||
|
||||
def edge(item: tuple[str, Any]) -> int:
|
||||
key, val = item
|
||||
try:
|
||||
return int(key)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if isinstance(val, dict):
|
||||
try:
|
||||
return int(val.get("fileSize") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return 0
|
||||
|
||||
entries = list(derivatives.items())
|
||||
chosen = min(entries, key=edge) if size == "preview" else max(entries, key=edge)
|
||||
val = chosen[1]
|
||||
return val.get("checksum") if isinstance(val, dict) else None
|
||||
|
||||
|
||||
def build_image_url(item: dict[str, Any]) -> str | None:
|
||||
"""Build a fetchable image URL from a ``webasseturls`` item entry."""
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
location = item.get("url_location")
|
||||
path = item.get("url_path")
|
||||
if not location or not path:
|
||||
return None
|
||||
scheme = item.get("scheme") or "https"
|
||||
return f"{scheme}://{location}{path}"
|
||||
|
||||
|
||||
def parse_photo_meta(photo: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract the metadata we surface from a webstream photo item."""
|
||||
out: dict[str, Any] = {}
|
||||
captured = _to_epoch_ms(photo.get("dateCreated")) or _to_epoch_ms(
|
||||
photo.get("batchDateCreated")
|
||||
)
|
||||
if captured is not None:
|
||||
out["captured_at"] = captured
|
||||
caption = photo.get("caption")
|
||||
if isinstance(caption, str) and caption.strip():
|
||||
out["description"] = caption.strip()
|
||||
return out
|
||||
|
||||
|
||||
class IcloudClient:
|
||||
"""Thin async wrapper over the iCloud shared-streams web API."""
|
||||
|
||||
def __init__(self, hass, token: str) -> None:
|
||||
self.hass = hass
|
||||
self.token = token
|
||||
# Resolved after the first webstream call (Apple may redirect us to a
|
||||
# different partition host via X-Apple-MMe-Host).
|
||||
self._host: str | None = None
|
||||
|
||||
@property
|
||||
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]:
|
||||
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()
|
||||
|
||||
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)
|
||||
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]] = {}
|
||||
for start in range(0, len(guids), _URL_BATCH):
|
||||
chunk = guids[start : start + _URL_BATCH]
|
||||
status, _headers, raw = await self._post(
|
||||
"/webasseturls", {"photoGuids": chunk}
|
||||
)
|
||||
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
|
||||
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
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.3.0"
|
||||
"version": "1.4.0"
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@
|
||||
"photoprism_filter": "Extra search query (optional)",
|
||||
"photoprism_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"icloud_url": "Shared album link",
|
||||
"icloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -89,7 +98,9 @@
|
||||
"immich_albums_required": "Pick at least one album for the Albums source.",
|
||||
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
|
||||
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password."
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -75,6 +75,15 @@
|
||||
"photoprism_filter": "Extra search query (optional)",
|
||||
"photoprism_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"icloud_url": "Shared album link",
|
||||
"icloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -89,7 +98,9 @@
|
||||
"immich_albums_required": "Pick at least one album for the Albums source.",
|
||||
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
|
||||
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password."
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.3.0";
|
||||
const VERSION = "1.4.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
Reference in New Issue
Block a user