Comprehensive review fixes: security, performance, code quality, and UI polish
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
Backend: Fix CORS wildcard+credentials, add secret key warning, remove raw API keys from sync endpoint, fix N+1 queries in watcher/sync, fix AttributeError on event_types, delete dead scheduled.py/templates.py, add limit cap on history, re-validate server on URL/key update, apply tracking/template config IDs in update_target. HA Integration: Replace datetime.now() with dt_util.now(), fix notification queue to only remove successfully sent items, use album UUID for entity unique IDs, add shared links dirty flag and users cache hourly refresh, deduplicate _is_quiet_hours, add HTTP timeouts, cache albums in config flow, change iot_class to local_polling. Frontend: Make i18n reactive via $state (remove window.location.reload), add Modal transitions/a11y/Escape key, create ConfirmModal replacing all confirm() calls, add error handling to all pages, replace Unicode nav icons with MDI SVGs, add card hover effects, dashboard stat icons, global focus-visible styles, form slide transitions, mobile responsive bottom nav, fix password error color, add ~20 i18n keys (EN/RU). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -354,6 +354,7 @@ async def _send_queued_items(
|
||||
|
||||
items = queue.get_all()
|
||||
sent_count = 0
|
||||
sent_indices = []
|
||||
for i in indices:
|
||||
if i >= len(items):
|
||||
continue
|
||||
@@ -371,14 +372,16 @@ async def _send_queued_items(
|
||||
blocking=True,
|
||||
)
|
||||
sent_count += 1
|
||||
sent_indices.append(i)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to send queued notification %d", i + 1)
|
||||
|
||||
# Small delay between notifications to avoid rate limiting
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Remove sent items from queue (in reverse order to preserve indices)
|
||||
await queue.async_remove_indices(sorted(indices, reverse=True))
|
||||
# Only remove successfully sent items (in reverse order to preserve indices)
|
||||
if sent_indices:
|
||||
await queue.async_remove_indices(sorted(sent_indices, reverse=True))
|
||||
_LOGGER.info("Sent %d/%d queued notifications", sent_count, len(indices))
|
||||
|
||||
|
||||
@@ -419,7 +422,7 @@ async def _async_update_listener(
|
||||
subentries_data = entry_data["subentries"]
|
||||
for subentry_data in subentries_data.values():
|
||||
subentry_data.coordinator.update_scan_interval(new_interval)
|
||||
subentry_data.coordinator._sync_client = sync_client
|
||||
subentry_data.coordinator.update_sync_client(sync_client)
|
||||
|
||||
_LOGGER.info("Updated hub options (scan_interval=%d)", new_interval)
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -74,7 +76,7 @@ class ImmichAlbumNewAssetsSensor(
|
||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_new_assets"
|
||||
|
||||
@property
|
||||
@@ -93,7 +95,7 @@ class ImmichAlbumNewAssetsSensor(
|
||||
|
||||
# Check if we're still within the reset window
|
||||
if self._album_data.last_change_time:
|
||||
elapsed = datetime.now() - self._album_data.last_change_time
|
||||
elapsed = dt_util.now() - self._album_data.last_change_time
|
||||
if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
|
||||
# Auto-reset the flag
|
||||
self.coordinator.clear_new_assets_flag()
|
||||
|
||||
@@ -75,7 +75,7 @@ class ImmichCreateShareLinkButton(
|
||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_create_share_link"
|
||||
|
||||
@property
|
||||
@@ -158,7 +158,7 @@ class ImmichDeleteShareLinkButton(
|
||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_delete_share_link"
|
||||
|
||||
@property
|
||||
@@ -248,7 +248,7 @@ class ImmichCreateProtectedLinkButton(
|
||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_create_protected_link"
|
||||
|
||||
@property
|
||||
@@ -335,7 +335,7 @@ class ImmichDeleteProtectedLinkButton(
|
||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link"
|
||||
|
||||
@property
|
||||
|
||||
@@ -22,7 +22,7 @@ from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
_THUMBNAIL_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -68,7 +68,7 @@ class ImmichAlbumThumbnailCamera(
|
||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_thumbnail"
|
||||
self._cached_image: bytes | None = None
|
||||
self._last_thumbnail_id: str | None = None
|
||||
@@ -131,7 +131,7 @@ class ImmichAlbumThumbnailCamera(
|
||||
)
|
||||
|
||||
try:
|
||||
async with session.get(thumbnail_url, headers=headers) as response:
|
||||
async with session.get(thumbnail_url, headers=headers, timeout=_THUMBNAIL_TIMEOUT) as response:
|
||||
if response.status == 200:
|
||||
self._cached_image = await response.read()
|
||||
self._last_thumbnail_id = self._album_data.thumbnail_asset_id
|
||||
|
||||
@@ -39,13 +39,16 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CONNECT_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||
|
||||
|
||||
async def validate_connection(
|
||||
session: aiohttp.ClientSession, url: str, api_key: str
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the Immich connection and return server info."""
|
||||
headers = {"x-api-key": api_key}
|
||||
async with session.get(
|
||||
f"{url.rstrip('/')}/api/server/ping", headers=headers
|
||||
f"{url.rstrip('/')}/api/server/ping", headers=headers, timeout=_CONNECT_TIMEOUT
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
raise InvalidAuth
|
||||
@@ -169,23 +172,7 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
|
||||
url = config_entry.data[CONF_IMMICH_URL]
|
||||
api_key = config_entry.data[CONF_API_KEY]
|
||||
|
||||
# Fetch available albums
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
self._albums = await fetch_albums(session, url, api_key)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to fetch albums")
|
||||
errors["base"] = "cannot_connect"
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
if not self._albums:
|
||||
return self.async_abort(reason="no_albums")
|
||||
|
||||
if user_input is not None:
|
||||
if user_input is not None and self._albums:
|
||||
album_id = user_input[CONF_ALBUM_ID]
|
||||
|
||||
# Check if album is already configured
|
||||
@@ -208,6 +195,23 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
|
||||
},
|
||||
)
|
||||
|
||||
# Fetch available albums (only when displaying the form)
|
||||
if not self._albums:
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
self._albums = await fetch_albums(session, url, api_key)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to fetch albums")
|
||||
errors["base"] = "cannot_connect"
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
if not self._albums:
|
||||
return self.async_abort(reason="no_albums")
|
||||
|
||||
# Get already configured album IDs
|
||||
configured_albums = set()
|
||||
for subentry in config_entry.subentries.values():
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .storage import ImmichAlbumStorage
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -122,7 +121,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
|
||||
# Caches managed by the client
|
||||
self._users_cache: dict[str, str] = {}
|
||||
self._users_cache_time: float = 0
|
||||
self._shared_links: list[SharedLinkInfo] = []
|
||||
self._shared_links_dirty = True
|
||||
self._server_config_fetched = False
|
||||
|
||||
def _get_client(self) -> ImmichClient:
|
||||
@@ -181,6 +182,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
"""Update the scan interval."""
|
||||
self.update_interval = timedelta(seconds=scan_interval)
|
||||
|
||||
def update_sync_client(self, sync_client: Any | None) -> None:
|
||||
"""Update the server sync client."""
|
||||
self._sync_client = sync_client
|
||||
|
||||
async def async_refresh_now(self) -> None:
|
||||
"""Force an immediate refresh."""
|
||||
await self.async_request_refresh()
|
||||
@@ -321,6 +326,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
result = await client.create_shared_link(self._album_id, password)
|
||||
if result:
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
self._shared_links_dirty = False
|
||||
return result
|
||||
|
||||
async def async_delete_shared_link(self, link_id: str) -> bool:
|
||||
@@ -329,6 +335,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
result = await client.delete_shared_link(link_id)
|
||||
if result:
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
self._shared_links_dirty = False
|
||||
return result
|
||||
|
||||
async def async_set_shared_link_password(
|
||||
@@ -339,6 +346,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
result = await client.set_shared_link_password(link_id, password)
|
||||
if result:
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
self._shared_links_dirty = False
|
||||
return result
|
||||
|
||||
def clear_new_assets_flag(self) -> None:
|
||||
@@ -358,12 +366,16 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
await client.get_server_config()
|
||||
self._server_config_fetched = True
|
||||
|
||||
# Fetch users to resolve owner names
|
||||
if not self._users_cache:
|
||||
# Fetch users to resolve owner names (refresh every hour)
|
||||
import time
|
||||
if not self._users_cache or (time.monotonic() - self._users_cache_time > 3600):
|
||||
self._users_cache = await client.get_users()
|
||||
self._users_cache_time = time.monotonic()
|
||||
|
||||
# Fetch shared links (refresh each time as links can change)
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
# Fetch shared links only when needed (on first load or after mutations)
|
||||
if self._shared_links_dirty:
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
self._shared_links_dirty = False
|
||||
|
||||
try:
|
||||
album = await client.get_album(self._album_id, self._users_cache)
|
||||
@@ -389,7 +401,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
)
|
||||
if change:
|
||||
album.has_new_assets = change.added_count > 0
|
||||
album.last_change_time = datetime.now()
|
||||
album.last_change_time = dt_util.now()
|
||||
self._fire_events(change, album)
|
||||
elif self._persisted_asset_ids is not None:
|
||||
# First refresh after restart - compare with persisted state
|
||||
@@ -423,7 +435,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
removed_asset_ids=list(removed_ids),
|
||||
)
|
||||
album.has_new_assets = change.added_count > 0
|
||||
album.last_change_time = datetime.now()
|
||||
album.last_change_time = dt_util.now()
|
||||
self._fire_events(change, album)
|
||||
_LOGGER.info(
|
||||
"Detected changes during downtime for album '%s': +%d -%d",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
|
||||
"iot_class": "cloud_polling",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
||||
"requirements": ["immich-watcher-core==0.1.0"],
|
||||
"version": "2.8.0"
|
||||
|
||||
@@ -174,7 +174,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||
self._unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
||||
self._unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
|
||||
@property
|
||||
def _album_data(self) -> AlbumData | None:
|
||||
@@ -239,27 +239,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
)
|
||||
return {"assets": assets}
|
||||
|
||||
@staticmethod
|
||||
def _is_quiet_hours(start_str: str | None, end_str: str | None) -> bool:
|
||||
"""Check if current time is within quiet hours."""
|
||||
from datetime import time as dt_time
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if not start_str or not end_str:
|
||||
return False
|
||||
|
||||
try:
|
||||
now = dt_util.now().time()
|
||||
start_time = dt_time.fromisoformat(start_str)
|
||||
end_time = dt_time.fromisoformat(end_str)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if start_time <= end_time:
|
||||
return start_time <= now < end_time
|
||||
else:
|
||||
return now >= start_time or now < end_time
|
||||
|
||||
async def async_send_telegram_notification(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -280,7 +259,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
) -> ServiceResponse:
|
||||
"""Send notification to Telegram."""
|
||||
# Check quiet hours — queue notification if active
|
||||
if self._is_quiet_hours(quiet_hours_start, quiet_hours_end):
|
||||
from . import _is_quiet_hours
|
||||
if _is_quiet_hours(quiet_hours_start, quiet_hours_end):
|
||||
from . import _register_queue_timers
|
||||
queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"]
|
||||
await queue.async_enqueue({
|
||||
|
||||
@@ -71,7 +71,7 @@ class ImmichAlbumProtectedPasswordText(
|
||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
||||
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_protected_password_edit"
|
||||
|
||||
@property
|
||||
|
||||
Reference in New Issue
Block a user