"""Discogs Marketplace Manager — manage YOUR Discogs listings + orders from StoreGod (the WowPlatter version, but heaps better: one source of truth, cross-channel inventory, DealGod pricing). Uses the seller's personal access token (vault `discogs_token`) — authorises managing your own inventory/orders. Discogs rate-limits ~60/min authenticated. Marketplace API: GET /users/{u}/inventory · POST/DELETE /marketplace/listings[/{id}] · GET /marketplace/orders[/{id}] · GET /marketplace/price_suggestions/{release_id}. Condition strings must be Discogs' EXACT values (mapped below). """ import httpx try: from sqlalchemy import text # guarded so the standalone condition-map selfcheck runs without sqlalchemy except ImportError: text = None DISCOGS = "https://api.discogs.com" UA = "StoreGod/1.0 +https://recordgod.com" # Goldmine/loose grades → Discogs' EXACT condition strings (the API rejects anything else) _COND = { "M": "Mint (M)", "MINT": "Mint (M)", "NM": "Near Mint (NM or M-)", "M-": "Near Mint (NM or M-)", "NEARMINT": "Near Mint (NM or M-)", "VG+": "Very Good Plus (VG+)", "VGPLUS": "Very Good Plus (VG+)", "VG +": "Very Good Plus (VG+)", "VG": "Very Good (VG)", "G+": "Good Plus (G+)", "GPLUS": "Good Plus (G+)", "G": "Good (G)", "GOOD": "Good (G)", "F": "Fair (F)", "FAIR": "Fair (F)", "P": "Poor (P)", "POOR": "Poor (P)", } def discogs_condition(v, default="Very Good Plus (VG+)"): if not v: return default k = str(v).strip().upper() if k in _COND: return _COND[k] k2 = k.replace("(", "").replace(")", "").replace(" ", "") return _COND.get(k2, _COND.get(k.split()[0] if k.split() else k, default)) async def _auth(db): from . import vault tok = await vault.get_secret(db, "discogs_token") return tok async def _req(tok, method, path, **kw): async with httpx.AsyncClient(timeout=25, headers={ "Authorization": f"Discogs token={tok}", "User-Agent": UA}) as c: return await c.request(method, DISCOGS + path, **kw) async def seller(db): tok = await _auth(db) if not tok: return None r = await _req(tok, "GET", "/oauth/identity") return r.json().get("username") if r.status_code == 200 else None async def status(db): """Seller + live counts — the cockpit header.""" tok = await _auth(db) if not tok: return {"ok": False, "reason": "no Discogs token (add it in Connections)"} me = await _req(tok, "GET", "/oauth/identity") if me.status_code != 200: return {"ok": False, "reason": f"token rejected ({me.status_code})"} u = me.json()["username"] inv = await _req(tok, "GET", f"/users/{u}/inventory", params={"per_page": 1, "status": "For Sale"}) orders = await _req(tok, "GET", "/marketplace/orders", params={"per_page": 1}) return {"ok": True, "seller": u, "listings": inv.json().get("pagination", {}).get("items", 0) if inv.status_code == 200 else None, "orders": orders.json().get("pagination", {}).get("items", 0) if orders.status_code == 200 else None} async def orders(db, page=1): tok = await _auth(db) if not tok: return {"ok": False, "reason": "no token"} r = await _req(tok, "GET", "/marketplace/orders", params={"per_page": 25, "page": page, "sort": "last_activity"}) if r.status_code != 200: return {"ok": False, "reason": f"HTTP {r.status_code}"} d = r.json() return {"ok": True, "orders": [{"id": o["id"], "status": o["status"], "total": o.get("total", {}).get("value"), "buyer": (o.get("buyer") or {}).get("username"), "created": o.get("created"), "items": len(o.get("items", []))} for o in d.get("orders", [])], "pages": d.get("pagination", {}).get("pages", 1)} async def price_suggestions(db, release_id): tok = await _auth(db) if not tok: return None r = await _req(tok, "GET", f"/marketplace/price_suggestions/{release_id}") return r.json() if r.status_code == 200 else None async def create_listing(db, release_id, price, condition, sleeve=None, status="Draft", comments=None): """List one item. status='Draft' is SAFE (not live/sellable) — use it to test; 'For Sale' goes live.""" tok = await _auth(db) if not tok: return {"ok": False, "error": "no token"} body = {"release_id": int(release_id), "condition": discogs_condition(condition), "price": round(float(price), 2), "status": status} if sleeve: body["sleeve_condition"] = discogs_condition(sleeve) if comments: body["comments"] = comments r = await _req(tok, "POST", "/marketplace/listings", json=body) if r.status_code in (200, 201): return {"ok": True, "listing_id": r.json().get("listing_id")} return {"ok": False, "error": r.text[:200], "status": r.status_code} async def update_listing(db, listing_id, **fields): tok = await _auth(db) if not tok: return {"ok": False, "error": "no token"} if "condition" in fields: fields["condition"] = discogs_condition(fields["condition"]) r = await _req(tok, "POST", f"/marketplace/listings/{listing_id}", json=fields) return {"ok": r.status_code in (200, 204), "status": r.status_code} async def delete_listing(db, listing_id): tok = await _auth(db) if not tok: return {"ok": False, "error": "no token"} r = await _req(tok, "DELETE", f"/marketplace/listings/{listing_id}") return {"ok": r.status_code in (200, 204), "status": r.status_code} async def delist_skus(db, skus): """Anti-oversell: any of these just-sold skus that have a LIVE Discogs listing → delist it. Best-effort — a Discogs hiccup must NEVER fail the sale (the sale's already committed when this runs).""" skus = [s for s in (skus or []) if s] if not skus: return 0 rows = (await db.execute(text( "SELECT sku, discogs_listing_id FROM inventory WHERE sku = ANY(:s) AND discogs_listing_id IS NOT NULL"), {"s": skus})).mappings().all() n = 0 for r in rows: try: if (await delete_listing(db, r["discogs_listing_id"])).get("ok"): await db.execute(text("UPDATE inventory SET discogs_listing_id = NULL WHERE sku = :s"), {"s": r["sku"]}) n += 1 except Exception: pass if n: await db.commit() return n async def sync_orders(db): """Pull Discogs marketplace orders → mark the matching StoreGod item sold + record the sale. Idempotent (skips orders already imported via sales.discogs_order_id). The other half of anti-oversell: sell on Discogs, it comes off the StoreGod floor too.""" tok = await _auth(db) if not tok: return {"ok": False, "reason": "no token"} r = await _req(tok, "GET", "/marketplace/orders", params={"per_page": 50, "sort": "last_activity", "sort_order": "desc"}) if r.status_code != 200: return {"ok": False, "reason": f"HTTP {r.status_code}"} seen = {str(x[0]) for x in (await db.execute( text("SELECT discogs_order_id FROM sales WHERE discogs_order_id IS NOT NULL"))).all()} imported = [] for o in r.json().get("orders", []): oid = str(o["id"]) if oid in seen: continue lines, total = [], 0.0 for it in o.get("items", []): lid = it.get("id") or (it.get("listing") or {}).get("id") rid = (it.get("release") or {}).get("id") price = float((it.get("price") or {}).get("value") or 0) total += price inv = (await db.execute(text( "SELECT sku, title FROM inventory WHERE (discogs_listing_id = :l OR (:l IS NULL AND release_id = :r AND in_stock)) " "AND store_id = 1 LIMIT 1"), {"l": lid, "r": rid})).mappings().first() lines.append({"sku": inv["sku"] if inv else None, "rid": rid, "price": price, "title": (inv and inv["title"]) or (it.get("release") or {}).get("description")}) ototal = float((o.get("total") or {}).get("value") or total) st = o.get("status") or "" paid = "paid" if any(k in st for k in ("Payment Received", "Shipped", "Merged")) else "pending" sale_id = (await db.execute(text(""" INSERT INTO sales (sale_number, subtotal, tax_amount, total, status, payment_method, payment_status, amount_paid, discogs_order_id, notes, sale_date, created_at) VALUES (:sn, :t, 0, :t, 'publish', 'discogs', :ps, :t, :oid, :notes, now(), now()) RETURNING id"""), {"sn": f"DG{oid}", "t": ototal, "ps": paid, "oid": oid, "notes": f"Discogs order {oid} · {st} · buyer {(o.get('buyer') or {}).get('username') or '?'}"})).first()[0] matched = 0 for ln in lines: await db.execute(text("""INSERT INTO sale_items (sale_id, sku, release_id, item_name, qty, unit_price, line_total) VALUES (:s, :sku, :rid, :nm, 1, :p, :p)"""), {"s": sale_id, "sku": ln["sku"], "rid": ln["rid"], "nm": ln["title"], "p": ln["price"]}) if ln["sku"]: await db.execute(text("UPDATE inventory SET in_stock=false, status='sold', sold_date=now(), " "discogs_listing_id=NULL WHERE sku=:s AND store_id=1"), {"s": ln["sku"]}) matched += 1 imported.append({"order": oid, "status": st, "items": len(lines), "matched": matched}) await db.commit() return {"ok": True, "count": len(imported), "imported": imported} def _selfcheck(): assert discogs_condition("VG+") == "Very Good Plus (VG+)" assert discogs_condition("nm") == "Near Mint (NM or M-)" assert discogs_condition("Mint (M)") == "Mint (M)" assert discogs_condition(None) == "Very Good Plus (VG+)" assert discogs_condition("garbage") == "Very Good Plus (VG+)" # safe default print("ok") if __name__ == "__main__": _selfcheck()