fix: barcode scan performance + timeout + success message

- Refactor _lookup_in_database to accept a shared httpx.AsyncClient so
  all three Open*Facts database attempts reuse one TLS connection instead
  of opening a new one per call; restores pre-fallback scan speed
- Increase recipe suggest timeout to 120s (was 30s) to survive cf-orch
  model cold-start on first request of a session
- Include product brand in barcode scan success message so the user can
  clearly see what was found (e.g. "Added: Cheerios (General Mills) to pantry")
This commit is contained in:
pyr0ball 2026-04-16 09:57:53 -07:00
parent 200a6ef87b
commit 9a277f9b42
3 changed files with 33 additions and 25 deletions

View file

@ -32,22 +32,23 @@ class OpenFoodFactsService:
"https://world.openproductsfacts.org/api/v2", "https://world.openproductsfacts.org/api/v2",
] ]
async def _lookup_in_database(self, barcode: str, base_url: str) -> Optional[Dict[str, Any]]: async def _lookup_in_database(
"""Try one Open*Facts database. Returns parsed product dict or None.""" self, barcode: str, base_url: str, client: httpx.AsyncClient
) -> Optional[Dict[str, Any]]:
"""Try one Open*Facts database using an existing client. Returns parsed product dict or None."""
try: try:
async with httpx.AsyncClient() as client: response = await client.get(
response = await client.get( f"{base_url}/product/{barcode}.json",
f"{base_url}/product/{barcode}.json", headers={"User-Agent": self.USER_AGENT},
headers={"User-Agent": self.USER_AGENT}, timeout=10.0,
timeout=10.0, )
) if response.status_code == 404:
if response.status_code == 404: return None
return None response.raise_for_status()
response.raise_for_status() data = response.json()
data = response.json() if data.get("status") != 1:
if data.get("status") != 1: return None
return None return self._parse_product_data(data, barcode)
return self._parse_product_data(data, barcode)
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.debug("HTTP error for %s at %s: %s", barcode, base_url, e) logger.debug("HTTP error for %s at %s: %s", barcode, base_url, e)
return None return None
@ -59,22 +60,26 @@ class OpenFoodFactsService:
""" """
Look up a product by barcode, trying OFFs then fallback databases. Look up a product by barcode, trying OFFs then fallback databases.
A single httpx.AsyncClient is created for the whole lookup chain so that
connection pooling and TLS session reuse apply across all database attempts.
Args: Args:
barcode: UPC/EAN barcode (8-13 digits) barcode: UPC/EAN barcode (8-13 digits)
Returns: Returns:
Dictionary with product information, or None if not found in any database. Dictionary with product information, or None if not found in any database.
""" """
result = await self._lookup_in_database(barcode, self.BASE_URL) async with httpx.AsyncClient() as client:
if result: result = await self._lookup_in_database(barcode, self.BASE_URL, client)
return result
for db_url in self._FALLBACK_DATABASES:
result = await self._lookup_in_database(barcode, db_url)
if result: if result:
logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
return result return result
for db_url in self._FALLBACK_DATABASES:
result = await self._lookup_in_database(barcode, db_url, client)
if result:
logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
return result
logger.info("Barcode %s not found in any Open*Facts database", barcode) logger.info("Barcode %s not found in any Open*Facts database", barcode)
return None return None

View file

@ -454,7 +454,7 @@ import { storeToRefs } from 'pinia'
import { useInventoryStore } from '../stores/inventory' import { useInventoryStore } from '../stores/inventory'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { inventoryAPI } from '../services/api' import { inventoryAPI } from '../services/api'
import type { InventoryItem, BarcodeScanResponse } from '../services/api' import type { InventoryItem } from '../services/api'
import { formatQuantity } from '../utils/units' import { formatQuantity } from '../utils/units'
import EditItemModal from './EditItemModal.vue' import EditItemModal from './EditItemModal.vue'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
@ -716,9 +716,11 @@ async function handleScannerGunInput() {
const item = result.results[0] const item = result.results[0]
if (item?.added_to_inventory) { if (item?.added_to_inventory) {
const productName = item.product?.name || 'item'
const productBrand = item.product?.brand ? ` (${item.product.brand})` : ''
scannerResults.value.push({ scannerResults.value.push({
type: 'success', type: 'success',
message: `Added: ${item.product?.name || 'item'} to ${scannerLocation.value}`, message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
}) })
await refreshItems() await refreshItems()
} else if (item?.needs_manual_entry) { } else if (item?.needs_manual_entry) {

View file

@ -585,7 +585,8 @@ export interface BuildRequest {
export const recipesAPI = { export const recipesAPI = {
async suggest(req: RecipeRequest): Promise<RecipeResult> { async suggest(req: RecipeRequest): Promise<RecipeResult> {
const response = await api.post('/recipes/suggest', req) // Allow up to 120s — cf-orch model cold-start can take 60+ seconds on first request
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
return response.data return response.data
}, },
async getRecipe(id: number): Promise<RecipeSuggestion> { async getRecipe(id: number): Promise<RecipeSuggestion> {