Add images

This commit is contained in:
TheThomaas 2026-09-13 21:40:47 +02:00
parent aca61a9069
commit eebe1bbd7e
5 changed files with 153 additions and 22 deletions

View file

@ -12,6 +12,7 @@ from .api import CookCliApiClient, CookCliApiError
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
from .coordinator import CookCliCoordinator from .coordinator import CookCliCoordinator
from .websocket_api import async_setup_websocket_api from .websocket_api import async_setup_websocket_api
from .image_proxy import CookCliImageView
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View file

@ -54,6 +54,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -98,7 +99,7 @@ class CookCliApiClient:
async def async_list_recipes(self) -> list[RecipeSummary]: async def async_list_recipes(self) -> list[RecipeSummary]:
"""Récupère la liste des recettes disponibles.""" """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) return self._normalize_recipe_list(data)
async def async_get_recipe(self, path: str) -> dict[str, Any]: 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 remplacé ces index par les objets correspondants (voir
`_resolve_recipe`). `_resolve_recipe`).
""" """
data = await self._request("GET", f"/recipes/{path}") data = await self._request_json(f"/recipes/{path}")
return _resolve_recipe(data) 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}" url = f"{self._base_url}{endpoint}"
try: try:
async with self._session.request( async with self._session.request(
method, url, timeout=DEFAULT_TIMEOUT "GET", url, timeout=DEFAULT_TIMEOUT
) as resp: ) as resp:
if resp.status >= 400: if resp.status >= 400:
raise CookCliResponseError( raise CookCliResponseError(
f"CookCLI a répondu {resp.status} pour {url}" f"CookCLI a répondu {resp.status} pour {url}"
) )
try: yield resp
return await resp.json()
except (aiohttp.ContentTypeError, ValueError) as err:
raise CookCliResponseError(
f"Réponse non-JSON de CookCLI pour {url}"
) from err
except asyncio.TimeoutError as err: except asyncio.TimeoutError as err:
raise CookCliConnectionError(f"Timeout en contactant {url}") from err raise CookCliConnectionError(f"Timeout en contactant {url}") from err
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise CookCliConnectionError(f"Impossible de joindre {url}: {err}") from 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 @staticmethod
def _normalize_recipe_list(data: Any) -> list[RecipeSummary]: def _normalize_recipe_list(data: Any) -> list[RecipeSummary]:
"""Aplatit l'arborescence renvoyée par GET /api/recipes en liste plate.""" """Aplatit l'arborescence renvoyée par GET /api/recipes en liste plate."""

View file

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

View file

@ -6,10 +6,17 @@ Deux commandes :
Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser
lequel interroger ; sinon, le premier configuré est utilisé. lequel interroger ; sinon, le premier configuré est utilisé.
Les deux commandes ajoutent un champ "image_url" déjà prêt à l'emploi pour
un <img src="...">, 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 __future__ import annotations
from urllib.parse import quote
import voluptuous as vol import voluptuous as vol
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -18,13 +25,22 @@ from .api import CookCliApiError
from .const import DOMAIN 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, {}) entries = hass.data.get(DOMAIN, {})
if entry_id is not None: if entry_id is not None:
return entries.get(entry_id) return entry_id, entries.get(entry_id)
if entries: if entries:
return next(iter(entries.values())) 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 None
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
@websocket_api.websocket_command( @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( async def ws_list_recipes(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None: ) -> 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: if entry_data is None:
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée") connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
return return
@ -53,6 +69,7 @@ async def ws_list_recipes(
"time": metadata.get("time"), "time": metadata.get("time"),
"servings": metadata.get("servings"), "servings": metadata.get("servings"),
"tags": metadata.get("tags", []), "tags": metadata.get("tags", []),
"image_url": _image_url(entry_id, metadata.get("image")),
} }
) )
connection.send_result(msg["id"], {"recipes": recipes}) connection.send_result(msg["id"], {"recipes": recipes})
@ -69,7 +86,7 @@ async def ws_list_recipes(
async def ws_get_recipe( async def ws_get_recipe(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None: ) -> 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: if entry_data is None:
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée") connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
return return
@ -81,6 +98,7 @@ async def ws_get_recipe(
connection.send_error(msg["id"], "cannot_connect", str(err)) connection.send_error(msg["id"], "cannot_connect", str(err))
return return
recipe["image_url"] = _image_url(entry_id, recipe.get("image"))
connection.send_result(msg["id"], recipe) connection.send_result(msg["id"], recipe)

View file

@ -162,10 +162,16 @@ class CookCliCard extends HTMLElement {
const meta = []; const meta = [];
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`); if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`);
if (r.servings) meta.push(`<span>🍽 ${this._escape(String(r.servings))}</span>`); if (r.servings) meta.push(`<span>🍽 ${this._escape(String(r.servings))}</span>`);
const thumb = r.image_url
? `<img class="recipe-thumb" src="${this._escape(r.image_url)}" alt="" loading="lazy" onerror="this.style.display='none'">`
: `<div class="recipe-thumb recipe-thumb-placeholder"></div>`;
return ` return `
<div class="recipe-row" data-path="${this._escape(r.path)}"> <div class="recipe-row" data-path="${this._escape(r.path)}">
${thumb}
<div class="recipe-row-text">
<div class="recipe-name">${this._escape(r.name)}</div> <div class="recipe-name">${this._escape(r.name)}</div>
<div class="recipe-meta">${meta.join("")}</div> <div class="recipe-meta">${meta.join("")}</div>
</div>
</div>`; </div>`;
}) })
.join(""); .join("");
@ -217,6 +223,11 @@ class CookCliCard extends HTMLElement {
return ` return `
<button class="back-btn" data-action="back">← Retour à la liste</button> <button class="back-btn" data-action="back">← Retour à la liste</button>
${
r.image_url
? `<img class="recipe-hero" src="${this._escape(r.image_url)}" alt="" onerror="this.style.display='none'">`
: ""
}
<h2>${this._escape(r.title || "")}</h2> <h2>${this._escape(r.title || "")}</h2>
${cookware ? `<h4>Ustensiles</h4><ul class="cookware-list">${cookware}</ul>` : ""} ${cookware ? `<h4>Ustensiles</h4><ul class="cookware-list">${cookware}</ul>` : ""}
<h4>Ingrédients</h4> <h4>Ingrédients</h4>
@ -246,17 +257,30 @@ class CookCliCard extends HTMLElement {
.state-msg.error { color: var(--error-color, #db4437); } .state-msg.error { color: var(--error-color, #db4437); }
.recipe-list { display: flex; flex-direction: column; } .recipe-list { display: flex; flex-direction: column; }
.recipe-row { .recipe-row {
display: flex; justify-content: space-between; align-items: center; display: flex; align-items: center; gap: 12px;
padding: 12px 4px; border-bottom: 1px solid var(--divider-color); padding: 10px 4px; border-bottom: 1px solid var(--divider-color);
cursor: pointer; cursor: pointer;
} }
.recipe-row:last-child { border-bottom: none; } .recipe-row:last-child { border-bottom: none; }
.recipe-row:hover { background: var(--secondary-background-color); } .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 { .recipe-meta {
display: flex; gap: 12px; font-size: 0.85em; display: flex; gap: 12px; font-size: 0.85em;
color: var(--secondary-text-color); white-space: nowrap; 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 { .back-btn {
background: none; border: none; color: var(--primary-color); background: none; border: none; color: var(--primary-color);
font-size: 0.95em; cursor: pointer; padding: 12px 0 4px; display: block; font-size: 0.95em; cursor: pointer; padding: 12px 0 4px; display: block;