mirror of
https://github.com/Andre-Bzk/Rezept0r.git
synced 2026-11-15 09:33:05 +01:00
Prototyp
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""SQLite-based recipe history."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
settings = get_settings()
|
||||
return Path(settings.tmp_dir).parent / "history.db"
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
db = _db_path()
|
||||
db.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recipe_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
extracted_at TEXT NOT NULL,
|
||||
title TEXT,
|
||||
source_url TEXT,
|
||||
image_url TEXT,
|
||||
recipe_json TEXT NOT NULL,
|
||||
imported_at TEXT,
|
||||
tandoor_recipe_id INTEGER
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def save_recipe(extract_data: dict) -> int:
|
||||
recipe = extract_data.get("recipe", {})
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with sqlite3.connect(_db_path()) as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO recipe_history
|
||||
(extracted_at, title, source_url, image_url, recipe_json)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(
|
||||
now,
|
||||
recipe.get("title"),
|
||||
recipe.get("source_url"),
|
||||
recipe.get("image_url"),
|
||||
json.dumps(extract_data),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_all() -> list[dict]:
|
||||
with sqlite3.connect(_db_path()) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM recipe_history ORDER BY extracted_at DESC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
entry = dict(r)
|
||||
entry["recipe_data"] = json.loads(entry.pop("recipe_json"))
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def delete_recipe(history_id: int) -> None:
|
||||
with sqlite3.connect(_db_path()) as conn:
|
||||
conn.execute("DELETE FROM recipe_history WHERE id = ?", (history_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def mark_imported(history_id: int, tandoor_recipe_id: Optional[int]) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with sqlite3.connect(_db_path()) as conn:
|
||||
conn.execute(
|
||||
"UPDATE recipe_history SET imported_at = ?, tandoor_recipe_id = ? WHERE id = ?",
|
||||
(now, tandoor_recipe_id, history_id),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""3-Tier nutrition strategy: extracted → USDA FoodData → GPT-4 estimate."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Ingredient, NutritionInfo, Recipe
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
USDA_API_URL = "https://api.nal.usda.gov/fdc/v1"
|
||||
MIN_USDA_COVERAGE = 0.5 # require ≥50% ingredients resolved
|
||||
|
||||
|
||||
async def enrich_nutrition(recipe: Recipe) -> Recipe:
|
||||
"""Enrich recipe with nutrition info using 3-tier fallback."""
|
||||
# Tier 1: Already extracted from source
|
||||
if recipe.nutrition and recipe.nutrition.kcal_per_serving:
|
||||
return recipe
|
||||
|
||||
if not recipe.ingredients:
|
||||
return recipe
|
||||
|
||||
servings = recipe.servings or 1
|
||||
|
||||
# Tier 2: USDA FoodData
|
||||
try:
|
||||
nutrition = await _usda_lookup(recipe.ingredients, servings)
|
||||
if nutrition and nutrition.kcal_per_serving:
|
||||
recipe = recipe.model_copy(update={"nutrition": nutrition})
|
||||
return recipe
|
||||
except Exception as e:
|
||||
log.warning("USDA lookup failed: %s", e)
|
||||
|
||||
# Tier 3: GPT-4 estimate (only if OpenAI key configured)
|
||||
settings = get_settings()
|
||||
if settings.openai_api_key:
|
||||
try:
|
||||
from app.services.openai_service import estimate_nutrition_gpt
|
||||
nutrition = await estimate_nutrition_gpt(recipe.ingredients, servings)
|
||||
recipe = recipe.model_copy(update={"nutrition": nutrition})
|
||||
except Exception as e:
|
||||
log.warning("GPT-4 nutrition estimate failed: %s", e)
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
async def _usda_lookup(ingredients: list[Ingredient], servings: int) -> Optional[NutritionInfo]:
|
||||
"""Look up each ingredient in USDA FoodData API and sum kcal."""
|
||||
settings = get_settings()
|
||||
api_key = settings.usda_api_key or "DEMO_KEY"
|
||||
|
||||
total_kcal = 0.0
|
||||
resolved = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for ing in ingredients:
|
||||
try:
|
||||
kcal = await _usda_ingredient_kcal(client, ing, api_key)
|
||||
if kcal is not None:
|
||||
total_kcal += kcal
|
||||
resolved += 1
|
||||
except Exception as e:
|
||||
log.debug("USDA lookup for '%s' failed: %s", ing.name, e)
|
||||
|
||||
coverage = resolved / len(ingredients) if ingredients else 0
|
||||
if coverage < MIN_USDA_COVERAGE:
|
||||
log.info("USDA coverage %.0f%% < 50%%, skipping", coverage * 100)
|
||||
return None
|
||||
|
||||
kcal_per_serving = total_kcal / servings if servings else total_kcal
|
||||
return NutritionInfo(
|
||||
kcal_per_serving=round(kcal_per_serving, 1),
|
||||
kcal_total=round(total_kcal, 1),
|
||||
servings=servings,
|
||||
source="usda",
|
||||
)
|
||||
|
||||
|
||||
async def _usda_ingredient_kcal(
|
||||
client: httpx.AsyncClient,
|
||||
ing: Ingredient,
|
||||
api_key: str,
|
||||
) -> Optional[float]:
|
||||
"""Search USDA for one ingredient and return estimated kcal."""
|
||||
resp = await client.get(
|
||||
f"{USDA_API_URL}/foods/search",
|
||||
params={
|
||||
"query": ing.name,
|
||||
"dataType": "Foundation,SR Legacy",
|
||||
"pageSize": 1,
|
||||
"api_key": api_key,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
foods = data.get("foods", [])
|
||||
if not foods:
|
||||
return None
|
||||
|
||||
food = foods[0]
|
||||
# Find Energy (kcal) nutrient
|
||||
nutrients = food.get("foodNutrients", [])
|
||||
kcal_per_100g = None
|
||||
for n in nutrients:
|
||||
name = (n.get("nutrientName") or "").lower()
|
||||
unit = (n.get("unitName") or "").upper()
|
||||
if "energy" in name and unit == "KCAL":
|
||||
kcal_per_100g = n.get("value")
|
||||
break
|
||||
|
||||
if kcal_per_100g is None:
|
||||
return None
|
||||
|
||||
grams = _estimate_grams(ing)
|
||||
if grams is None:
|
||||
return None
|
||||
|
||||
return kcal_per_100g * grams / 100
|
||||
|
||||
|
||||
def _estimate_grams(ing: Ingredient) -> Optional[float]:
|
||||
"""Convert ingredient amount + unit to grams (approximate)."""
|
||||
if not ing.amount:
|
||||
return None
|
||||
|
||||
amount_val = _parse_float(ing.amount)
|
||||
if amount_val is None:
|
||||
return None
|
||||
|
||||
unit = (ing.unit or "").lower().strip()
|
||||
|
||||
# Direct weight
|
||||
if unit in ("g", "gr", "gramm", "gram"):
|
||||
return amount_val
|
||||
if unit in ("kg", "kilogramm"):
|
||||
return amount_val * 1000
|
||||
if unit in ("mg", "milligramm"):
|
||||
return amount_val / 1000
|
||||
|
||||
# Volume → grams (approximate, assuming water density)
|
||||
if unit in ("ml", "milliliter", "millilitre"):
|
||||
return amount_val
|
||||
if unit in ("l", "liter", "litre"):
|
||||
return amount_val * 1000
|
||||
if unit in ("el", "esslöffel", "tbsp", "tablespoon"):
|
||||
return amount_val * 15
|
||||
if unit in ("tl", "teelöffel", "tsp", "teaspoon"):
|
||||
return amount_val * 5
|
||||
if unit in ("cup", "cups", "tasse"):
|
||||
return amount_val * 240
|
||||
|
||||
# Count → fallback average
|
||||
if unit in ("stück", "stk", "piece", "pieces", "", "pcs"):
|
||||
return amount_val * 100 # rough average
|
||||
|
||||
# Unknown unit → assume grams
|
||||
return amount_val
|
||||
|
||||
|
||||
def _parse_float(s: str) -> Optional[float]:
|
||||
if not s:
|
||||
return None
|
||||
# Handle fractions like "1/2"
|
||||
frac = re.match(r"^\s*(\d+)\s*/\s*(\d+)\s*$", s)
|
||||
if frac:
|
||||
return int(frac.group(1)) / int(frac.group(2))
|
||||
# Handle mixed like "1 1/2"
|
||||
mixed = re.match(r"^\s*(\d+)\s+(\d+)\s*/\s*(\d+)\s*$", s)
|
||||
if mixed:
|
||||
return int(mixed.group(1)) + int(mixed.group(2)) / int(mixed.group(3))
|
||||
# Range like "2-3" → take first
|
||||
range_m = re.match(r"^\s*([\d.]+)\s*[-–]\s*([\d.]+)\s*$", s)
|
||||
if range_m:
|
||||
return float(range_m.group(1))
|
||||
# Plain float
|
||||
plain = re.search(r"[\d.]+", s)
|
||||
if plain:
|
||||
try:
|
||||
return float(plain.group())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
@@ -0,0 +1,183 @@
|
||||
"""OpenAI integration: Whisper transcription + GPT-4o-mini recipe extraction."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from openai import AsyncOpenAI, AuthenticationError, RateLimitError, APIConnectionError
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Ingredient, NutritionInfo, Recipe
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_RECIPE_SYSTEM_PROMPT = """Du bist ein Rezept-Extraktor. Extrahiere das Rezept aus dem Text und antworte NUR mit einem JSON-Objekt (kein Markdown).
|
||||
|
||||
JSON-Schema:
|
||||
{
|
||||
"title": "string",
|
||||
"description": "string oder null",
|
||||
"servings": integer oder null,
|
||||
"prep_time_minutes": integer oder null,
|
||||
"cook_time_minutes": integer oder null,
|
||||
"ingredients": [
|
||||
{"name": "string", "amount": "string oder null", "unit": "string oder null", "note": "string oder null"}
|
||||
],
|
||||
"steps": ["string"],
|
||||
"kcal_per_serving": number oder null,
|
||||
"tags": ["string"]
|
||||
}
|
||||
|
||||
Regeln:
|
||||
- amount ist immer ein String (z.B. "200", "1/2", "2-3")
|
||||
- unit ist die Maßeinheit (g, ml, EL, TL, Stück, etc.)
|
||||
- Wenn keine Kalorien erkennbar sind, setze kcal_per_serving auf null
|
||||
- steps sind vollständige Zubereitungsschritte
|
||||
- tags: maximal 5 relevante Schlagwörter
|
||||
"""
|
||||
|
||||
_CAPTION_CHECK_PROMPT = """Enthält der folgende Text ein vollständiges Rezept mit Zutaten UND Zubereitungsschritten?
|
||||
Antworte NUR mit einem JSON-Objekt: {"has_recipe": true} oder {"has_recipe": false}
|
||||
|
||||
Text:
|
||||
"""
|
||||
|
||||
|
||||
def _get_client() -> AsyncOpenAI:
|
||||
return AsyncOpenAI(api_key=get_settings().openai_api_key)
|
||||
|
||||
|
||||
def _openai_error(e: Exception) -> RuntimeError:
|
||||
if isinstance(e, AuthenticationError):
|
||||
return RuntimeError("OpenAI API-Schlüssel ungültig oder abgelaufen.")
|
||||
if isinstance(e, RateLimitError):
|
||||
return RuntimeError("OpenAI Rate-Limit erreicht. Bitte kurz warten und erneut versuchen.")
|
||||
if isinstance(e, APIConnectionError):
|
||||
return RuntimeError("OpenAI nicht erreichbar. Internetverbindung prüfen.")
|
||||
return RuntimeError(f"OpenAI-Fehler: {e}")
|
||||
|
||||
|
||||
async def check_caption_has_recipe(text: str) -> bool:
|
||||
"""Check if a video description/caption contains a complete recipe."""
|
||||
client = _get_client()
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "user", "content": _CAPTION_CHECK_PROMPT + text[:3000]}
|
||||
],
|
||||
max_tokens=20,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
data = json.loads(resp.choices[0].message.content or "{}")
|
||||
return bool(data.get("has_recipe", False))
|
||||
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
|
||||
raise _openai_error(e) from e
|
||||
except Exception as e:
|
||||
log.warning("Caption check failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def transcribe_audio(audio_path: Path) -> str:
|
||||
"""Transcribe audio file with Whisper API."""
|
||||
client = _get_client()
|
||||
try:
|
||||
with open(audio_path, "rb") as f:
|
||||
transcript = await client.audio.transcriptions.create(
|
||||
model="whisper-1",
|
||||
file=f,
|
||||
language="de",
|
||||
response_format="text",
|
||||
)
|
||||
return str(transcript)
|
||||
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
|
||||
raise _openai_error(e) from e
|
||||
|
||||
|
||||
async def extract_recipe_from_text(text: str, source_url: str) -> Recipe:
|
||||
"""Use GPT-4o-mini to extract a structured recipe from text."""
|
||||
client = _get_client()
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": _RECIPE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": text[:12000]},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.1,
|
||||
)
|
||||
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
|
||||
raise _openai_error(e) from e
|
||||
raw = resp.choices[0].message.content or "{}"
|
||||
data = json.loads(raw)
|
||||
return _gpt_json_to_recipe(data, source_url)
|
||||
|
||||
|
||||
async def estimate_nutrition_gpt(ingredients: list[Ingredient], servings: int) -> NutritionInfo:
|
||||
"""Estimate kcal using GPT-4o-mini as last resort."""
|
||||
client = _get_client()
|
||||
ing_text = "\n".join(
|
||||
f"- {i.amount or ''} {i.unit or ''} {i.name} {('(' + i.note + ')') if i.note else ''}".strip()
|
||||
for i in ingredients
|
||||
)
|
||||
prompt = (
|
||||
f"Schätze die Gesamtkalorienzahl und Kalorien pro Portion für folgende Zutaten "
|
||||
f"({servings} Portionen):\n{ing_text}\n\n"
|
||||
'Antworte NUR mit JSON: {"kcal_total": number, "kcal_per_serving": number}'
|
||||
)
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0,
|
||||
)
|
||||
data = json.loads(resp.choices[0].message.content or "{}")
|
||||
return NutritionInfo(
|
||||
kcal_per_serving=data.get("kcal_per_serving"),
|
||||
kcal_total=data.get("kcal_total"),
|
||||
servings=servings,
|
||||
source="gpt4_estimate",
|
||||
)
|
||||
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
|
||||
log.warning("GPT nutrition estimate failed (API): %s", _openai_error(e))
|
||||
return NutritionInfo(source="gpt4_estimate")
|
||||
except Exception as e:
|
||||
log.warning("GPT nutrition estimate failed: %s", e)
|
||||
return NutritionInfo(source="gpt4_estimate")
|
||||
|
||||
|
||||
def _gpt_json_to_recipe(data: dict, source_url: str) -> Recipe:
|
||||
ingredients = [
|
||||
Ingredient(
|
||||
name=i.get("name", ""),
|
||||
amount=i.get("amount"),
|
||||
unit=i.get("unit"),
|
||||
note=i.get("note"),
|
||||
)
|
||||
for i in data.get("ingredients", [])
|
||||
if i.get("name")
|
||||
]
|
||||
|
||||
nutrition = None
|
||||
kcal = data.get("kcal_per_serving")
|
||||
if kcal:
|
||||
try:
|
||||
nutrition = NutritionInfo(kcal_per_serving=float(kcal), source="extracted")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return Recipe(
|
||||
title=data.get("title", "Unbekanntes Rezept"),
|
||||
description=data.get("description"),
|
||||
image_url=None,
|
||||
source_url=source_url,
|
||||
servings=data.get("servings"),
|
||||
prep_time_minutes=data.get("prep_time_minutes"),
|
||||
cook_time_minutes=data.get("cook_time_minutes"),
|
||||
ingredients=ingredients,
|
||||
steps=data.get("steps", []),
|
||||
nutrition=nutrition,
|
||||
tags=data.get("tags", [])[:10],
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tandoor REST API client."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import (
|
||||
Ingredient,
|
||||
Recipe,
|
||||
TandoorIngredient,
|
||||
TandoorRecipe,
|
||||
TandoorStep,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def recipe_to_tandoor(recipe: Recipe) -> TandoorRecipe:
|
||||
"""Convert internal Recipe to Tandoor-compatible import format."""
|
||||
working_time = (recipe.prep_time_minutes or 0) + (recipe.cook_time_minutes or 0)
|
||||
|
||||
# All ingredients go into step[0] (Tandoor convention)
|
||||
tandoor_ingredients = [_to_tandoor_ingredient(ing) for ing in recipe.ingredients]
|
||||
|
||||
# Build steps: first step carries all ingredients, subsequent steps are plain text
|
||||
steps: list[TandoorStep] = []
|
||||
for i, step_text in enumerate(recipe.steps):
|
||||
step = TandoorStep(
|
||||
instruction=step_text,
|
||||
ingredients=tandoor_ingredients if i == 0 else [],
|
||||
time=0,
|
||||
)
|
||||
steps.append(step)
|
||||
|
||||
if not steps:
|
||||
# If no steps, create a placeholder with all ingredients
|
||||
steps = [TandoorStep(instruction="Zubereitung", ingredients=tandoor_ingredients)]
|
||||
|
||||
keywords = [{"label": tag, "name": tag} for tag in recipe.tags]
|
||||
|
||||
servings_text = "Portionen"
|
||||
if recipe.nutrition and recipe.nutrition.kcal_per_serving:
|
||||
kcal = round(recipe.nutrition.kcal_per_serving)
|
||||
servings_text = f"Portionen (ca. {kcal} kcal)"
|
||||
|
||||
return TandoorRecipe(
|
||||
name=recipe.title,
|
||||
description=recipe.description or "",
|
||||
servings=recipe.servings or 1,
|
||||
working_time=working_time,
|
||||
source_url=recipe.source_url,
|
||||
image=recipe.image_url,
|
||||
steps=steps,
|
||||
keywords=keywords,
|
||||
servings_text=servings_text,
|
||||
)
|
||||
|
||||
|
||||
def _to_tandoor_ingredient(ing: Ingredient) -> TandoorIngredient:
|
||||
amount = 0.0
|
||||
if ing.amount:
|
||||
try:
|
||||
from app.services.nutrition import _parse_float
|
||||
val = _parse_float(ing.amount)
|
||||
amount = val if val is not None else 0.0
|
||||
except Exception:
|
||||
amount = 0.0
|
||||
|
||||
unit = {"name": ing.unit} if ing.unit else None
|
||||
|
||||
return TandoorIngredient(
|
||||
food={"name": ing.name},
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
note=ing.note or "",
|
||||
)
|
||||
|
||||
|
||||
async def import_to_tandoor(recipe: Recipe) -> dict:
|
||||
"""Import a recipe to Tandoor via REST API. Returns created recipe data."""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.tandoor_api_token:
|
||||
raise RuntimeError("TANDOOR_API_TOKEN nicht konfiguriert.")
|
||||
if not settings.tandoor_base_url:
|
||||
raise RuntimeError("TANDOOR_BASE_URL nicht konfiguriert.")
|
||||
|
||||
base_url = settings.tandoor_base_url.rstrip("/")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.tandoor_api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
tandoor_recipe = recipe_to_tandoor(recipe)
|
||||
payload = tandoor_recipe.model_dump(exclude={"image"})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
# POST recipe
|
||||
resp = await client.post(
|
||||
f"{base_url}/api/recipe/",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"Tandoor API Fehler {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
created = resp.json()
|
||||
recipe_id = created.get("id")
|
||||
|
||||
# Upload image if available
|
||||
if recipe.image_url and recipe_id:
|
||||
await _upload_image(client, base_url, recipe_id, recipe.image_url, settings.tandoor_api_token)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
async def _upload_image(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
recipe_id: int,
|
||||
image_url: str,
|
||||
token: str,
|
||||
) -> None:
|
||||
"""Download image from URL and upload to Tandoor."""
|
||||
try:
|
||||
img_resp = await client.get(image_url, follow_redirects=True, timeout=15)
|
||||
img_resp.raise_for_status()
|
||||
image_data = img_resp.content
|
||||
|
||||
# Determine content type
|
||||
content_type = img_resp.headers.get("content-type", "image/jpeg").split(";")[0]
|
||||
ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}.get(content_type, "jpg")
|
||||
|
||||
files = {"image": (f"recipe.{ext}", image_data, content_type)}
|
||||
patch_resp = await client.patch(
|
||||
f"{base_url}/api/recipe/{recipe_id}/",
|
||||
files=files,
|
||||
headers={"Authorization": f"Bearer {token}"}, # NO Content-Type here
|
||||
)
|
||||
if patch_resp.status_code not in (200, 201):
|
||||
log.warning("Image upload failed: %s", patch_resp.text[:200])
|
||||
except Exception as e:
|
||||
log.warning("Could not upload image: %s", e)
|
||||
Reference in New Issue
Block a user