Files
Donavan Fritz 5b9afa53ff mealie: add recipe-add/edit reference covering ingredient parser
Captures the non-obvious bits of Mealie's REST API: two-step create+patch
flow, food/unit id resolution (parser returns id:null for unknown names),
locale seeding to stop the parser fuzzy-matching teaspoon→tablespoon,
required ingredientReferences:[] on instructions, and the family
meal-type tagging convention (Breakfast/Lunch/Dinner/Dessert).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:34:51 -05:00

7.5 KiB

name, description
name description
mealie Adding and editing recipes in the family Mealie instance, including the non-obvious ingredient-parsing requirements
- URL: https://meals.vino.network (Mealie v3.18.x, OIDC via Authentik) - Hosted in sjc001 cluster, `mealie` namespace - Family group/household: `Home` / `Family` - API token: stored in `code/git/code.fritzlab.net/fritzlab/agent/.env` as `MEALIE_TOKEN` (paired with `MEALIE_URL=https://meals.vino.network`). Regenerate at https://meals.vino.network/user/profile/api-tokens. - All endpoints take `Authorization: Bearer $MEALIE_TOKEN`. - **Every recipe must carry exactly one meal-type tag**: `Breakfast`, `Lunch`, `Dinner`, or `Dessert`. When importing or editing a recipe, add the appropriate one; preserve any other tags already on the recipe. If meal type is ambiguous (e.g. banana bread → breakfast or dessert), ask the user — don't pick silently. - Meal-type tag IDs in the family Mealie: - `Breakfast` = `38fb71bf-a89d-45ee-9d21-0b1e5f716569` - `Lunch` = `5f0ce972-2243-4ef6-b3c7-ad24f8561e84` - `Dinner` = `bc992ba9-80ce-4407-8d1e-e69a7f659ebb` - `Dessert` = `e7adc953-3f23-4032-804b-ada18b3a2da9` - To append a tag without clobbering: GET the recipe, take `tags`, append the new `{id, name, slug}` object, PATCH back with `{"name": , "tags": [...]}`. GET /api/users/self — confirms the token works and returns group/household. Mealie scrapes recipe sites that publish schema.org Recipe JSON-LD. POST /api/recipes/create/html-or-json with `{"url":"https://..."}` — returns the new slug. Falls back to scraping HTML if no JSON-LD. PDFs and image URLs do NOT work here. Two-step: create then patch. Mealie has no single endpoint that takes a full recipe at once.
  1. POST /api/recipes body {"name": "..."} Returns the slug as a raw JSON string (e.g. "my-recipe"), not an object. If a recipe with the same name exists, Mealie auto-suffixes (-1, -2). To avoid duplicates, GET /api/recipes first and match by name before posting.

  2. PATCH /api/recipes/{slug} with the full Recipe-Input body:

    • name, description, prepTime, cookTime, totalTime (free strings)
    • recipeYield (string), recipeServings (number), recipeYieldQuantity (number)
    • recipeIngredient: [...] — see ingredient-parsing below
    • recipeInstructions: [{text, ingredientReferences: []}, ...]
    • tags: [{id, name, slug}, ...], recipeCategory: [{id, name, slug}, ...]
    • notes: [{title, text}, ...]
    • orgURL: "..." — source link
    • DO NOT include slug in the PATCH body; it triggers a rename attempt that conflicts with the current slug and returns 400 "Recipe already exists".
Same as step 2 above: PATCH /api/recipes/{slug} with the fields to change. Other fields are preserved. To replace the entire ingredient list, send the full new `recipeIngredient` array. To clear a list, send `[]`.

DELETE /api/recipes/{slug} removes the recipe.

Mealie has an NLP parser (also `brute` and `openai` variants) that splits raw strings like `"1½ tablespoons mayo"` into structured `{quantity, unit, food, note}` objects. The unit and food are objects with their own `id` referencing rows in `/api/units` and `/api/foods`.

POST /api/parser/ingredients body: {"parser":"nlp", "ingredients":["1½ tablespoons mayo", "4 ears fresh corn, husked", ...]} returns one ParsedIngredient per input.

Parser gotchas that bite every time:

  1. Unit / food objects with id: null cannot be PATCHed onto a recipe — server raises ValueError: Expected 'id' to be provided for unit/food. The parser returns id: null whenever the unit/food name isn't already in the database. Workflow: after parsing, resolve each name against /api/units and /api/foods (auto-creating missing ones via POST), then set ing.unit.id and ing.food.id before PATCHing the recipe.

  2. Seed the locale before parsing or the parser fuzzy-matches against whatever you created previously. Concrete bite: with only tablespoon in the unit table, every "1 teaspoon" parses to unit_id = <tablespoon's id> because the Levenshtein distance is 1. Pre-seed once per instance:

    • POST /api/groups/seeders/units {"locale":"en-US"}
    • POST /api/groups/seeders/foods {"locale":"en-US"} These populate the standard US units (cup, teaspoon, tablespoon, ounce, etc.) and common foods. After seeding the parser stops conflating teaspoon/tablespoon. Already done on the family Mealie 2026-05-22.
  3. Sections in an ingredient list (e.g. "Cookie dough" vs "Buttercream" in the same recipe): set title on the FIRST ingredient of each section. Convention used in the family import script: prefix the raw input with [Section Name], the helper strips the bracket and promotes it to title on the first ingredient of that group.

  4. Instructions also have a hidden required field: each step object must include ingredientReferences: []. The OpenAPI schema says it defaults to [], but PATCH's model_dump(exclude_unset=True) excludes it and the SQLAlchemy model then raises RecipeInstruction.__init__() missing 1 required positional argument: 'ingredient_references'. Always send ingredientReferences: [] explicitly.

RecipeTag and RecipeCategory objects in a PATCH body need **all three** of `id`, `name`, `slug`. Sending just name+slug produces a misleading 400 "Recipe already exists" (the real cause is a SQL integrity error logged server-side as `SQL Integrity Error on recipe controller action`).

Create with POST /api/organizers/tags or POST /api/organizers/categories — body {"name":"..."} — response contains the id and slug to reuse.

Existing IDs in the family Mealie:

  • Tag Mexican Street Eats Class = bb6841c5-3b6d-4569-b707-3d14bea83d7b
  • Category Mexican = 6ee64be6-83ce-4d6e-926c-62edfb791a41
- `400 "Recipe already exists"` on PATCH → almost always means tag/category/food/unit is missing an `id`. Check the server logs (`kubectl --context sjc001 -n mealie logs -l app=mealie`) for `SQL Integrity Error` to confirm. - `500 TypeError` on PATCH → missing `ingredientReferences: []` on an instruction. - `500 ValueError: Expected 'id' to be provided for unit` → parsed ingredient still has `unit.id: null` or `food.id: null`; resolve via /api/units, /api/foods first. - `500` on POST /api/foods or /api/units when the name already exists → list and dedupe before creating, or catch the UniqueViolation. A working ingest script for the Mexican Street Eats PDF lives at `/tmp/add_mealie_recipes.py` during the 2026-05-22 session. Pattern to reuse:
  1. Build food + unit name→id caches once (GET /api/foods, /api/units, paginate).
  2. For each raw ingredient line, POST to /api/parser/ingredients.
  3. For each parsed result with unit.id: null or food.id: null, look up by lowercased name in the cache; POST to create if missing; update cache.
  4. For sectioned recipes, strip [Section] prefix and set title on first item.
  5. Build PATCH body with recipeIngredient, recipeInstructions (each with ingredientReferences: []), tags and recipeCategory (with ids).
  6. POST to /api/recipes for the name, then PATCH /api/recipes/{slug} with the body.