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 .coordinator import CookCliCoordinator
from .websocket_api import async_setup_websocket_api
from .image_proxy import CookCliImageView
_LOGGER = logging.getLogger(__name__)

View file

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

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

View file

@ -162,10 +162,16 @@ class CookCliCard extends HTMLElement {
const meta = [];
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</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 `
<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-meta">${meta.join("")}</div>
</div>
</div>`;
})
.join("");
@ -217,6 +223,11 @@ class CookCliCard extends HTMLElement {
return `
<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>
${cookware ? `<h4>Ustensiles</h4><ul class="cookware-list">${cookware}</ul>` : ""}
<h4>Ingrédients</h4>
@ -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;