Add images
This commit is contained in:
parent
aca61a9069
commit
eebe1bbd7e
|
|
@ -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__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
@ -308,4 +344,4 @@ def _resolve_recipe(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"ingredients": ingredients,
|
"ingredients": ingredients,
|
||||||
"cookware": cookware,
|
"cookware": cookware,
|
||||||
"sections": sections,
|
"sections": sections,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
custom_components/cookcli/image_proxy.py
Normal file
52
custom_components/cookcli/image_proxy.py
Normal 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"},
|
||||||
|
)
|
||||||
|
|
@ -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 None
|
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(
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}">
|
||||||
<div class="recipe-name">${this._escape(r.name)}</div>
|
${thumb}
|
||||||
<div class="recipe-meta">${meta.join("")}</div>
|
<div class="recipe-row-text">
|
||||||
|
<div class="recipe-name">${this._escape(r.name)}</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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue