Skip to content

Mealie

Mealie is the recipe and meal-planning app on the personal VM, port 9925. It drives the weekly dinner plan and the grocery list. Long-lived JWT auth, Authorization: Bearer $MEALIE_TOKEN.

Three gotchas that cost real debugging

  1. List endpoints return items, not results — {items:[...], total, total_pages}. Page until page >= total_pages.
  2. Recipe URL slug is slugify(name), not a free-form key. Mealie derives the slug from the recipe name. Look a recipe up at /api/recipes/<slugify(name)>. After a create 500, re-GET by the name-derived slug, never by the 500 text body.
  3. Shopping items are generated from recipe ingredients, not free-text. POST /api/households/shopping/items with a name 500s and does not commit, leaving name:null junk. The working path is POST /api/households/shopping/lists/{listId}/recipe/{recipeId} with body {} — it adds each ingredient with correct quantity, unit, and note, de-duped by food and with quantities summed across multiple recipe refs.

500 means success

The 500 on Mealie writes is a benign commit-then-crash. The transaction commits, then the event bus throws. Always verify by GET, never blind-retry — a retry re-triggers or slug-collides.

The event-bus bug

POST /api/recipes/create/url is broken, it hits a Pydantic Event validation error in _publish_recipe_created. It commits a shell (name, description, orgURL, servings) but drops ingredients, instructions, and image. PUT /api/recipes/{slug}/image (multipart) is broken the same way, plus a UnicodeDecodeError when the error handler tries to decode JPEG bytes as UTF-8.

What works: POST /api/recipes/test-scrape-url returns full scraped recipe JSON including the image array, and PUT /api/recipes works with a JSON array body. Media files live at data/recipes/{recipe_id}/images/original.webp and are served at GET /api/media/recipes/{recipe_id}/images/original.webp. The image field is just a label token, not the filename.

Shopping list rebuild

The weekly builder rebuilds the recipe-derived part of the list from the current plan instead of add-only. Recipe-derived quantities sum across every recipe reference, so repeated add-only runs accumulate stale quantities — the symptom is "7 packages packer brisket". Before re-adding, clear every unchecked item that has at least one recipeReferences entry. Checked items and hand-typed items are preserved.

Rebuild is slow, don't assume it crashed

A single recipe POST routinely takes over 30 seconds because of the commit-then-crash handler, and a full 7-recipe rebuild can take 3–5 minutes. A hardcoded 30-second timeout raises mid-rebuild and leaves the list partially populated. Use a 180-second timeout and run it in the background.

Kitchen constraints shape the plan

The weekly planner picks recipes against the appliances actually in the kitchen: Traeger, Weber charcoal grill, air fryer, electric stove and oven, rice maker, Instant Pot, sous vide, crockpot.

The one hard rule is safety, not convenience: no unattended oven or stovetop while the whole family is out of the house during the eating window. Only the crockpot, sous vide, and Traeger are unattended-safe. So on a night with an evening activity, dinner has to be one of:

  • cooked ahead and finished before leaving (crockpot on LOW around 12:30, done by 5:00),
  • running on an unattended-safe appliance while out,
  • or simply eaten out, which is a legitimate plan rather than a failure.

Soccer and gymnastics end at 6:00–6:15 with bedtime by 8:00, so those nights need dinner ready on arrival — Instant Pot or rice cooker started as you leave, holding on keep-warm when you walk in. A stovetop or oven recipe cannot work on those nights.

Multi-day projects get scheduled by the serve day. A brisket is a three-day thing: trim and 24-hour dry brine, overnight on the Traeger, pull and rest. The builder computes the day-1 start and writes a prep alert so the morning briefing warns you when to begin.

The 401 that wasn't Mealie's fault

Mealie was pinging gotify with 401s that looked like a broken token. It was a kiosk session expiring — tokenTime: 48 hours on the web-session JWT. The diagnosis is in the blog post The 401 spam that wasn't HA.

See also