from __future__ import annotations import json import mimetypes import subprocess import uuid from typing import Any from urllib import error, parse, request from app.config import settings class KitchenOwlService: def __init__(self) -> None: self.base_url = settings.kitchenowl_base_url.rstrip("/") self.token = settings.kitchenowl_api_token self.household_id = settings.kitchenowl_household_id def configured(self) -> bool: return bool(self.base_url and self.token and self.household_id) def _request(self, method: str, path: str, *, params: dict[str, Any] | None = None, body: dict[str, Any] | None = None, timeout: int = 30): url = f"{self.base_url}{path}" if params: url += ("&" if "?" in url else "?") + parse.urlencode(params, doseq=True) data = None headers = { "Authorization": f"Bearer {self.token}", "Accept": "application/json", "User-Agent": "doris-kitchen/1.0", } if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" req = request.Request(url, data=data, headers=headers, method=method.upper()) try: with request.urlopen(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8", "replace") content_type = resp.headers.get("Content-Type", "") parsed = json.loads(raw) if "json" in content_type and raw else raw return resp.status, parsed except error.HTTPError as exc: raw = exc.read().decode("utf-8", "replace") try: parsed = json.loads(raw) except Exception: parsed = raw return exc.code, parsed def _fetch_binary(self, url: str, timeout: int = 45) -> tuple[bytes, str]: req = request.Request( url, headers={ "User-Agent": "Mozilla/5.0 (Doris Kitchen)", "Accept": "image/*,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", }, ) try: with request.urlopen(req, timeout=timeout) as resp: mime_type = resp.headers.get_content_type() or "application/octet-stream" return resp.read(), mime_type except error.HTTPError as exc: if exc.code not in {403, 406, 429}: raise curl = subprocess.run( [ "curl", "-fsSL", "--max-time", str(timeout), "-A", "Mozilla/5.0 (Doris Kitchen)", "-H", "Accept: image/*,*/*;q=0.8", "-H", "Accept-Language: en-US,en;q=0.9", url, ], capture_output=True, check=False, ) if curl.returncode != 0: raise RuntimeError(f"Failed to fetch image URL: {url}") guessed = mimetypes.guess_type(url)[0] or "application/octet-stream" return curl.stdout, guessed def _upload_photo_from_url(self, photo_url: str) -> str | None: photo_url = str(photo_url or "").strip() if not photo_url.startswith(("http://", "https://")): return photo_url or None content, mime_type = self._fetch_binary(photo_url) ext = mimetypes.guess_extension(mime_type) or ".jpg" filename = f"doris-{uuid.uuid4().hex}{ext}" boundary = f"----DorisBoundary{uuid.uuid4().hex}" parts = [ f"--{boundary}\r\n".encode("utf-8"), f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode("utf-8"), f"Content-Type: {mime_type}\r\n\r\n".encode("utf-8"), content, f"\r\n--{boundary}--\r\n".encode("utf-8"), ] data = b"".join(parts) req = request.Request( f"{self.base_url}/api/upload", data=data, headers={ "Authorization": f"Bearer {self.token}", "Accept": "application/json", "Content-Type": f"multipart/form-data; boundary={boundary}", "User-Agent": "doris-kitchen/1.0", }, method="POST", ) with request.urlopen(req, timeout=60) as resp: payload = json.loads(resp.read().decode("utf-8", "replace")) return str(payload.get("filename") or "").strip() or None def list_recipes(self) -> list[dict[str, Any]]: if not self.configured(): return [] status, payload = self._request("GET", f"/api/household/{self.household_id}/recipe") if status == 200 and isinstance(payload, list): return payload return [] def find_existing(self, payload: dict[str, Any]) -> dict[str, Any] | None: want_source = str(payload.get("source") or "").strip().lower() want_name = str(payload.get("name") or "").strip().lower() for recipe in self.list_recipes(): source = str(recipe.get("source") or "").strip().lower() name = str(recipe.get("name") or "").strip().lower() if want_source and source and source == want_source: return recipe if want_name and name == want_name: return recipe return None def _recipe_update_payload(self, existing: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: uploaded_photo = self._upload_photo_from_url(payload.get("photo")) if payload.get("photo") else existing.get("photo") return { "name": payload.get("name") or existing.get("name"), "description": payload.get("description") or existing.get("description") or "", "source": payload.get("source") or existing.get("source"), "visibility": int(payload.get("visibility", existing.get("visibility", 0) or 0)), "items": payload.get("items") or [ { "name": item.get("name"), "description": item.get("description", ""), "optional": bool(item.get("optional", False)), } for item in (existing.get("items") or []) ], "tags": payload.get("tags") or [ tag.get("name") if isinstance(tag, dict) else str(tag) for tag in (existing.get("tags") or []) ], "cook_time": payload.get("cook_time", existing.get("cook_time")), "prep_time": payload.get("prep_time", existing.get("prep_time")), "time": payload.get("time", existing.get("time")), "yields": payload.get("yields", existing.get("yields")), "photo": uploaded_photo or existing.get("photo"), "planned": bool(existing.get("planned", False)), "planned_cooking_dates": existing.get("planned_cooking_dates") or [], "planned_days": existing.get("planned_days") or [], } def update_recipe(self, recipe_id: int | str, payload: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]: if not self.configured(): raise RuntimeError("KitchenOwl credentials are not configured.") if existing is None: status, existing = self._request("GET", f"/api/recipe/{recipe_id}") if status != 200 or not isinstance(existing, dict): return {"status": "error", "http_status": status, "recipe": existing} body = self._recipe_update_payload(existing, payload) status, updated = self._request("POST", f"/api/recipe/{recipe_id}", body=body, timeout=45) return {"status": "updated" if status == 200 else "error", "http_status": status, "recipe": updated} def create_recipe(self, payload: dict[str, Any]) -> dict[str, Any]: if not self.configured(): raise RuntimeError("KitchenOwl credentials are not configured.") existing = self.find_existing(payload) if existing: if payload.get("photo") and not existing.get("photo"): refreshed = self.update_recipe(existing.get("id"), payload, existing=existing) if refreshed.get("status") == "updated": return {"status": "updated", "recipe": refreshed.get("recipe")} return {"status": "duplicate", "recipe": existing} create_payload = dict(payload) if create_payload.get("photo"): create_payload["photo"] = self._upload_photo_from_url(create_payload["photo"]) status, created = self._request("POST", f"/api/household/{self.household_id}/recipe", body=create_payload, timeout=45) return {"status": "created" if status == 200 else "error", "http_status": status, "recipe": created} kitchenowl = KitchenOwlService()