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
+ ? `
`
+ : `