3524f372cc
Both APIs require numeric quantity/amount fields. When a range like "2-3" is detected, uses the first number as the quantity and puts the full range (e.g. "2- 3 ek") in the note field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
349 lines
12 KiB
Python
349 lines
12 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 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()
|