From eebe1bbd7e425e302d1570c1ba40b46b8a24423f Mon Sep 17 00:00:00 2001 From: TheThomaas Date: Sun, 13 Sep 2026 21:40:47 +0200 Subject: [PATCH] Add images --- custom_components/cookcli/__init__.py | 1 + custom_components/cookcli/api.py | 58 ++++++++++++++++++---- custom_components/cookcli/image_proxy.py | 52 +++++++++++++++++++ custom_components/cookcli/websocket_api.py | 30 ++++++++--- www/cookcli-card.js | 34 +++++++++++-- 5 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 custom_components/cookcli/image_proxy.py diff --git a/custom_components/cookcli/__init__.py b/custom_components/cookcli/__init__.py index f3e25a0..5775e1c 100644 --- a/custom_components/cookcli/__init__.py +++ b/custom_components/cookcli/__init__.py @@ -12,6 +12,7 @@ from .api import CookCliApiClient, CookCliApiError from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS from .coordinator import CookCliCoordinator from .websocket_api import async_setup_websocket_api +from .image_proxy import CookCliImageView _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/cookcli/api.py b/custom_components/cookcli/api.py index 5813288..67b4d92 100644 --- a/custom_components/cookcli/api.py +++ b/custom_components/cookcli/api.py @@ -54,6 +54,7 @@ from __future__ import annotations import asyncio import logging +from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Any @@ -98,7 +99,7 @@ class CookCliApiClient: async def async_list_recipes(self) -> list[RecipeSummary]: """Récupère la liste des recettes disponibles.""" - data = await self._request("GET", "/recipes") + data = await self._request_json("/recipes") return self._normalize_recipe_list(data) async def async_get_recipe(self, path: str) -> dict[str, Any]: @@ -109,30 +110,65 @@ class CookCliApiClient: remplacé ces index par les objets correspondants (voir `_resolve_recipe`). """ - data = await self._request("GET", f"/recipes/{path}") + data = await self._request_json(f"/recipes/{path}") return _resolve_recipe(data) - async def _request(self, method: str, endpoint: str) -> Any: + async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]: + """Récupère les octets d'une image statique CookCLI. + + `image_ref` peut prendre trois formes selon d'où il vient : + - "/api/static/img/x.jpg" : valeur brute du champ "image" du détail + d'une recette (chemin déjà préfixé par /api). + - "/img/x.jpg" ou "img/x.jpg" : valeur brute de metadata.image dans + l'arborescence de /api/recipes (chemin relatif, avec ou sans "/" + initial selon comment la recette a été écrite). + + Ces trois formes ont été observées dans des réponses réelles du + serveur (pas juste supposées) ; `_image_endpoint` les normalise + toutes vers /static/... — à vérifier visuellement une fois la carte + rechargée, au cas où une recette aurait un format différent. + """ + endpoint = self._image_endpoint(image_ref) + async with self._get(endpoint) as resp: + content_type = resp.headers.get("Content-Type") + data = await resp.read() + return data, content_type + + @staticmethod + def _image_endpoint(image_ref: str) -> str: + if image_ref.startswith("/api/"): + return image_ref[len("/api") :] + if image_ref.startswith("/"): + return f"/static{image_ref}" + return f"/static/{image_ref}" + + @asynccontextmanager + async def _get(self, endpoint: str): + """Contexte partagé pour un GET vers CookCLI, avec gestion d'erreurs.""" url = f"{self._base_url}{endpoint}" try: async with self._session.request( - method, url, timeout=DEFAULT_TIMEOUT + "GET", url, timeout=DEFAULT_TIMEOUT ) as resp: if resp.status >= 400: raise CookCliResponseError( f"CookCLI a répondu {resp.status} pour {url}" ) - try: - return await resp.json() - except (aiohttp.ContentTypeError, ValueError) as err: - raise CookCliResponseError( - f"Réponse non-JSON de CookCLI pour {url}" - ) from err + yield resp except asyncio.TimeoutError as err: raise CookCliConnectionError(f"Timeout en contactant {url}") from err except aiohttp.ClientError as err: raise CookCliConnectionError(f"Impossible de joindre {url}: {err}") from err + async def _request_json(self, endpoint: str) -> Any: + async with self._get(endpoint) as resp: + try: + return await resp.json() + except (aiohttp.ContentTypeError, ValueError) as err: + raise CookCliResponseError( + f"Réponse non-JSON de CookCLI pour {endpoint}" + ) from err + @staticmethod def _normalize_recipe_list(data: Any) -> list[RecipeSummary]: """Aplatit l'arborescence renvoyée par GET /api/recipes en liste plate.""" @@ -308,4 +344,4 @@ def _resolve_recipe(data: dict[str, Any]) -> dict[str, Any]: "ingredients": ingredients, "cookware": cookware, "sections": sections, - } \ No newline at end of file + } diff --git a/custom_components/cookcli/image_proxy.py b/custom_components/cookcli/image_proxy.py new file mode 100644 index 0000000..2f1d45c --- /dev/null +++ b/custom_components/cookcli/image_proxy.py @@ -0,0 +1,52 @@ +"""Proxy HTTP pour les images statiques de CookCLI. + +Le navigateur ne peut pas atteindre le serveur CookCLI directement (ni le +port ingress 9080, restreint à l'IP du Supervisor par nginx, ni le port +direct 9081, non publié sur le LAN). Seul le backend Home Assistant, sur le +même réseau Docker interne que l'add-on, peut le faire. Cette vue relaie +donc les octets de l'image en passant par ce backend. +""" + +from __future__ import annotations + +import logging + +from aiohttp import web +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant + +from .api import CookCliApiError +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class CookCliImageView(HomeAssistantView): + """GET /api/cookcli/image/{entry_id}/{image_ref} -> octets de l'image.""" + + url = "/api/cookcli/image/{entry_id}/{image_ref:.*}" + name = "api:cookcli:image" + requires_auth = True + + def __init__(self, hass: HomeAssistant) -> None: + self.hass = hass + + async def get( + self, request: web.Request, entry_id: str, image_ref: str + ) -> web.Response: + entry_data = self.hass.data.get(DOMAIN, {}).get(entry_id) + if entry_data is None: + return web.Response(status=404, text="Intégration CookCLI introuvable") + + client = entry_data["client"] + try: + data, content_type = await client.async_get_image(image_ref) + except CookCliApiError as err: + _LOGGER.debug("Échec de récupération de l'image %s: %s", image_ref, err) + return web.Response(status=502, text="Impossible de récupérer l'image") + + return web.Response( + body=data, + content_type=content_type or "application/octet-stream", + headers={"Cache-Control": "max-age=3600"}, + ) diff --git a/custom_components/cookcli/websocket_api.py b/custom_components/cookcli/websocket_api.py index eaa3a82..431ab61 100644 --- a/custom_components/cookcli/websocket_api.py +++ b/custom_components/cookcli/websocket_api.py @@ -6,10 +6,17 @@ Deux commandes : Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser lequel interroger ; sinon, le premier configuré est utilisé. + +Les deux commandes ajoutent un champ "image_url" déjà prêt à l'emploi pour +un , qui pointe vers CookCliImageView (voir image_proxy.py) — +le frontend n'a pas besoin de connaître le format brut des chemins d'image +renvoyés par CookCLI. """ from __future__ import annotations +from urllib.parse import quote + import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant @@ -18,13 +25,22 @@ from .api import CookCliApiError from .const import DOMAIN -def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | None: +def _resolve_entry( + hass: HomeAssistant, entry_id: str | None +) -> tuple[str | None, dict | None]: entries = hass.data.get(DOMAIN, {}) if entry_id is not None: - return entries.get(entry_id) + return entry_id, entries.get(entry_id) if entries: - return next(iter(entries.values())) - return None + first_id, first_data = next(iter(entries.items())) + return first_id, first_data + return None, None + + +def _image_url(entry_id: str, image_ref: str | None) -> str | None: + if not image_ref: + return None + return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}" @websocket_api.websocket_command( @@ -37,7 +53,7 @@ def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | Non async def ws_list_recipes( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - entry_data = _resolve_entry_data(hass, msg.get("entry_id")) + entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id")) if entry_data is None: connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée") return @@ -53,6 +69,7 @@ async def ws_list_recipes( "time": metadata.get("time"), "servings": metadata.get("servings"), "tags": metadata.get("tags", []), + "image_url": _image_url(entry_id, metadata.get("image")), } ) connection.send_result(msg["id"], {"recipes": recipes}) @@ -69,7 +86,7 @@ async def ws_list_recipes( async def ws_get_recipe( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - entry_data = _resolve_entry_data(hass, msg.get("entry_id")) + entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id")) if entry_data is None: connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée") return @@ -81,6 +98,7 @@ async def ws_get_recipe( connection.send_error(msg["id"], "cannot_connect", str(err)) return + recipe["image_url"] = _image_url(entry_id, recipe.get("image")) connection.send_result(msg["id"], recipe) diff --git a/www/cookcli-card.js b/www/cookcli-card.js index 0fbde99..63a2e91 100644 --- a/www/cookcli-card.js +++ b/www/cookcli-card.js @@ -162,10 +162,16 @@ class CookCliCard extends HTMLElement { const meta = []; if (r.time) meta.push(`⏱ ${this._escape(r.time)}`); if (r.servings) meta.push(`🍽 ${this._escape(String(r.servings))}`); + const thumb = r.image_url + ? `` + : `
`; return `
-
${this._escape(r.name)}
-
${meta.join("")}
+ ${thumb} +
+
${this._escape(r.name)}
+
${meta.join("")}
+
`; }) .join(""); @@ -217,6 +223,11 @@ class CookCliCard extends HTMLElement { return ` + ${ + r.image_url + ? `` + : "" + }

${this._escape(r.title || "")}

${cookware ? `

Ustensiles

` : ""}

Ingrédients

@@ -246,17 +257,30 @@ class CookCliCard extends HTMLElement { .state-msg.error { color: var(--error-color, #db4437); } .recipe-list { display: flex; flex-direction: column; } .recipe-row { - display: flex; justify-content: space-between; align-items: center; - padding: 12px 4px; border-bottom: 1px solid var(--divider-color); + display: flex; align-items: center; gap: 12px; + padding: 10px 4px; border-bottom: 1px solid var(--divider-color); cursor: pointer; } .recipe-row:last-child { border-bottom: none; } .recipe-row:hover { background: var(--secondary-background-color); } - .recipe-name { font-weight: 500; color: var(--primary-text-color); } + .recipe-thumb { + width: 56px; height: 56px; border-radius: 8px; object-fit: cover; + flex-shrink: 0; background: var(--secondary-background-color); + } + .recipe-thumb-placeholder { background: var(--divider-color); } + .recipe-row-text { min-width: 0; } + .recipe-name { + font-weight: 500; color: var(--primary-text-color); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } .recipe-meta { display: flex; gap: 12px; font-size: 0.85em; color: var(--secondary-text-color); white-space: nowrap; } + .recipe-hero { + width: 100%; max-height: 220px; object-fit: cover; + border-radius: 8px; margin: 4px 0 8px; display: block; + } .back-btn { background: none; border: none; color: var(--primary-color); font-size: 0.95em; cursor: pointer; padding: 12px 0 4px; display: block;