187 files
This commit is contained in:
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from aiohttp import ClientResponseError
|
||||
from PIL import Image
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
@@ -1581,12 +1582,26 @@ class AlbumSlideshowCamera(Camera):
|
||||
return dict(headers)
|
||||
return None
|
||||
|
||||
def _image_request_params(self, url: str) -> dict[str, str] | None:
|
||||
"""Session query params added at fetch time only (UGREEN ``ugk``)."""
|
||||
params = getattr(self.coordinator, "image_request_params", None)
|
||||
if params and isinstance(url, str) and url.startswith("http"):
|
||||
return dict(params)
|
||||
return None
|
||||
|
||||
async def _http_get(self, url: str) -> bytes | None:
|
||||
"""Fetch one remote image with validation and a hard timeout."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
session = async_get_clientsession(
|
||||
self.hass,
|
||||
verify_ssl=getattr(self.coordinator, "image_request_verify_ssl", True),
|
||||
)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
async with session.get(url, headers=self._image_request_headers(url)) as resp:
|
||||
async with session.get(
|
||||
url,
|
||||
headers=self._image_request_headers(url),
|
||||
params=self._image_request_params(url),
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
@@ -1636,6 +1651,12 @@ class AlbumSlideshowCamera(Camera):
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
except ClientResponseError as err:
|
||||
# str(err) includes the full request URL, fetch-time params and all.
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: failed to fetch image %s: HTTP %s", url, err.status
|
||||
)
|
||||
return None
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
|
||||
return None
|
||||
|
||||
@@ -99,6 +99,13 @@ from .const import (
|
||||
DEFAULT_ENTE_IMAGE_SIZE,
|
||||
ENTE_IMAGE_FULL,
|
||||
ENTE_IMAGE_PREVIEW,
|
||||
CONF_UGREEN_URL,
|
||||
CONF_UGREEN_USERNAME,
|
||||
CONF_UGREEN_PASSWORD,
|
||||
CONF_UGREEN_ALBUM_UUID,
|
||||
CONF_UGREEN_ALBUM_TYPE,
|
||||
CONF_UGREEN_ALBUM_NAME,
|
||||
CONF_UGREEN_VERIFY_SSL,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
@@ -109,6 +116,7 @@ from .const import (
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
PROVIDER_UGREEN,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -374,6 +382,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._syn_places: dict[str, str] = {}
|
||||
self._syn_tags: dict[str, str] = {}
|
||||
self._syn_subjects: dict[str, str] = {}
|
||||
# UGREEN flow state carried between steps.
|
||||
self._ugr_url: str | None = None
|
||||
self._ugr_username: str | None = None
|
||||
self._ugr_password: str | None = None
|
||||
self._ugr_verify_ssl: bool = True
|
||||
self._ugr_albums: list[dict[str, Any]] = []
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -382,8 +396,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> config_entries.OptionsFlow:
|
||||
"""Return the options flow handler.
|
||||
|
||||
Local-folder, Nextcloud and Ente entries expose the reverse-geocode
|
||||
toggle; Immich entries reopen the albums/people/favorites picker.
|
||||
Local-folder, Nextcloud, Ente and UGREEN entries expose the
|
||||
reverse-geocode toggle; Immich entries reopen the
|
||||
albums/people/favorites picker.
|
||||
Other providers get a no-op handler so that the "Configure" button
|
||||
doesn't appear empty in the UI.
|
||||
|
||||
@@ -397,6 +412,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
PROVIDER_UGREEN,
|
||||
):
|
||||
return LocalFolderOptionsFlow()
|
||||
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_IMMICH:
|
||||
@@ -424,6 +440,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_nextcloud()
|
||||
if self._provider == PROVIDER_ENTE:
|
||||
return await self.async_step_ente()
|
||||
if self._provider == PROVIDER_UGREEN:
|
||||
return await self.async_step_ugreen()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -437,6 +455,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
|
||||
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder or public album link)",
|
||||
PROVIDER_ENTE: "Ente Photos (public album link)",
|
||||
PROVIDER_UGREEN: "UGREEN NAS (UGOS Photos, experimental)",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -1116,6 +1135,123 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="synology_select", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_ugreen(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect the UGREEN NAS URL + account and log in.
|
||||
|
||||
The album is picked from a live dropdown in the next step rather
|
||||
than typed in, so logging in here (and listing albums) doubles as
|
||||
credential validation.
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_UGREEN_URL].strip()
|
||||
username = user_input[CONF_UGREEN_USERNAME].strip()
|
||||
password = user_input.get(CONF_UGREEN_PASSWORD) or ""
|
||||
verify_ssl = bool(user_input.get(CONF_UGREEN_VERIFY_SSL, True))
|
||||
|
||||
from . import ugreen as ugr_api
|
||||
|
||||
client = ugr_api.UGreenClient(
|
||||
self.hass, url, username, password, verify_ssl=verify_ssl
|
||||
)
|
||||
albums: list[dict[str, Any]] = []
|
||||
try:
|
||||
await client.async_login()
|
||||
albums = await client.async_list_albums()
|
||||
except ugr_api.UGreenAuthError as err:
|
||||
_LOGGER.warning(
|
||||
"UGREEN login failed for %s: %s", url, _describe_error(err)
|
||||
)
|
||||
errors["base"] = "ugreen_cannot_connect"
|
||||
except ugr_api.UGreenApiError as err:
|
||||
_LOGGER.warning(
|
||||
"UGREEN album listing failed for %s: %s", url, _describe_error(err)
|
||||
)
|
||||
errors["base"] = "ugreen_cannot_connect"
|
||||
|
||||
named_albums = [
|
||||
a for a in albums if isinstance(a, dict) and a.get("album_name")
|
||||
]
|
||||
if not errors and not named_albums:
|
||||
errors["base"] = "ugreen_no_albums"
|
||||
|
||||
if not errors:
|
||||
self._ugr_url = client.base_url
|
||||
self._ugr_username = username
|
||||
self._ugr_password = password
|
||||
self._ugr_verify_ssl = verify_ssl
|
||||
self._ugr_albums = named_albums
|
||||
return await self.async_step_ugreen_select()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_UGREEN_URL): str,
|
||||
vol.Required(CONF_UGREEN_USERNAME): str,
|
||||
vol.Required(CONF_UGREEN_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Optional(CONF_UGREEN_VERIFY_SSL, default=True): bool,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="ugreen", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_ugreen_select(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Pick the UGOS Photos album from a live, searchable dropdown."""
|
||||
from . import ugreen as ugr_api
|
||||
|
||||
# Keyed by uuid since album names aren't guaranteed unique.
|
||||
albums_by_uuid = {a["album_uuid"]: a for a in self._ugr_albums}
|
||||
|
||||
if user_input is not None:
|
||||
album_uuid = user_input[CONF_UGREEN_ALBUM_UUID]
|
||||
album = albums_by_uuid[album_uuid]
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_UGREEN}:{self._ugr_url}:"
|
||||
f"{self._ugr_username}:{album_uuid}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
data = {
|
||||
CONF_PROVIDER: PROVIDER_UGREEN,
|
||||
CONF_UGREEN_URL: self._ugr_url,
|
||||
CONF_UGREEN_USERNAME: self._ugr_username,
|
||||
CONF_UGREEN_PASSWORD: self._ugr_password,
|
||||
CONF_UGREEN_VERIFY_SSL: self._ugr_verify_ssl,
|
||||
CONF_UGREEN_ALBUM_UUID: album_uuid,
|
||||
CONF_UGREEN_ALBUM_TYPE: album.get(
|
||||
"album_type", ugr_api.ALBUM_TYPE_REGULAR
|
||||
),
|
||||
CONF_UGREEN_ALBUM_NAME: album["album_name"],
|
||||
}
|
||||
return self.async_create_entry(title=album["album_name"], data=data)
|
||||
|
||||
# A searchable dropdown (rather than a plain vol.In) keeps this
|
||||
# usable for accounts with a large number of albums.
|
||||
options = [
|
||||
selector.SelectOptionDict(
|
||||
value=a["album_uuid"],
|
||||
label=f"{a['album_name']} ({ugr_api.describe_album_type(a.get('album_type'))})",
|
||||
)
|
||||
for a in self._ugr_albums
|
||||
]
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_UGREEN_ALBUM_UUID): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=options,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
custom_value=False,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="ugreen_select", data_schema=schema)
|
||||
|
||||
async def async_step_nextcloud(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
|
||||
@@ -75,6 +75,7 @@ PROVIDER_ICLOUD = "icloud"
|
||||
PROVIDER_SYNOLOGY = "synology"
|
||||
PROVIDER_NEXTCLOUD = "nextcloud"
|
||||
PROVIDER_ENTE = "ente"
|
||||
PROVIDER_UGREEN = "ugreen"
|
||||
|
||||
# Providers whose coordinator runs a background enrichment pass: per-photo
|
||||
# metadata reads, reverse-geocoding, or both. These are the entries that get
|
||||
@@ -85,6 +86,7 @@ ENRICHING_PROVIDERS = (
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
PROVIDER_UGREEN,
|
||||
)
|
||||
|
||||
# Nextcloud provider - two auth modes against the same PROVIDER_NEXTCLOUD id:
|
||||
@@ -239,6 +241,23 @@ DEFAULT_PHOTOPRISM_IMAGE_SIZE = PHOTOPRISM_IMAGE_PREVIEW
|
||||
PHOTOPRISM_SELECTION_COMPOSITE = "composite"
|
||||
|
||||
|
||||
# UGREEN NAS (UGOS Photos) provider. Talks to the undocumented UGOS Photos
|
||||
# web API (see ``ugreen.py``); login is username + password, with the
|
||||
# password RSA-encrypted before it ever leaves HA, same as the UGOS web app.
|
||||
# TLS certificates are verified unless the user turns that off for a NAS with
|
||||
# a self-signed certificate.
|
||||
CONF_UGREEN_URL = "ugreen_url"
|
||||
CONF_UGREEN_USERNAME = "ugreen_username"
|
||||
CONF_UGREEN_PASSWORD = "ugreen_password"
|
||||
CONF_UGREEN_VERIFY_SSL = "ugreen_verify_ssl"
|
||||
# The album is stored by ``album_uuid`` + ``album_type`` (what the API needs),
|
||||
# so renaming it in UGOS Photos does not break the slideshow. The name is kept
|
||||
# for display only.
|
||||
CONF_UGREEN_ALBUM_UUID = "ugreen_album_uuid"
|
||||
CONF_UGREEN_ALBUM_TYPE = "ugreen_album_type"
|
||||
CONF_UGREEN_ALBUM_NAME = "ugreen_album_name"
|
||||
|
||||
|
||||
FILL_COVER = "cover"
|
||||
FILL_CONTAIN = "contain"
|
||||
FILL_BLUR = "blur"
|
||||
|
||||
@@ -89,6 +89,12 @@ from .const import (
|
||||
CONF_ENTE_IMAGE_SIZE,
|
||||
DEFAULT_ENTE_IMAGE_SIZE,
|
||||
ENTE_IMAGE_PREVIEW,
|
||||
CONF_UGREEN_URL,
|
||||
CONF_UGREEN_USERNAME,
|
||||
CONF_UGREEN_PASSWORD,
|
||||
CONF_UGREEN_ALBUM_UUID,
|
||||
CONF_UGREEN_ALBUM_TYPE,
|
||||
CONF_UGREEN_VERIFY_SSL,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
ENRICHING_PROVIDERS,
|
||||
@@ -101,6 +107,7 @@ from .const import (
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
PROVIDER_UGREEN,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -1094,9 +1101,20 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
# Extra headers the camera must send when fetching image bytes
|
||||
# (Immich API key). Empty for providers that need no auth.
|
||||
self.image_request_headers: dict[str, str] = {}
|
||||
# Query params added only at fetch time (UGREEN ``ugk``), so stored
|
||||
# URLs, attributes and logs never carry them.
|
||||
self.image_request_params: dict[str, str] = {}
|
||||
# False when the entry opted out of TLS certificate checks (UGREEN NAS
|
||||
# with a self-signed certificate).
|
||||
self.image_request_verify_ssl: bool = True
|
||||
# Ente only: file id -> {key, header, thumbnail}, the material needed
|
||||
# to decrypt each image. Rebuilt on every album refresh.
|
||||
self._ente_fetch_meta: dict[str, dict[str, Any]] = {}
|
||||
# UGREEN only: logged-in client + selected album, reused by the
|
||||
# background enrichment pass instead of logging in again per photo.
|
||||
self._ugreen_client: Any = None
|
||||
self._ugreen_album_uuid: str | None = None
|
||||
self._ugreen_album_type: int = 1
|
||||
|
||||
# Persist the most recent successful album fetch so that a transient
|
||||
# network/Google failure doesn't blank the slideshow on restart.
|
||||
@@ -1172,6 +1190,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_nextcloud()
|
||||
elif self.provider == PROVIDER_ENTE:
|
||||
data = await self._update_ente()
|
||||
elif self.provider == PROVIDER_UGREEN:
|
||||
data = await self._update_ugreen()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -1993,6 +2013,102 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_ugreen(self) -> dict[str, Any]:
|
||||
"""Fetch photos from a UGREEN NAS UGOS Photos album.
|
||||
|
||||
Logs in fresh on every refresh, like the Synology provider. The
|
||||
album is addressed by its stored uuid, so renaming it in UGOS Photos
|
||||
needs no reconfiguration.
|
||||
"""
|
||||
from . import ugreen as ugr_api
|
||||
|
||||
url = self.entry.data.get(CONF_UGREEN_URL)
|
||||
username = self.entry.data.get(CONF_UGREEN_USERNAME)
|
||||
password = self.entry.data.get(CONF_UGREEN_PASSWORD)
|
||||
album_uuid = self.entry.data.get(CONF_UGREEN_ALBUM_UUID)
|
||||
album_type = self.entry.data.get(
|
||||
CONF_UGREEN_ALBUM_TYPE, ugr_api.ALBUM_TYPE_REGULAR
|
||||
)
|
||||
verify_ssl = bool(self.entry.data.get(CONF_UGREEN_VERIFY_SSL, True))
|
||||
if not url or not username or not password or not album_uuid:
|
||||
raise UpdateFailed("UGREEN provider is missing URL, credentials or album")
|
||||
|
||||
client = ugr_api.UGreenClient(
|
||||
self.hass, url, username, password, verify_ssl=verify_ssl
|
||||
)
|
||||
try:
|
||||
await client.async_login()
|
||||
photos = await client.async_list_album_pictures(album_uuid, album_type)
|
||||
except ugr_api.UGreenAuthError as err:
|
||||
raise UpdateFailed(f"UGREEN login failed: {err}") from err
|
||||
except ugr_api.UGreenApiError as err:
|
||||
raise UpdateFailed(f"Error listing UGREEN album photos: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed(f"No images found in UGREEN album '{self.entry.title}'")
|
||||
|
||||
# Stored so the camera can fetch image bytes server-side (session
|
||||
# cookie plus ``ugk``, added per request so stored URLs never carry it)
|
||||
# with the same TLS setting as the login.
|
||||
self.image_request_headers = dict(client.image_headers)
|
||||
self.image_request_params = dict(client.image_params)
|
||||
self.image_request_verify_ssl = verify_ssl
|
||||
# Kept for the background enrichment pass (GPS/address lookups),
|
||||
# which needs a logged-in client and the album context per photo.
|
||||
self._ugreen_client = client
|
||||
self._ugreen_album_uuid = album_uuid
|
||||
self._ugreen_album_type = album_type
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
picture_id = p.get("picture_id")
|
||||
if picture_id is None:
|
||||
continue
|
||||
# Backstop for type_option; a video would only show its poster frame.
|
||||
ext = p.get("real_ext_name") or Path(str(p.get("file_name") or "")).suffix
|
||||
if f".{str(ext).lstrip('.').lower()}" in _VIDEO_EXTS:
|
||||
continue
|
||||
meta = ugr_api.parse_photo_meta(p)
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=ugr_api.build_image_url(
|
||||
client.base_url,
|
||||
picture_id,
|
||||
album_uuid,
|
||||
source_album_type=album_type,
|
||||
upload_time=p.get("upload_time", 0),
|
||||
),
|
||||
width=meta.get("width"),
|
||||
height=meta.get("height"),
|
||||
mime_type=None,
|
||||
filename=p.get("file_name"),
|
||||
captured_at=meta.get("captured_at"),
|
||||
byte_size=meta.get("byte_size"),
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
location=None,
|
||||
description=None,
|
||||
source_id=str(picture_id),
|
||||
# GPS/address isn't in this listing - the background
|
||||
# enrichment pass fills it in via picture/info.
|
||||
exif_scanned=False,
|
||||
)
|
||||
)
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("Could not resolve any UGREEN images")
|
||||
|
||||
try:
|
||||
album_name = await client.async_get_album_name(album_uuid)
|
||||
except Exception as err: # noqa: BLE001 - the title is cosmetic
|
||||
_LOGGER.debug("UGREEN: could not read the album name: %s", err)
|
||||
album_name = None
|
||||
|
||||
return {
|
||||
"title": album_name or self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_nextcloud(self) -> dict[str, Any]:
|
||||
"""List photos from Nextcloud, dispatching on the configured auth mode."""
|
||||
mode = self.entry.data.get(
|
||||
@@ -2502,6 +2618,40 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
if completed:
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
async def _enrich_ugreen_item(self, item: MediaItem) -> None:
|
||||
"""Fetch GPS coordinates and a location label for one UGREEN photo.
|
||||
|
||||
Reuses the client stashed by ``_update_ugreen`` instead of logging in
|
||||
again per photo. Image URLs are stable across refreshes, so
|
||||
``_merge_prior_enrichment`` carries results forward and only new
|
||||
photos are looked up.
|
||||
"""
|
||||
from . import ugreen as ugr_api
|
||||
|
||||
client = self._ugreen_client
|
||||
if client is None or not item.source_id or not self._ugreen_album_uuid:
|
||||
item.exif_scanned = True
|
||||
return
|
||||
try:
|
||||
info = await client.async_get_picture_info(
|
||||
item.source_id, self._ugreen_album_uuid, self._ugreen_album_type
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except ugr_api.UGreenApiError as err:
|
||||
_LOGGER.debug(
|
||||
"UGREEN: picture/info failed for %s: %s", item.source_id, err
|
||||
)
|
||||
item.exif_scanned = True
|
||||
return
|
||||
meta = ugr_api.parse_picture_location(info)
|
||||
if "latitude" in meta and "longitude" in meta:
|
||||
item.latitude = meta["latitude"]
|
||||
item.longitude = meta["longitude"]
|
||||
if "location" in meta:
|
||||
item.location = meta["location"]
|
||||
item.exif_scanned = True
|
||||
|
||||
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
|
||||
"""Read EXIF for unscanned local files, then reverse-geocode.
|
||||
|
||||
@@ -2564,6 +2714,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
self.async_set_updated_data(data)
|
||||
continue
|
||||
|
||||
if self.provider == PROVIDER_UGREEN:
|
||||
try:
|
||||
await self._enrich_ugreen_item(item)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("UGREEN enrich error: %s", err)
|
||||
item.exif_scanned = True
|
||||
scanned_since_save += 1
|
||||
self._enrich_progress["exif_done"] = (
|
||||
self._enrich_progress.get("exif_done", 0) + 1
|
||||
)
|
||||
if scanned_since_save >= _EXIF_BATCH_SAVE:
|
||||
scanned_since_save = 0
|
||||
await self._save_cached_items(data)
|
||||
self.async_set_updated_data(data)
|
||||
continue
|
||||
|
||||
url = item.url
|
||||
if not url.startswith("file://"):
|
||||
item.exif_scanned = True
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["PyNaCl>=1.5.0"],
|
||||
"version": "1.13.0"
|
||||
"version": "1.14.0"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,13 @@ class _BaseAlbumSensor(SensorEntity):
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
self.entry = entry
|
||||
self.coordinator = coordinator
|
||||
coordinator.async_add_listener(self.async_write_ha_state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
# Disabled entities are constructed but never added, so subscribe here.
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||
)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
@@ -80,7 +86,10 @@ class HiddenPhotoCountSensor(_BaseAlbumSensor):
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_hidden_photo_count"
|
||||
self._attr_name = "Hidden photos"
|
||||
coordinator.store.add_listener(self.async_write_ha_state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.coordinator.store.add_listener(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
|
||||
@@ -110,6 +110,23 @@
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"ugreen": {
|
||||
"title": "UGREEN NAS (UGOS Photos)",
|
||||
"description": "Experimental: connect to the Photos app on a UGREEN NAS (UGOS Pro). Enter the NAS address including port (e.g. https://192.168.1.10:9443) and an account. This provider talks to an undocumented, reverse-engineered API and may break with future UGOS updates.",
|
||||
"data": {
|
||||
"ugreen_url": "NAS URL",
|
||||
"ugreen_username": "Username",
|
||||
"ugreen_password": "Password",
|
||||
"ugreen_verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
},
|
||||
"ugreen_select": {
|
||||
"title": "UGREEN album",
|
||||
"description": "Pick the UGOS Photos album to show.",
|
||||
"data": {
|
||||
"ugreen_album_uuid": "Album"
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud",
|
||||
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
|
||||
@@ -173,7 +190,9 @@
|
||||
"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 {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."
|
||||
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected.",
|
||||
"ugreen_cannot_connect": "Could not connect to the UGREEN NAS. Check the URL, username and password. If the NAS uses a self-signed certificate, turn off Verify SSL certificate.",
|
||||
"ugreen_no_albums": "No albums were found on this UGREEN NAS."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -29,11 +29,6 @@ class PairDividerColorText(TextEntity, RestoreEntity):
|
||||
self._attr_unique_id = f"{entry.entry_id}_pair_divider_color"
|
||||
self._attr_name = "Pair divider color"
|
||||
|
||||
def _on_store_change() -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
return self.store.pair_divider_color
|
||||
|
||||
@@ -110,6 +110,23 @@
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"ugreen": {
|
||||
"title": "UGREEN NAS (UGOS Photos)",
|
||||
"description": "Experimental: connect to the Photos app on a UGREEN NAS (UGOS Pro). Enter the NAS address including port (e.g. https://192.168.1.10:9443) and an account. This provider talks to an undocumented, reverse-engineered API and may break with future UGOS updates.",
|
||||
"data": {
|
||||
"ugreen_url": "NAS URL",
|
||||
"ugreen_username": "Username",
|
||||
"ugreen_password": "Password",
|
||||
"ugreen_verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
},
|
||||
"ugreen_select": {
|
||||
"title": "UGREEN album",
|
||||
"description": "Pick the UGOS Photos album to show.",
|
||||
"data": {
|
||||
"ugreen_album_uuid": "Album"
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud",
|
||||
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
|
||||
@@ -173,7 +190,9 @@
|
||||
"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 {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."
|
||||
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected.",
|
||||
"ugreen_cannot_connect": "Could not connect to the UGREEN NAS. Check the URL, username and password. If the NAS uses a self-signed certificate, turn off Verify SSL certificate.",
|
||||
"ugreen_no_albums": "No albums were found on this UGREEN NAS."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.13.0";
|
||||
const VERSION = "1.14.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
Reference in New Issue
Block a user