- 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
202 lines
7.8 KiB
Python
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")
|