Comprehensive review fixes: security, performance, code quality, and UI polish
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:
2026-03-19 18:34:31 +03:00
parent a04d5618d0
commit 381de98c40
39 changed files with 785 additions and 626 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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",

View File

@@ -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"

View File

@@ -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({

View File

@@ -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