cauldron/cauldron/mealie.py
Kayos 3ec120c1d9 discover v0.1: scrape + search + import
- requirements: add recipe-scrapers 15.6.0
- mealie.import_from_url(): POST /api/recipes/create/url returns slug
- db helpers: insert_discovered_recipe, update_discovered_meta,
  set_discovered_status, list_discovered_recipes (FULLTEXT + JSON
  filters), count_discovered_by_status, get_discovered_recipe;
  discover-job CRUD + anti-zombie finalize + stuck-job recovery
- discover_recipes.py: daemon-thread runner (mirrors enrich pattern)
  walks a URL list; scrape_me → reshape to mealie shape → INSERT IGNORE
  → forge.enrich_recipe → flip raw → enriched. SEED_URLS curated
  starter packs for allrecipes / bbc / smitten / pinch / hbh.
- endpoints: GET /discover, GET /api/discover/search (q + cuisine +
  complexity + protein + meal_type + kid-fit + max_minutes + status),
  POST /api/discover/import/<id>, /reject/<id>, /scrape-start (seed
  or urls list), /scrape-status, /scrape-cancel/<id>
- discover.html: filter row + card grid + collapsible scrape panel
  with seed chips and url textarea + live progress poll
- nav: 'discover' tab on /, link card on /me
- boot recovery: fail_stuck_discover_jobs at startup
2026-05-01 07:38:27 -07:00

202 lines
7.8 KiB
Python

"""Mealie API client — only the surface cauldron actually uses.
Mealie ingredient schema (relevant fields):
- quantity: float | None
- unit: {id, name, ...} | None
- food: {id, name, plural_name, ...} | None
- note: str (free-form addendum)
- display: str (rendered)
- is_food: bool (false = section header)
- original_text: str
Cauldron's sterilizer reads recipes, asks clawdforge to parse free-form
ingredient strings into structured form, then maps the parse to existing
food/unit IDs (creating them if missing) and writes back.
"""
import requests
class MealieError(RuntimeError):
pass
class Mealie:
def __init__(self, *, base_url: str, api_token: str):
self.base_url = base_url.rstrip("/")
self.api_token = api_token
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
}
)
# --- low-level ----------------------------------------------------------
def _get(self, path: str, **params) -> dict:
try:
r = self.session.get(f"{self.base_url}{path}", params=params, timeout=30)
except requests.RequestException as e:
raise MealieError(f"GET {path} transport: {e}") from e
if r.status_code >= 400:
raise MealieError(f"GET {path} -> {r.status_code}: {r.text[:300]}")
return r.json()
def _put(self, path: str, body: dict) -> dict:
try:
r = self.session.put(f"{self.base_url}{path}", json=body, timeout=30)
except requests.RequestException as e:
raise MealieError(f"PUT {path} transport: {e}") from e
if r.status_code >= 400:
raise MealieError(f"PUT {path} -> {r.status_code}: {r.text[:300]}")
return r.json()
def _post(self, path: str, body: dict) -> dict:
try:
r = self.session.post(f"{self.base_url}{path}", json=body, timeout=30)
except requests.RequestException as e:
raise MealieError(f"POST {path} transport: {e}") from e
if r.status_code >= 400:
raise MealieError(f"POST {path} -> {r.status_code}: {r.text[:300]}")
return r.json()
# --- auth / self --------------------------------------------------------
def who_am_i(self) -> dict:
"""GET /api/users/self — returns the authenticated user's profile.
Used by cauldron to validate user-supplied tokens before storing."""
return self._get("/api/users/self")
# --- recipes ------------------------------------------------------------
def list_recipes(
self,
*,
page: int = 1,
per_page: int = 50,
search: str | None = None,
order_by: str | None = None,
order_direction: str | None = None,
categories: list[str] | None = None,
tags: list[str] | None = None,
) -> dict:
params: dict = {"page": page, "perPage": per_page}
if search:
params["search"] = search
if order_by:
params["orderBy"] = order_by
if order_direction:
params["orderDirection"] = order_direction
if categories:
params["categories"] = categories
if tags:
params["tags"] = tags
return self._get("/api/recipes", **params)
def list_categories(self) -> dict:
"""GET /api/organizers/categories — returns full list of categories
in the household."""
return self._get("/api/organizers/categories", perPage=200)
def get_recipe(self, slug: str) -> dict:
return self._get(f"/api/recipes/{slug}")
def update_recipe(self, slug: str, body: dict) -> dict:
return self._put(f"/api/recipes/{slug}", body)
def import_from_url(
self,
url: str,
*,
include_tags: bool = False,
include_categories: bool = False,
) -> str:
"""POST /api/recipes/create/url — Mealie scrapes the URL itself
and creates a recipe row in the caller's household. Returns the
new recipe slug. After this lands, the household's existing
sterilize+enrich pipelines will pick it up on next walk.
Mealie does its own scraping with recipe_scrapers internally; we
don't pass our scraped JSON. This keeps the import path canonical
— same code path as the user clicking "Import from URL" in
Mealie's UI."""
body = {
"url": url,
"includeTags": bool(include_tags),
"includeCategories": bool(include_categories),
}
try:
r = self.session.post(
f"{self.base_url}/api/recipes/create/url",
json=body,
timeout=60,
)
except requests.RequestException as e:
raise MealieError(f"POST /api/recipes/create/url transport: {e}") from e
if r.status_code >= 400:
raise MealieError(
f"POST /api/recipes/create/url -> {r.status_code}: {r.text[:300]}"
)
# Mealie returns the new slug as a bare JSON string
try:
slug = r.json()
except Exception:
slug = r.text.strip().strip('"')
if isinstance(slug, dict):
slug = slug.get("slug") or slug.get("id")
if not isinstance(slug, str) or not slug:
raise MealieError(f"create/url returned no slug: {r.text[:200]}")
return slug
def delete_recipe(self, slug: str) -> dict:
"""DELETE /api/recipes/<slug>. Permanently removes the recipe and
its recipe_ingredient rows. Permission-scoped per-household.
Returns Mealie's response dict (often the deleted recipe summary)."""
try:
r = self.session.delete(f"{self.base_url}/api/recipes/{slug}", timeout=30)
except requests.RequestException as e:
raise MealieError(f"DELETE /api/recipes/{slug} transport: {e}") from e
if r.status_code >= 400:
raise MealieError(f"DELETE /api/recipes/{slug} -> {r.status_code}: {r.text[:300]}")
try:
return r.json()
except Exception:
return {}
# --- foods / units ------------------------------------------------------
def list_foods(self, *, search: str | None = None, per_page: int = 200) -> dict:
return self._get("/api/foods", search=search or "", perPage=per_page)
def create_food(self, name: str, *, plural_name: str | None = None) -> dict:
body = {"name": name}
if plural_name:
body["pluralName"] = plural_name
return self._post("/api/foods", body)
def update_food(self, food_id: str, body: dict) -> dict:
return self._put(f"/api/foods/{food_id}", body)
def merge_foods(self, *, from_id: str, to_id: str) -> dict:
"""PUT /api/foods/merge — consolidates `from_id` into `to_id`. Mealie
rewrites every recipe_ingredient.food_id reference and deletes
from_id. Permission-scoped per-household."""
return self._put("/api/foods/merge", {"fromFood": from_id, "toFood": to_id})
def list_units(self, *, search: str | None = None, per_page: int = 200) -> dict:
return self._get("/api/units", search=search or "", perPage=per_page)
def create_unit(self, name: str, *, abbreviation: str | None = None) -> dict:
body = {"name": name}
if abbreviation:
body["abbreviation"] = abbreviation
return self._post("/api/units", body)
# --- meal plans / shopping (used in v0.3+) ------------------------------
def list_meal_plans(self, *, start: str, end: str) -> dict:
return self._get("/api/households/mealplans", start_date=start, end_date=end)
def list_shopping_lists(self) -> dict:
return self._get("/api/households/shopping/lists")