9a59b38fd6
When deployed behind Cloudflare Tunnel, requests from the container to the external Mealie URL fail with 530 (hairpin). MEALIE_INTERNAL_URL lets the container use the Docker-internal address (e.g. http://mealie:9000) for API calls while keeping the external URL for browser links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
116 lines
3.7 KiB
Python
116 lines
3.7 KiB
Python
"""Mealie API client — creates recipes and uploads images."""
|
|
|
|
import io
|
|
import uuid
|
|
import requests
|
|
|
|
|
|
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",
|
|
})
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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 create_recipe(self, recipe: dict) -> str:
|
|
"""Create a recipe in Mealie from a scraper result dict.
|
|
|
|
*recipe* keys: title, description, image_url, ingredients, instructions, 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)
|
|
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
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal
|
|
# ------------------------------------------------------------------
|
|
|
|
def _build_payload(self, recipe: dict) -> dict:
|
|
ingredients = []
|
|
for line in recipe.get("ingredients", []):
|
|
ingredients.append({
|
|
"note": line,
|
|
"isFood": False,
|
|
"disableAmount": True,
|
|
})
|
|
|
|
instructions = []
|
|
for text in recipe.get("instructions", []):
|
|
instructions.append({
|
|
"id": uuid.uuid4().hex,
|
|
"text": text,
|
|
})
|
|
|
|
return {
|
|
"name": recipe["title"],
|
|
"description": recipe.get("description", ""),
|
|
"recipeIngredient": ingredients,
|
|
"recipeInstructions": instructions,
|
|
"orgURL": recipe.get("original_url", ""),
|
|
"recipeYield": "",
|
|
}
|
|
|
|
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,
|
|
timeout=30,
|
|
)
|
|
r.raise_for_status()
|