#!/usr/bin/env python3 """ Pak Jabit — Photoroom return leg. The missing half of photoroom_pipeline.py: takes a source image, runs it through Photoroom, and pushes the result back onto the Spree product as a media asset. source jpg -> Photoroom /v2/edit -> Spree POST /products/:id/media -> verify Verification is not optional here. Spree's media#create has a url path that enqueues a background job and answers `head :accepted` — a 202 with no body and no way to know if the image ever landed. This uses the multipart path, which returns 201 plus the serialized asset, and then re-reads the product's media to confirm the asset id is really there. Usage: photoroom_to_spree.py --slug 19-electro-denim-lab-chino-pants --live photoroom_to_spree.py --slug --position 1 # make it the thumbnail photoroom_to_spree.py --slug --dry-run Keys come from /root/.photoroom/credentials. Sandbox is the default and watermarks every image; --live uses the paid allowance (10/month) and is the only way to get a clean result onto a live storefront. """ import argparse import json import mimetypes import pathlib import sys import urllib.error import urllib.request import uuid import os as _os def _spree_admin_key() -> str: """The Spree admin API key. Required — never defaulted.""" key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() if not key: raise SystemExit( "SPREE_ADMIN_KEY is not set.\n" " export SPREE_ADMIN_KEY=\n" "It is deliberately not stored in this file — see README.md." ) return key SPREE_API = "https://api.jux-lab.com/api/v3/admin" SPREE_KEY = _spree_admin_key() PHOTOROOM_EDIT = "https://image-api.photoroom.com/v2/edit" CREDS = pathlib.Path("/root/.photoroom/credentials") SOURCE_ROOT = pathlib.Path( "/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products" ) def fatal(msg): print(f"FATAL: {msg}", file=sys.stderr) sys.exit(1) def load_key(live: bool) -> str: if not CREDS.exists(): fatal(f"{CREDS} missing — no API key to use") want = "PHOTOROOM_LIVE_KEY" if live else "PHOTOROOM_SANDBOX_KEY" for line in CREDS.read_text().splitlines(): if line.startswith(want + "="): return line.split("=", 1)[1].strip() fatal(f"{want} not found in {CREDS}") def multipart(fields: dict, file_field: str, filename: str, blob: bytes, content_type: str): boundary = uuid.uuid4().hex parts = [] for k, v in fields.items(): parts.append( f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode() ) parts.append( f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; ' f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'.encode() + blob + b"\r\n" ) parts.append(f"--{boundary}--\r\n".encode()) return b"".join(parts), f"multipart/form-data; boundary={boundary}" def spree_get(path): req = urllib.request.Request( f"{SPREE_API}{path}", headers={"X-Spree-Api-Key": SPREE_KEY} ) with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read()) def find_product(slug): data = spree_get(f"/products?filter[slug_eq]={slug}&per_page=50") for p in data.get("data", []): if p.get("slug") == slug or p.get("attributes", {}).get("slug") == slug: return p # The filter key varies by Spree build; fall back to a full scan rather than # reporting "not found" for a product that is actually there. page = 1 while True: d = spree_get(f"/products?per_page=100&page={page}") for p in d.get("data", []): if p.get("slug") == slug: return p if page >= d.get("meta", {}).get("pages", 1): return None page += 1 def photoroom(img_path: pathlib.Path, key: str, size: str, bg: str) -> bytes: body, ctype = multipart( {"background.color": bg, "outputSize": size, "padding": "0.05"}, "imageFile", img_path.name, img_path.read_bytes(), mimetypes.guess_type(img_path.name)[0] or "image/jpeg", ) req = urllib.request.Request( PHOTOROOM_EDIT, data=body, headers={"x-api-key": key, "Content-Type": ctype} ) try: with urllib.request.urlopen(req, timeout=180) as r: data = r.read() if not data: fatal("Photoroom returned 200 with an empty body") if not r.headers.get("content-type", "").startswith("image/"): fatal(f"Photoroom returned non-image: {r.headers.get('content-type')}") return data except urllib.error.HTTPError as e: fatal(f"Photoroom HTTP {e.code}: {e.read().decode()[:300]}") def upload_to_spree(product_id: str, blob: bytes, filename: str, position): # position is always sent. Spree defaults a new asset to position 1, which # silently makes it the product thumbnail — a watermarked sandbox image went # live on the storefront that way. Callers pass the append position unless # they explicitly want the front. fields = {"type": "Spree::Image", "position": str(position)} body, ctype = multipart(fields, "attachment", filename, blob, "image/png") req = urllib.request.Request( f"{SPREE_API}/products/{product_id}/media", data=body, headers={"X-Spree-Api-Key": SPREE_KEY, "Content-Type": ctype}, method="POST", ) try: with urllib.request.urlopen(req, timeout=180) as r: return r.status, json.loads(r.read() or b"{}") except urllib.error.HTTPError as e: fatal(f"Spree HTTP {e.code}: {e.read().decode()[:400]}") def main(): ap = argparse.ArgumentParser() ap.add_argument("--slug", required=True) ap.add_argument("--image", help="source file (default: products//image-1.jpg)") ap.add_argument("--live", action="store_true", help="use the paid key (10/month, no watermark)") ap.add_argument("--position", type=int, help="1 makes it the product thumbnail. Default: appended last.") ap.add_argument("--size", default="1000x1000") ap.add_argument("--bg", default="FFFFFF") ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() src = pathlib.Path(args.image) if args.image else SOURCE_ROOT / args.slug / "image-1.jpg" if not src.exists(): fatal(f"source image not found: {src}") product = find_product(args.slug) if not product: fatal(f"no Spree product with slug {args.slug}") pid = product["id"] name = product.get("name", "?") before = spree_get(f"/products/{pid}/media").get("data", []) print(f"product : {name} ({pid})") print(f"source : {src} ({src.stat().st_size:,} bytes)") print(f"media : {len(before)} existing") print(f"key : {'LIVE (consumes paid quota)' if args.live else 'sandbox (watermarked)'}") if args.dry_run: print("\n--dry-run: nothing sent.") return key = load_key(args.live) position = args.position if args.position is not None else len(before) + 1 print(f"\nprocessing... (will land at position {position}" f"{' — THUMBNAIL' if position == 1 else ''})") out = photoroom(src, key, args.size, args.bg) print(f" photoroom ok: {len(out):,} bytes png") status, resp = upload_to_spree(pid, out, f"{args.slug}-photoroom.png", position) asset_id = resp.get("data", resp).get("id") print(f" spree upload: HTTP {status} asset={asset_id} position={position}") # Re-read rather than trusting the response. A 201 is not proof the asset is # attached and serving. after = spree_get(f"/products/{pid}/media").get("data", []) ids = [m.get("id") for m in after] if asset_id and asset_id in ids: url = next((m.get("original_url") or m.get("url") for m in after if m.get("id") == asset_id), None) print(f"\ncheck ok: media {len(before)} -> {len(after)}, asset present") if url: print(f"url: {url}") else: print(f"\nCHECK FAILED: asset {asset_id} not in product media after upload", file=sys.stderr) print(f" media ids: {ids}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()