Files
recipe-importer/app/tandoor.py
T
admin 458b1e362a feat: Tandoor integration — settings, test connection, import, duplicate detection
Add TandoorClient (app/tandoor.py) with full recipe creation, image upload,
and duplicate detection via the Tandoor REST API. Settings page now has
separate Mealie and Tandoor sections. Import page shows both send buttons
based on which services are configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:29:58 +01:00

230 lines
7.5 KiB
Python

"""Tandoor API client — creates recipes and uploads images."""
import io
import re
import requests
class TandoorClient:
"""Thin wrapper around the Tandoor REST API."""
def __init__(self, base_url: str, api_key: str, api_url: str = ""):
self.base_url = base_url.rstrip("/")
self.api_url = api_url.rstrip("/") if api_url else self.base_url
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
})
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def test_connection(self) -> dict:
"""Return Tandoor version info or raise on failure."""
# Try the recipe list endpoint as a connection test
r = self.session.get(
f"{self.api_url}/api/recipe/",
params={"limit": 1},
timeout=10,
)
r.raise_for_status()
# Try to get version from openapi endpoint
version = "unknown"
try:
r2 = self.session.get(f"{self.api_url}/openapi/", timeout=10)
if r2.ok:
# Parse version from YAML: "version: 1.5.26"
m = re.search(r"version:\s*['\"]?([0-9][0-9a-z._-]*)", r2.text)
if m:
version = m.group(1)
except Exception:
pass
return {"version": version}
def find_duplicate(self, url: str, title: str = "") -> dict | None:
"""Check if a recipe with this source URL already exists."""
if not url and not title:
return None
search = title or url.split("/")[-1].replace("-", " ")
r = self.session.get(
f"{self.api_url}/api/recipe/",
params={"query": search, "limit": 20},
timeout=10,
)
if not r.ok:
return None
for item in r.json().get("results", []):
source = item.get("source_url") or ""
desc = item.get("description") or ""
if source == url or url in desc:
return {
"id": item["id"],
"name": item["name"],
"url": f"{self.base_url}/view/recipe/{item['id']}",
}
return None
def create_recipe(self, recipe: dict) -> dict:
"""Create a recipe in Tandoor from a scraper result dict.
Returns {"id": int, "url": str}.
"""
payload = self._build_payload(recipe)
r = self.session.post(
f"{self.api_url}/api/recipe/",
json=payload,
timeout=15,
)
r.raise_for_status()
data = r.json()
recipe_id = data["id"]
# Upload image if available
image_url = recipe.get("image_url")
if image_url:
try:
self._upload_image(recipe_id, image_url)
except Exception:
pass # non-fatal
return {
"id": recipe_id,
"url": f"{self.base_url}/view/recipe/{recipe_id}",
}
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _build_payload(self, recipe: dict) -> dict:
description = recipe.get("description", "")
original_url = recipe.get("original_url", "")
if original_url and original_url not in description:
description = f"{description}\n\n{original_url}".strip()
# Build ingredients list
ingredients = []
order = 0
pending_group = ""
for item in recipe.get("ingredients", []):
if isinstance(item, dict):
if "group" in item and "food" not in item:
# Group header
ingredients.append({
"food": None,
"unit": None,
"amount": 0,
"note": item["group"],
"is_header": True,
"no_amount": True,
"order": order,
})
order += 1
else:
ingredients.append(self._build_ingredient(item, order))
order += 1
# Build steps — all ingredients on step 0
instructions = recipe.get("instructions", [])
steps = []
for i, text in enumerate(instructions):
steps.append({
"instruction": text,
"ingredients": ingredients if i == 0 else [],
"order": i,
})
if not instructions and ingredients:
steps.append({
"instruction": "",
"ingredients": ingredients,
"order": 0,
})
return {
"name": recipe["title"],
"description": description,
"source_url": original_url,
"steps": steps,
"servings": 1,
}
def _build_ingredient(self, item: dict, order: int) -> dict:
"""Build a Tandoor ingredient from a structured dict."""
qty_str = str(item.get("quantity", "")).strip()
unit_str = item.get("unit", "").strip()
food_str = item.get("food", "").strip()
extra = item.get("extra", "").strip()
has_structured = bool(qty_str or unit_str)
if has_structured and food_str:
qty = 0
try:
qty = float(qty_str.replace(",", "."))
except (ValueError, TypeError):
pass
return {
"food": {"name": food_str},
"unit": {"name": unit_str} if unit_str else None,
"amount": qty,
"note": extra,
"is_header": False,
"no_amount": False,
"order": order,
}
else:
# No quantity/unit — food-only or note
note = food_str
if extra:
note = f"{note} ({extra})" if note else extra
if food_str:
return {
"food": {"name": food_str},
"unit": None,
"amount": 0,
"note": extra,
"is_header": False,
"no_amount": True,
"order": order,
}
else:
return {
"food": None,
"unit": None,
"amount": 0,
"note": note,
"is_header": False,
"no_amount": True,
"order": order,
}
def _upload_image(self, recipe_id: int, image_url: str):
"""Download image from *image_url* and upload it to the recipe."""
img_resp = requests.get(image_url, timeout=30, headers={
"User-Agent": "RecipeImporter/1.0",
})
img_resp.raise_for_status()
content_type = img_resp.headers.get("Content-Type", "image/jpeg")
ext = "jpg"
if "png" in content_type:
ext = "png"
elif "webp" in content_type:
ext = "webp"
files = {
"image": (f"recipe.{ext}", io.BytesIO(img_resp.content), content_type),
}
r = self.session.put(
f"{self.api_url}/api/recipe/{recipe_id}/image/",
files=files,
timeout=30,
)
r.raise_for_status()