Food Provider Images

How food photos flow from an external provider into SparkyFitness, which providers actually supply them, and how to verify a provider yourself instead of guessing.

Support matrix

ProviderImages?How this was verified
OpenFoodFactsYes — workingLive API call, plus confirmed end-to-end in the app
MealieYes — workingConfirmed end-to-end against a live instance
TandoorYes — workingConfirmed end-to-end against a live instance
FatSecretAPI supports it, but gatedLive API call with real credentials — see FatSecret below
NutritionixExpected yesOfficial docs (photo.thumb / photo.highres); not yet verified live
YazioUnverifiedCode maps an image field; never tested against the live API
NorishUnverifiedRecipe image is mapped and resolved against the instance URL; no instance to test on
USDANoLive API call — the FDC response has no image fields at all
SwissFoodNoLive API call — no image fields in the response

Treat every "unverified" row as unproven: mapping code reading an image field proves the plumbing, not that the upstream API populates it.

Mealie, Tandoor, and Norish are self-hosted, so their images are served from the user's own instance. Mealie and Tandoor return relative media paths that are resolved against the configured base URL before download; Norish does the same. Because those URLs are private-network hosts, localizeImages may refuse them under its SSRF guard — in that case the remote URL stays in place and the browser loads it directly, which still works for a user on the same network.

The pipeline

A provider photo takes the same path regardless of provider:

  1. Provider mapper sets image_url (and optionally image_source_url for a full-size variant) on the mapped food — for example integrations/openfoodfacts/openFoodFactsService.ts.
  2. Response schema must declare the image keys. NormalizedFoodSchema in SparkyFitnessServer/schemas/foodSchemas.ts is a plain z.object(), so any key it doesn't declare is silently stripped from the search, barcode, and details responses.
  3. Search card renders it — FoodResultCard resolves images[], then the imageUrl prop, then image_url.
  4. Edit form seeds the image picker. A provider result has no images array yet, so useFoodForm falls back to image_source_url || image_url.
  5. Save payload sends the ordered images array. Both the create and update branches of api/Foods/enhancedCustomFoodFormService.ts must include it.
  6. PersistenceresolveImageInput in SparkyFitnessServer/utils/imageLocalizer.ts normalizes images / image_url / image_source_url into one array.
  7. Localization — after commit, localizeImages downloads remote http(s) URLs into /uploads/foods/<id>/ and rewrites the column. Failures are non-fatal and leave the remote URL hotlinked. Both createFood and updateFood in models/food.ts do this.

Every hop must carry the field. A break at any one of them looks identical from the UI: no image.

Re-importing an existing food

createFood in services/foodCoreService.ts de-duplicates by barcode and by provider external id. When a match is found it returns the existing row through refreshExistingExternalFoodMetadata, which backfills the provider photo only when the stored food has none. An image already on the food is the user's and is never overwritten.

This means a food imported while images were broken will pick one up on re-import, without needing to be deleted first.

The MCP / assistant path

Foods logged through the MCP server or the in-app assistant do not use the web form's save path. log_external_food in ai/tools/foodTools.ts builds its own createFood payload field by field, so image keys have to be listed there explicitly — the same trap the web create branch fell into.

MCP and the chatbot share one tool registry (routes/mcpRoutes.ts mounts it via registerRegistryTools), so a fix in foodTools.ts covers both surfaces at once.

create_food is deliberately excluded: it exists for custom and AI-estimated foods that have no provider and therefore no photo.

When adding any new food-creation entry point, prefer passing the mapped provider object through rather than re-listing fields. Every image bug so far has been a hand-enumerated payload quietly omitting image_url.

FatSecret

FatSecret images require three things, and all three must line up:

  1. Premier OAuth scope. getFatSecretAccessToken defaults to basic. getFatSecretNutrients requests premier first and falls back to basic on failure, so Basic-plan installs keep working and simply get no photo. A failed premier attempt is cached per credential for an hour so Basic accounts don't repeat the handshake on every enrichment call.
  2. The include_food_images=true parameter. Without it the API returns no image element regardless of plan. This applies to food.get.v4 and to foods.search v3/v5 alike.
  3. The images add-on enabled on the account. This is the one that cannot be solved in code. FatSecret's docs state: "Requires separate premier offering, please contact us in order for this feature to be enabled for your account."

On the plan comparison page, Food images and Allergens and Dietary Preferences both carry a ** footnote. Those two features are withheld until FatSecret provisions them, even on Premier Free.

A quick way to tell whether an account is provisioned — request all three premier flags at once:

  • include_sub_categories returning food_sub_categories proves the premier scope is working.
  • include_food_images and include_food_attributes coming back empty while sub-categories work means the account is missing the ** add-ons, not that the request is wrong.

SparkyFitness calls the v1 foods.search method, whose response has no image fields at all — it does not accept include_food_images. (The newer v3/v5 foods.search do accept it under the premier scope; we do not use them, see the warning below.) So a search result only gains an image through the detail-enrichment call, applyDetailToItem in services/externalFoodSearchService.ts, which fetches food.get.v4 per result. Only the top ENRICH_SYNC_COUNT results are enriched, so images can only ever appear on those.

Do not migrate the search to foods.search.v5. It requires premier scope and hard-fails for Basic accounts with Missing scope: scope 'premier', and it returns no images that food.get.v4 doesn't already return.

Verifying a provider yourself

Read the mapper, then call the real API — the two answer different questions.

# OpenFoodFacts: the fields= list is mandatory, images are omitted without it
curl -s -A "SparkyFitness/1.0 (https://github.com/CodeWithCJ/SparkyFitness)" \
  "https://world.openfoodfacts.org/cgi/search.pl?search_terms=nutella&search_simple=1&action=process&json=1&page_size=1&fields=product_name,image_front_url,image_url"

# USDA: confirms there is nothing to map
curl -s "https://api.nal.usda.gov/fdc/v1/foods/search?query=cheddar&pageSize=1&api_key=DEMO_KEY"

For an OAuth provider, fetch a token first, then request one known food and grep the raw JSON for image. Testing a provider's own documentation example food id is the strongest check available: if their docs show images for that id and your call doesn't, the difference is account entitlement, not code.

Diary entries own their photo

A diary entry does not display the food's photo live. It snapshots it, exactly as it snapshots nutrition:

  • models/foodEntry.ts copies the food's images onto food_entries.images when the entry is logged, and models/foodEntryMealRepository.ts does the same from the meal template.
  • Editing the food afterwards therefore does not change past entries. POST /foods/update-snapshot is the only thing that refreshes them, and it is opt-in — both clients ask "Update past entries?" after a save.
  • Migration 20260814000000_backfill_diary_entry_images_from_parent.sql backfilled rows logged before this behaviour existed. The photo an old entry was originally logged with is unrecoverable — it was never stored — so the backfill stamps the parent's current image. It freezes history going forward rather than restoring it.

Telling an inherited photo from a diary-set one

Both live in food_entries.images, so they are distinguished by upload directory rather than a flag. finalizeUploadedImages writes /uploads/<domain>/<entityId>/<file>, so:

  • /uploads/foods/… (or a remote provider URL) — inherited at log time. A sync may refresh it.
  • /uploads/food_entries/… — the user chose this photo for this entry. A sync must never touch it.

updateFoodEntriesSnapshot in models/foodMisc.ts encodes exactly that with a NOT EXISTS … LIKE '/uploads/food_entries/%' guard. If upload paths are ever restructured, that guard has to move with them.

Deleting images that history still uses

Because entries store the food's path rather than a copy of the file, dropping an image from a food would delete a file past entries still render. removeOrphanedImages checks food_entries / food_entry_meals for the path first and keeps the file when it is still referenced — and keeps it on any error too, since an unreferenced file on disk is cheaper than a broken thumbnail in someone's history.

Food deletion was already safe: the food is either hard-deleted along with its entries, or hidden (is_quick_food) with its row and images intact.

Gotchas

  • Search a branded product, not a dish. "chicken pasta" returns generic entries that have no photo on any provider. Use nutella, oreo, or a specific packaged item to actually exercise the image path. Absent images there mean the food has no photo upstream, not that something is broken.
  • Restart the server after changing provider code — integration services are server-side.
  • NormalizedFoodSchema strips undeclared keys. Adding a new image-ish field to a mapper without declaring it in the schema means it will never reach the client.
  • Local uploads are stored server-relative (/uploads/foods/<id>/...); provider images that failed to download stay absolute. resolveFoodImageSrc in the frontend handles both.