Files
admin e746dc10c9 Edit page: auto-expand step textareas, wider layout, image management
- Textareas auto-resize to fit content (min 120px)
- Edit page container widened to 1100px
- Show recipe image with URL input and file upload options
- Add image upload endpoint (POST /api/recipes/<backend>/<id>/image)
- Add upload_image_bytes() to both Mealie and Tandoor clients

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:07:56 +01:00

485 lines
17 KiB
Python

"""Mealie API client — creates recipes and uploads images."""
import io
import re
import uuid
import requests
def _parse_qty(qty_str: str) -> tuple[float, str]:
"""Parse a quantity string, handling ranges like '2-3' or '6- 8'.
Returns (number, range_note) where range_note is the original range
string if the quantity is a range, or empty string if it's a plain number.
"""
if not qty_str:
return (0, "")
# Try plain number first
try:
return (float(qty_str.replace(",", ".")), "")
except (ValueError, TypeError):
pass
# Try range: "2-3", "2- 3", "6 - 8"
m = re.match(r"^(\d+(?:[.,]\d+)?)\s*-\s*(\d+(?:[.,]\d+)?)$", qty_str.strip())
if m:
first = float(m.group(1).replace(",", "."))
return (first, qty_str.strip())
return (0, "")
class MealieClient:
"""Thin wrapper around the Mealie 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",
})
self._units_cache = None # {name_or_abbr: {id, name}}
self._foods_cache = {} # {lowercase_name: {id, name}}
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def test_connection(self) -> dict:
"""Return Mealie app info or raise on failure."""
r = self.session.get(f"{self.api_url}/api/app/about", timeout=10)
r.raise_for_status()
return r.json()
def find_duplicate(self, url: str, title: str = "") -> dict | None:
"""Check if a recipe with this original URL already exists.
Searches by URL in orgURL and description fields.
Returns {"slug": ..., "name": ..., "url": ...} or None.
"""
if not url:
return None
# Search by title to find candidates (Mealie search matches name)
search_term = title or url.split("/")[-1].replace("-", " ")
r = self.session.get(
f"{self.api_url}/api/recipes",
params={"search": search_term, "perPage": 50},
timeout=10,
)
if not r.ok:
return None
for item in r.json().get("items", []):
detail = self.session.get(
f"{self.api_url}/api/recipes/{item['slug']}", timeout=10
)
if not detail.ok:
continue
data = detail.json()
org = data.get("orgURL", "") or ""
desc = data.get("description", "") or ""
if org == url or url in desc:
return {
"slug": data["slug"],
"name": data.get("name", data["slug"]),
"url": f"{self.base_url}/g/home/r/{data['slug']}",
}
return None
def list_tags(self) -> list[dict]:
"""Return all tags as [{name, slug, id}]."""
r = self.session.get(
f"{self.api_url}/api/organizers/tags",
params={"page": 1, "perPage": -1},
timeout=10,
)
if not r.ok:
return []
return [{"name": t["name"], "slug": t["slug"], "id": t["id"]}
for t in r.json().get("items", [])]
def list_recipes(self, search: str = "", tag_ids: list[str] | None = None,
page: int = 1, per_page: int = 50) -> dict:
"""List recipes with optional search and tag filtering.
Returns {"items": [...], "page": int, "per_page": int, "total": int}.
"""
params: dict = {
"page": page, "perPage": per_page,
"orderBy": "name", "orderDirection": "asc",
}
if search:
params["search"] = search
if tag_ids:
# Mealie accepts repeated ?tags=<id>&tags=<id> params.
# requests encodes list values as repeated keys automatically.
params["tags"] = tag_ids
r = self.session.get(f"{self.api_url}/api/recipes", params=params,
timeout=15)
r.raise_for_status()
data = r.json()
items = []
for item in data.get("items", []):
image_url = ""
if item.get("image"):
image_url = f"{self.base_url}/api/media/recipes/{item['id']}/images/min-original.webp"
items.append({
"id": item["slug"],
"name": item.get("name", ""),
"image": image_url,
"tags": [t.get("name", "") for t in item.get("tags", []) if t.get("name")],
"url": f"{self.base_url}/g/home/r/{item['slug']}",
})
return {
"items": items,
"page": data.get("page", page),
"per_page": data.get("perPage", per_page),
"total": data.get("total", 0),
}
def get_recipe(self, slug: str) -> dict:
"""Fetch a single recipe and return it in the common format."""
r = self.session.get(f"{self.api_url}/api/recipes/{slug}", timeout=15)
r.raise_for_status()
data = r.json()
# Parse ingredients
ingredients = []
current_group = None
for ing in data.get("recipeIngredient", []):
group_title = ing.get("title", "")
if group_title and group_title != current_group:
ingredients.append({"group": group_title})
current_group = group_title
food_name = ""
if ing.get("food"):
food_name = ing["food"].get("name", "")
unit_name = ""
if ing.get("unit"):
unit_name = ing["unit"].get("name", "")
qty = ing.get("quantity", 0)
qty_str = ""
if qty:
qty_str = str(int(qty)) if qty == int(qty) else str(qty)
note = ing.get("note", "")
if food_name or qty_str or unit_name:
ingredients.append({
"quantity": qty_str,
"unit": unit_name,
"food": food_name,
"extra": note,
})
# Parse instructions
instructions = []
for step in data.get("recipeInstructions", []):
text = step.get("text", "").strip()
if text:
instructions.append(text)
# Parse tags
tags = [t.get("name", "") for t in data.get("tags", []) if t.get("name")]
# Image URL
image_url = ""
if data.get("image"):
image_url = f"{self.base_url}/api/media/recipes/{data['id']}/images/original.webp"
return {
"title": data.get("name", ""),
"description": data.get("description", ""),
"image_url": image_url,
"ingredients": ingredients,
"instructions": instructions,
"tags": tags,
"original_url": data.get("orgURL", ""),
}
def update_recipe(self, slug: str, recipe: dict) -> None:
"""Update an existing recipe from a common-format dict."""
payload = self._build_payload(recipe)
tag_names = recipe.get("tags", [])
if tag_names:
payload["tags"] = self._ensure_tags(tag_names)
else:
payload["tags"] = []
r = self.session.patch(
f"{self.api_url}/api/recipes/{slug}",
json=payload,
timeout=15,
)
r.raise_for_status()
def delete_recipe(self, slug: str) -> None:
"""Delete a recipe by slug."""
r = self.session.delete(f"{self.api_url}/api/recipes/{slug}", timeout=10)
r.raise_for_status()
def upload_image_bytes(self, slug: str, image_data: bytes,
content_type: str = "image/jpeg") -> None:
"""Upload raw image bytes to a recipe."""
ext = "jpg"
if "png" in content_type:
ext = "png"
elif "webp" in content_type:
ext = "webp"
files = {
"image": (f"recipe.{ext}", io.BytesIO(image_data), content_type),
}
r = self.session.put(
f"{self.api_url}/api/recipes/{slug}/image",
files=files,
data={"extension": ext},
timeout=30,
)
r.raise_for_status()
def create_recipe(self, recipe: dict) -> str:
"""Create a recipe in Mealie from a scraper result dict.
*recipe* keys: title, description, image_url, ingredients, instructions, tags, original_url.
Returns the recipe slug.
"""
# Step 1: create stub
r = self.session.post(
f"{self.api_url}/api/recipes",
json={"name": recipe["title"]},
timeout=15,
)
r.raise_for_status()
slug = r.json() # Mealie returns the slug as a plain string
# Step 2: build full payload and PATCH
payload = self._build_payload(recipe)
# Step 2b: resolve tags (create if needed, get {id, name, slug})
tag_names = recipe.get("tags", [])
if tag_names:
payload["tags"] = self._ensure_tags(tag_names)
r = self.session.patch(
f"{self.api_url}/api/recipes/{slug}",
json=payload,
timeout=15,
)
r.raise_for_status()
# Step 3: upload image if available
image_url = recipe.get("image_url")
if image_url:
try:
self._upload_image(slug, image_url)
except Exception:
pass # non-fatal — recipe is still created
return slug
# ------------------------------------------------------------------
# Unit / food resolution
# ------------------------------------------------------------------
def _load_units(self):
"""Fetch all units from Mealie, build lookup by name and abbreviation."""
if self._units_cache is not None:
return
self._units_cache = {}
r = self.session.get(f"{self.api_url}/api/units", params={"perPage": -1}, timeout=10)
r.raise_for_status()
for u in r.json().get("items", []):
entry = {"id": u["id"], "name": u["name"]}
self._units_cache[u["name"].lower()] = entry
abbr = u.get("abbreviation", "")
if abbr:
self._units_cache[abbr.lower()] = entry
def _ensure_unit(self, name: str):
"""Look up a unit by name/abbreviation. Create if missing.
Returns {"id": ..., "name": ...} or None for empty name."""
if not name:
return None
self._load_units()
key = name.lower().strip()
if key in self._units_cache:
return self._units_cache[key]
# Create new unit
r = self.session.post(
f"{self.api_url}/api/units",
json={"name": name, "abbreviation": name, "pluralName": name},
timeout=10,
)
if r.ok:
u = r.json()
entry = {"id": u["id"], "name": u["name"]}
self._units_cache[key] = entry
return entry
return None
def _ensure_food(self, name: str):
"""Look up a food by name. Create if missing.
Returns {"id": ..., "name": ...} or None for empty name."""
if not name:
return None
key = name.lower().strip()
if key in self._foods_cache:
return self._foods_cache[key]
# Search existing foods
r = self.session.get(
f"{self.api_url}/api/foods",
params={"search": name, "perPage": 50},
timeout=10,
)
if r.ok:
for f in r.json().get("items", []):
if f["name"].lower() == key:
entry = {"id": f["id"], "name": f["name"]}
self._foods_cache[key] = entry
return entry
# Create new food
r = self.session.post(
f"{self.api_url}/api/foods",
json={"name": name},
timeout=10,
)
if r.ok:
f = r.json()
entry = {"id": f["id"], "name": f["name"]}
self._foods_cache[key] = entry
return entry
return None
def _ensure_tags(self, tag_names: list[str]) -> list[dict]:
"""Create tags that don't exist yet, return [{id, name, slug}] for all."""
existing = {t["name"].lower(): t for t in self.list_tags()}
result = []
for name in tag_names:
key = name.lower()
if key in existing:
result.append(existing[key])
else:
r = self.session.post(
f"{self.api_url}/api/organizers/tags",
json={"name": name},
timeout=10,
)
if r.ok:
t = r.json()
tag = {"id": t["id"], "name": t["name"], "slug": t["slug"]}
existing[key] = tag
result.append(tag)
return result
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _build_payload(self, recipe: dict) -> dict:
ingredients = []
pending_group = ""
for item in recipe.get("ingredients", []):
if isinstance(item, dict):
# Group header marker — apply title to the next real ingredient
if "group" in item and "food" not in item:
pending_group = item["group"]
continue
ing = self._build_ingredient(item)
else:
# Legacy: plain string
ing = {
"referenceId": str(uuid.uuid4()),
"note": str(item),
"isFood": False,
"disableAmount": True,
}
if pending_group:
ing["title"] = pending_group
pending_group = ""
ingredients.append(ing)
instructions = []
for text in recipe.get("instructions", []):
instructions.append({
"id": str(uuid.uuid4()),
"title": "",
"text": text,
"ingredientReferences": [],
})
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()
return {
"name": recipe["title"],
"description": description,
"recipeIngredient": ingredients,
"recipeInstructions": instructions,
"orgURL": original_url,
"recipeYield": "",
}
def _build_ingredient(self, item: dict) -> dict:
"""Build a Mealie 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:
unit_ref = self._ensure_unit(unit_str) if unit_str else None
food_ref = self._ensure_food(food_str)
qty, range_note = _parse_qty(qty_str)
if range_note:
extra = f"{range_note} {unit_str}; {extra}".strip("; ") if extra else f"{range_note} {unit_str}".strip()
return {
"referenceId": str(uuid.uuid4()),
"quantity": qty,
"unit": unit_ref,
"food": food_ref,
"note": extra,
"isFood": True,
"disableAmount": False,
}
else:
# No quantity/unit — put food+extra in note
note = food_str
if extra:
note = f"{note} ({extra})" if note else extra
return {
"referenceId": str(uuid.uuid4()),
"note": note,
"isFood": False,
"disableAmount": True,
}
def _upload_image(self, slug: str, 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/recipes/{slug}/image",
files=files,
data={"extension": ext},
timeout=30,
)
r.raise_for_status()