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
| Provider | Images? | How this was verified |
|---|---|---|
| OpenFoodFacts | Yes — working | Live API call, plus confirmed end-to-end in the app |
| Mealie | Yes — working | Confirmed end-to-end against a live instance |
| Tandoor | Yes — working | Confirmed end-to-end against a live instance |
| FatSecret | API supports it, but gated | Live API call with real credentials — see FatSecret below |
| Nutritionix | Expected yes | Official docs (photo.thumb / photo.highres); not yet verified live |
| Yazio | Unverified | Code maps an image field; never tested against the live API |
| Norish | Unverified | Recipe image is mapped and resolved against the instance URL; no instance to test on |
| USDA | No | Live API call — the FDC response has no image fields at all |
| SwissFood | No | Live 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:
- Provider mapper sets
image_url(and optionallyimage_source_urlfor a full-size variant) on the mapped food — for exampleintegrations/openfoodfacts/openFoodFactsService.ts. - Response schema must declare the image keys.
NormalizedFoodSchemainSparkyFitnessServer/schemas/foodSchemas.tsis a plainz.object(), so any key it doesn't declare is silently stripped from the search, barcode, and details responses. - Search card renders it —
FoodResultCardresolvesimages[], then theimageUrlprop, thenimage_url. - Edit form seeds the image picker. A provider result has no
imagesarray yet, souseFoodFormfalls back toimage_source_url || image_url. - Save payload sends the ordered
imagesarray. Both the create and update branches ofapi/Foods/enhancedCustomFoodFormService.tsmust include it. - Persistence —
resolveImageInputinSparkyFitnessServer/utils/imageLocalizer.tsnormalizesimages/image_url/image_source_urlinto one array. - Localization — after commit,
localizeImagesdownloads remotehttp(s)URLs into/uploads/foods/<id>/and rewrites the column. Failures are non-fatal and leave the remote URL hotlinked. BothcreateFoodandupdateFoodinmodels/food.tsdo 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:
- Premier OAuth scope.
getFatSecretAccessTokendefaults tobasic.getFatSecretNutrientsrequestspremierfirst and falls back tobasicon 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. - The
include_food_images=trueparameter. Without it the API returns no image element regardless of plan. This applies tofood.get.v4and tofoods.searchv3/v5 alike. - 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_categoriesreturningfood_sub_categoriesproves the premier scope is working.include_food_imagesandinclude_food_attributescoming 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.
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.tscopies the food'simagesontofood_entries.imageswhen the entry is logged, andmodels/foodEntryMealRepository.tsdoes the same from the meal template.- Editing the food afterwards therefore does not change past entries.
POST /foods/update-snapshotis 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.sqlbackfilled 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.
NormalizedFoodSchemastrips 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.resolveFoodImageSrcin the frontend handles both.
