191 lines
8.6 KiB
Python
191 lines
8.6 KiB
Python
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()
|