feat(commands): per-chat album scope derived from notification routing

The "per-chat album scope" feature stored on CommandTrackerListener was
really per-bot: listener_id = bot.id, and every chat that bot served
shared the same scope.  Commands like /albums, /random, /status,
/events leaked the full provider catalog into chats that were never
wired up to receive notifications from those trackers.

New model: the album scope for /commands in a given chat is derived
from the notification-routing graph.  For a (provider, bot, chat_id)
triple we walk TargetReceiver (chat_id match, enabled) →
NotificationTarget (telegram or broadcast parent) →
NotificationTrackerTarget → NotificationTracker (provider match) and
union their collection_ids.  That's the natural "what does this chat
get notifications about" set, and it becomes the command scope.

- New helper: command_utils.resolve_chat_album_scope(provider_id,
  bot_id, chat_id) -> set[str].  Empty set is the default for chats
  with no routing — commands return nothing rather than leaking the
  provider's catalog.
- Dispatcher computes the scope per (tracker, bot, chat) and threads
  it through handler.handle(..., allowed_album_ids=...).  Explicit
  CommandTrackerListener.allowed_album_ids override, when set, still
  wins verbatim (kept as an escape hatch for users who want a divergent
  scope for a whole bot).
- /status, /albums, /events, and all /_cmd_immich-routed commands
  (/random, /search, /find, /latest, /memory, /summary, /favorites,
  /place, /person) now intersect with the resolved scope.
- UI scope modal relabeled: it's an explicit *override for this bot*,
  not a per-chat setting.  Default is "derive from notification
  routing", which matches what users already configured elsewhere.

Also:
- /search, /find, /person, /place — _enrich_assets return value was
  discarded, dropping public_url enrichment.  Assign the return value.
- search_smart / search_metadata — consolidated into _search_items
  helper that logs non-200 responses and transport errors instead of
  silently returning [].  Makes "always no results" bugs actually
  diagnosable.  Also accepts the alternate {"assets": [...]} flat-list
  shape from older Immich versions.
- Immich search error bodies go through _redact_body so credentials
  echoed by authenticating proxies don't land in server logs.
This commit is contained in:
2026-04-22 03:20:51 +03:00
parent 4ff3876e49
commit 3b76a09759
13 changed files with 242 additions and 58 deletions
@@ -297,19 +297,9 @@ class ImmichClient:
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids:
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
try:
async with self._session.post(
f"{self._url}/api/search/smart",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
return []
return await self._search_items(
f"{self._url}/api/search/smart", payload, limit, "smart",
)
async def search_metadata(
self,
@@ -322,18 +312,57 @@ class ImmichClient:
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids:
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
return await self._search_items(
f"{self._url}/api/search/metadata", payload, limit, "metadata",
)
async def _search_items(
self,
url: str,
payload: dict[str, Any],
limit: int,
kind: str,
) -> list[dict[str, Any]]:
"""Shared POST-and-extract-items helper with error logging.
Returns an empty list on any error; previously these paths swallowed
non-200s silently, making "/search always returns no results" on
misbehaving Immich deployments impossible to diagnose without a
network trace. Logging keeps the empty-list contract but tells the
operator *why* it's empty.
"""
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
url,
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
if response.status != 200:
body_snip = await response.text()
_LOGGER.warning(
"Immich %s search non-200: HTTP %s body=%s",
kind, response.status, _redact_body(body_snip),
)
return []
data = await response.json()
# Modern Immich: {"assets": {"items": [...], ...}}
assets_block = data.get("assets")
if isinstance(assets_block, dict):
items = assets_block.get("items", []) or []
elif isinstance(assets_block, list):
# Older/alternate shape — flat list of assets.
items = assets_block
else:
_LOGGER.warning(
"Immich %s search returned unexpected shape: keys=%s",
kind, list(data.keys())[:5],
)
items = []
return items[:limit]
except aiohttp.ClientError as err:
_LOGGER.warning("Immich %s search transport error: %s", kind, err)
except Exception as err: # noqa: BLE001 — don't crash caller on unexpected JSON
_LOGGER.warning("Immich %s search parse error: %s", kind, err)
return []
async def search_by_person(