feat(intake): three-source intake — internal lookup + Discogs collection + Google Sheet

Replaces the bare release-id box with a tabbed Intake that all feeds one _stage() core:
- Find & stage: search the local Discogs mirror (instant) with a Discogs-API fallback for new
  titles; pick a release → condition/price → stage. Cards show cover/artist/year/label/format.
- Discogs collection: lists the OAuth token's own folders (mrp3, 152 with stock) → page through
  releases, reads media/sleeve/price from the collection's custom fields, stage-all-on-page.
- Google Sheet: paste the share URL → public CSV export (no Google creds) → auto-detect columns
  (release id/media/sleeve/price/comment/timestamp) → preview → bulk import. SKU from the
  Google-Form timestamp (YYYYMMDDHHMMSS), matching migrated stock.

Staging enriches title/artist/cover on demand: a disc_cache miss fetches the release from the
Discogs API and grows the local mirror (disc_cache + disc_release + artist + label), so internal
search + storefront get richer with every intake. Verified end-to-end on the VPS (incl. enrich of
a not-in-cache release) + sheet-parser self-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-24 10:05:46 +10:00
parent 5ad4de1d2c
commit 88172924eb
3 changed files with 547 additions and 15 deletions

421
app/intake_routes.py Normal file
View File

@ -0,0 +1,421 @@
import asyncio
import base64
import csv
import io
import json
import re
import secrets
import httpx
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import text
from . import vault
from .auth import require_token
from .db import get_db
# Intake — stage new stock from three sources, all feeding one _stage() core:
# 1. internal lookup over the local Discogs mirror (the daily driver) + Discogs-API fallback
# 2. a Discogs collection folder (OAuth token's own collection)
# 3. a Google Sheet (public CSV export — no Google creds; SKU from the form timestamp)
# Staging enriches title/artist/cover-art on demand; a cache miss fetches the release from the
# Discogs API and grows the local mirror (release + artist + label + thumb).
router = APIRouter(prefix="/admin/intake", tags=["intake"])
DISCOGS = "https://api.discogs.com"
UA = "RecordGod/0.1 +https://recordgod.com"
def _new_sku() -> str:
return "MRP-" + base64.b32encode(secrets.token_bytes(5)).decode().rstrip("=")[:8]
async def _client(db):
tok = await vault.get_secret(db, "discogs_token")
headers = {"User-Agent": UA}
if tok:
headers["Authorization"] = f"Discogs token={tok}"
return httpx.AsyncClient(base_url=DISCOGS, headers=headers, timeout=20,
follow_redirects=True), bool(tok)
_user_cache = {}
async def _discogs_user(db):
if "u" not in _user_cache:
c, ok = await _client(db)
async with c:
if not ok:
return None
r = await c.get("/oauth/identity")
_user_cache["u"] = r.json().get("username") if r.status_code == 200 else None
return _user_cache["u"]
# --- enrichment ------------------------------------------------------------
async def _cache_hit(db, rid):
return (await db.execute(text(
"SELECT title, artist, thumb, weight FROM disc_cache WHERE release_id=:r"),
{"r": rid})).mappings().first()
async def _enrich(db, rid):
"""{title, artist, thumb, weight} for a release_id. disc_cache first; on a miss, fetch the
release from Discogs and grow the local mirror (best-effort never fails the stage)."""
if not rid:
return None
hit = await _cache_hit(db, rid)
if hit:
return dict(hit)
try:
c, ok = await _client(db)
async with c:
if not ok:
return None
r = await c.get(f"/releases/{rid}")
if r.status_code != 200:
return None
d = r.json()
except Exception:
return None
artist = ", ".join(a["name"] for a in d.get("artists", []) if a.get("name")) or None
title = d.get("title")
thumb = (d.get("images") or [{}])[0].get("uri") or d.get("thumb") or None
weight = int(d["estimated_weight"]) if d.get("estimated_weight") else None
year = d.get("year") or None
await _grow_mirror(db, d, artist, title, thumb, weight, year)
return {"title": title, "artist": artist, "thumb": thumb, "weight": weight}
async def _grow_mirror(db, d, artist, title, thumb, weight, year):
"""Upsert the fetched release + its artists/labels into disc_* so it's findable next time
and the storefront has artist/label/cover. Minimal columns; best-effort."""
rid = d["id"]
await db.execute(text("""
INSERT INTO disc_cache (release_id, title, artist, thumb, weight)
VALUES (:r,:t,:a,:th,:w) ON CONFLICT (release_id) DO UPDATE
SET title=EXCLUDED.title, artist=EXCLUDED.artist,
thumb=COALESCE(EXCLUDED.thumb, disc_cache.thumb)"""),
{"r": rid, "t": title, "a": artist, "th": thumb, "w": weight})
await db.execute(text("""
INSERT INTO disc_release (id, title, artists_sort, country, year, thumb, master_id)
VALUES (:r,:t,:a,:c,:y,:th,:m) ON CONFLICT (id) DO UPDATE
SET title=EXCLUDED.title, artists_sort=EXCLUDED.artists_sort,
thumb=COALESCE(EXCLUDED.thumb, disc_release.thumb)"""),
{"r": rid, "t": title, "a": artist, "c": d.get("country"), "y": year,
"th": thumb, "m": (d.get("master_id") or None)})
for pos, a in enumerate(d.get("artists", []) or [], 1):
if not a.get("id"):
continue
await db.execute(text(
"INSERT INTO disc_artist (id,name) VALUES (:i,:n) ON CONFLICT (id) DO NOTHING"),
{"i": a["id"], "n": a.get("name")})
await db.execute(text("""INSERT INTO disc_release_artist (release_id,artist_id,artist_name,position)
SELECT :r,:i,:n,:p WHERE NOT EXISTS (SELECT 1 FROM disc_release_artist
WHERE release_id=:r AND artist_id=:i)"""),
{"r": rid, "i": a["id"], "n": a.get("name"), "p": str(pos)})
for lab in d.get("labels", []) or []:
if not lab.get("id"):
continue
await db.execute(text(
"INSERT INTO disc_label (id,name) VALUES (:i,:n) ON CONFLICT (id) DO NOTHING"),
{"i": lab["id"], "n": lab.get("name")})
await db.execute(text("""INSERT INTO disc_release_label (release_id,label_id,label_name,catno)
SELECT :r,:i,:n,:c WHERE NOT EXISTS (SELECT 1 FROM disc_release_label
WHERE release_id=:r AND label_id=:i)"""),
{"r": rid, "i": lab["id"], "n": lab.get("name"), "c": lab.get("catno")})
# --- staging core ----------------------------------------------------------
async def _stage(db, store_id, rid, sku, condition, sleeve, price, notes, kind="vinyl",
identifier=None):
meta = await _enrich(db, rid) if rid else None
sku = sku or _new_sku()
await db.execute(text("""
INSERT INTO inventory (sku, store_id, kind, release_id, identifier, title, price,
condition, sleeve_cond, weight_g, notes, staged, status)
VALUES (:sku,:sid,:kind,:rid,:ident,:title,:price,:cond,:sleeve,:wt,:notes,true,'staged')
ON CONFLICT (sku) DO UPDATE SET
release_id=EXCLUDED.release_id, title=EXCLUDED.title, price=EXCLUDED.price,
condition=EXCLUDED.condition, sleeve_cond=EXCLUDED.sleeve_cond,
notes=EXCLUDED.notes, updated_at=now()"""),
{"sku": sku, "sid": store_id, "kind": kind, "rid": rid, "ident": identifier,
"title": (meta or {}).get("title"), "price": price, "cond": condition,
"sleeve": sleeve, "wt": (meta or {}).get("weight"), "notes": notes})
return {"sku": sku, "title": (meta or {}).get("title"),
"artist": (meta or {}).get("artist"), "enriched": bool(meta)}
class StageIn(BaseModel):
release_id: int | None = None
identifier: str | None = None
condition: str | None = "VG+"
sleeve: str | None = None
price: float | None = None
sku: str | None = None
notes: str | None = None
kind: str = "vinyl"
@router.post("/stage")
async def stage(body: StageIn, ident=Depends(require_token), db=Depends(get_db)):
res = await _stage(db, ident["store_id"], body.release_id, body.sku, body.condition,
body.sleeve, body.price, body.notes, body.kind, body.identifier)
await db.commit()
return {"ok": True, "staged": True, **res}
# --- 1. internal lookup ----------------------------------------------------
@router.get("/search")
async def search(q: str = Query(""), label: str = Query(""), year: int | None = None,
country: str = Query(""), fmt: str = Query(""),
ident=Depends(require_token), db=Depends(get_db)):
"""Search the local Discogs mirror — the fast daily path (re-stocks resolve instantly)."""
where, params = ["TRUE"], {}
if q:
where.append("(dr.title ILIKE :q OR dr.artists_sort ILIKE :q)"); params["q"] = f"%{q}%"
if year:
where.append("dr.year = :y"); params["y"] = year
if country:
where.append("dr.country ILIKE :c"); params["c"] = f"%{country}%"
if label:
where.append("EXISTS (SELECT 1 FROM disc_release_label rl WHERE rl.release_id=dr.id AND rl.label_name ILIKE :lab)")
params["lab"] = f"%{label}%"
if fmt:
where.append("EXISTS (SELECT 1 FROM disc_release_format f WHERE f.release_id=dr.id AND f.name ILIKE :fmt)")
params["fmt"] = f"%{fmt}%"
rows = [dict(r) for r in (await db.execute(text(f"""
SELECT dr.id AS release_id, dr.title, dr.artists_sort AS artist, dr.year, dr.country,
COALESCE(dc.thumb, dr.thumb) AS thumb,
(SELECT string_agg(label_name || COALESCE(' ('||catno||')',''), ', ')
FROM disc_release_label WHERE release_id=dr.id) AS label,
(SELECT string_agg(name, ', ') FROM disc_release_format WHERE release_id=dr.id) AS format,
EXISTS (SELECT 1 FROM inventory i WHERE i.release_id=dr.id AND i.in_stock AND i.store_id=1) AS in_stock
FROM disc_release dr LEFT JOIN disc_cache dc ON dc.release_id=dr.id
WHERE {' AND '.join(where)}
ORDER BY dr.title LIMIT 40"""), params)).mappings()]
return {"items": rows, "source": "local"}
@router.get("/discogs/search")
async def discogs_search(q: str = Query(...), ident=Depends(require_token), db=Depends(get_db)):
"""Fallback for brand-new titles not yet in the local mirror — Discogs API search."""
c, ok = await _client(db)
async with c:
if not ok:
return {"items": [], "source": "discogs", "error": "no Discogs token saved"}
r = await c.get("/database/search", params={"q": q, "type": "release", "per_page": 40})
if r.status_code != 200:
return {"items": [], "source": "discogs", "error": f"HTTP {r.status_code}"}
out = []
for d in r.json().get("results", []):
title = d.get("title", "")
artist, _, rest = title.partition(" - ")
out.append({"release_id": d.get("id"), "title": rest or title, "artist": artist,
"year": d.get("year"), "country": d.get("country"),
"thumb": d.get("thumb"), "label": ", ".join(d.get("label", []) or []),
"format": ", ".join(d.get("format", []) or []), "in_stock": False})
return {"items": out, "source": "discogs"}
# --- 2. Discogs collection folder -----------------------------------------
@router.get("/discogs/folders")
async def folders(ident=Depends(require_token), db=Depends(get_db)):
user = await _discogs_user(db)
if not user:
return {"folders": [], "error": "no Discogs token saved"}
c, _ = await _client(db)
async with c:
r = await c.get(f"/users/{user}/collection/folders")
fs = [{"id": f["id"], "name": f["name"], "count": f["count"]}
for f in r.json().get("folders", []) if f["count"]]
return {"user": user, "folders": fs}
async def _field_map(c, user):
"""Discogs collection custom-field ids → our slots (media/sleeve/price/notes), matched by name."""
r = await c.get(f"/users/{user}/collection/fields")
out = {}
for f in r.json().get("fields", []):
n = f.get("name", "").lower()
if "sleeve" in n or "cover" in n:
out[f["id"]] = "sleeve"
elif "media" in n or "record" in n or "grade" in n or "condition" in n:
out[f["id"]] = "media"
elif "price" in n or "$" in n:
out[f["id"]] = "price"
elif "note" in n or "comment" in n:
out[f["id"]] = "notes"
return out
@router.get("/discogs/folder/{folder_id}")
async def folder_items(folder_id: int, page: int = Query(1, ge=1),
ident=Depends(require_token), db=Depends(get_db)):
user = await _discogs_user(db)
if not user:
return {"items": [], "error": "no Discogs token saved"}
c, _ = await _client(db)
async with c:
fmap = await _field_map(c, user)
r = await c.get(f"/users/{user}/collection/folders/{folder_id}/releases",
params={"per_page": 100, "page": page, "sort": "added", "sort_order": "desc"})
j = r.json()
items = []
for it in j.get("releases", []):
bi = it.get("basic_information", {})
slots = {}
for note in it.get("notes", []) or []:
slot = fmap.get(note.get("field_id"))
if slot and note.get("value"):
slots[slot] = note["value"]
items.append({
"release_id": bi.get("id"),
"title": bi.get("title"),
"artist": ", ".join(a["name"] for a in bi.get("artists", []) if a.get("name")),
"year": bi.get("year"), "thumb": bi.get("thumb"),
"media": slots.get("media"), "sleeve": slots.get("sleeve"),
"price": slots.get("price"), "notes": slots.get("notes"),
})
pg = j.get("pagination", {})
return {"items": items, "page": pg.get("page", page), "pages": pg.get("pages", 1)}
# --- 3. Google Sheet (public CSV export) ----------------------------------
def _csv_url(url: str) -> str | None:
m = re.search(r"/spreadsheets/d/([a-zA-Z0-9-_]+)", url)
if not m:
return None
gid = (re.search(r"[#&?]gid=(\d+)", url) or [None, "0"])[1]
return f"https://docs.google.com/spreadsheets/d/{m.group(1)}/export?format=csv&gid={gid}"
def _detect(headers):
"""Map a sheet's header row → our fields by keyword (handles the Google-Form layout)."""
col = {}
for i, h in enumerate(headers):
n = (h or "").lower()
if "release" in n and ("id" in n or "discogs" in n): col.setdefault("release_id", i)
elif n in ("release_id", "releaseid", "discogs"): col.setdefault("release_id", i)
elif "sleeve" in n or "cover" in n: col.setdefault("sleeve", i)
elif "media" in n or "record cond" in n or n == "condition" or "grade" in n: col.setdefault("media", i)
elif "price" in n or "aud" in n or "$" in n: col.setdefault("price", i)
elif "sku" in n: col.setdefault("sku", i)
elif "comment" in n or "note" in n: col.setdefault("notes", i)
elif "time" in n or "stamp" in n: col.setdefault("timestamp", i)
return col
def _sku_from_ts(ts: str) -> str | None:
m = re.match(r"(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})", ts or "")
if m:
return "".join(m.groups()) # YYYYMMDDHHMMSS — matches WowPlatter / migrated stock
m = re.match(r"(\d{1,2})/(\d{1,2})/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})", ts or "")
if m:
d, mo, y, h, mi, s = m.groups()
return f"{y}{int(mo):02d}{int(d):02d}{int(h):02d}{mi}{s}"
return None
def _parse_sheet(text_csv):
rows = list(csv.reader(io.StringIO(text_csv)))
if not rows:
return [], {}
col = _detect(rows[0])
if "release_id" not in col:
return [], col
out = []
for r in rows[1:]:
def g(k):
i = col.get(k)
return r[i].strip() if i is not None and i < len(r) and r[i] else None
rid = g("release_id")
if not rid or not rid.isdigit():
continue
price = g("price")
try:
price = float(re.sub(r"[^0-9.]", "", price)) if price else None
except ValueError:
price = None
sku = g("sku") or _sku_from_ts(g("timestamp") or "")
out.append({"release_id": int(rid), "sku": sku, "price": price,
"media": g("media") or "VG+", "sleeve": g("sleeve"),
"notes": g("notes")})
return out, col
async def _fetch_sheet(url):
csv_url = _csv_url(url)
if not csv_url:
return None, "not a Google Sheets URL"
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
r = await c.get(csv_url)
if r.status_code != 200 or r.text.lstrip().startswith("<"):
return None, "sheet not public (set Share → anyone with link can view)"
return r.text, None
class SheetIn(BaseModel):
url: str
@router.post("/sheet/preview")
async def sheet_preview(body: SheetIn, ident=Depends(require_token), db=Depends(get_db)):
csv_text, err = await _fetch_sheet(body.url)
if err:
return {"ok": False, "error": err}
rows, col = _parse_sheet(csv_text)
if not rows and "release_id" not in col:
return {"ok": False, "error": "no release_id column detected", "columns": col}
return {"ok": True, "total": len(rows), "columns": col, "sample": rows[:10]}
@router.post("/sheet/import")
async def sheet_import(body: SheetIn, ident=Depends(require_token), db=Depends(get_db)):
csv_text, err = await _fetch_sheet(body.url)
if err:
return {"ok": False, "error": err}
rows, _ = _parse_sheet(csv_text)
staged = 0
for row in rows:
await _stage(db, ident["store_id"], row["release_id"], row["sku"], row["media"],
row["sleeve"], row["price"], row["notes"])
staged += 1
if staged % 25 == 0:
await db.commit()
await asyncio.sleep(0.2) # ponytail: gentle on the Discogs API for cache-miss enrich
await db.commit()
return {"ok": True, "staged": staged}
def _selfcheck():
# SKU from a Google-Form timestamp (both ISO and en-AU slash formats) → YYYYMMDDHHMMSS
assert _sku_from_ts("2025-01-30T14:01:02.821Z") == "20250130140102"
assert _sku_from_ts("30/01/2025 14:01:02") == "20250130140102"
assert _sku_from_ts("") is None
# CSV export URL derivation (id + gid)
assert _csv_url("https://docs.google.com/spreadsheets/d/ABC_123/edit#gid=42") == \
"https://docs.google.com/spreadsheets/d/ABC_123/export?format=csv&gid=42"
assert _csv_url("https://example.com/nope") is None
# header detection + row parse (the Google-Form layout)
csv_text = ("Timestamp,Discogs Release ID,Media Condition,Sleeve Condition,Price (AUD),Comment\n"
"2025-01-30T14:01:02.821Z,249504,VG+,VG,25.50,nice copy\n"
",,,,,\n" # blank → skipped
"2025-02-01T09:00:00.000Z,bad,NM,NM,10,\n") # non-numeric release_id → skipped
rows, col = _parse_sheet(csv_text)
assert col["release_id"] == 1 and col["price"] == 4, col
assert len(rows) == 1, rows
assert rows[0] == {"release_id": 249504, "sku": "20250130140102", "price": 25.5,
"media": "VG+", "sleeve": "VG", "notes": "nice copy"}, rows[0]
print("intake selfcheck OK")
if __name__ == "__main__":
_selfcheck()

View File

@ -25,6 +25,7 @@ from .sales_routes import router as sales_router # noqa: E402
from .navigator_routes import router as navigator_router # noqa: E402
from .collections_routes import router as collections_router # noqa: E402
from .disc_images import router as disc_images_router # noqa: E402
from .intake_routes import router as intake_router # noqa: E402
from .db import engine # noqa: E402
from sqlalchemy import text as _sqltext # noqa: E402
@ -39,6 +40,7 @@ app.include_router(sales_router)
app.include_router(navigator_router)
app.include_router(collections_router)
app.include_router(disc_images_router)
app.include_router(intake_router)
_STARTUP_DDL = [

View File

@ -522,24 +522,133 @@ async function testConn(svc){
}
// ---------- Intake ----------
const CONDS = ['M','NM','VG+','VG','G+','G','F','P'];
const condSel = (sel,cls) => `<select class="${cls||''}">`+CONDS.map(c=>`<option${c===(sel||'VG+')?' selected':''}>${c}</option>`).join('')+'</select>';
let inkTab = 'find';
function vIntake(){
$('#content').innerHTML = '<h2>Intake <span class="muted" style="font-size:13px">· stage new stock</span></h2>'
+ '<div class="card" style="max-width:460px">'
+ '<label>Discogs release id</label><input id="in_rid" placeholder="e.g. 8949699" style="width:100%">'
+ '<label>Condition</label><select id="in_cond" style="width:100%"><option>M</option><option>NM</option><option selected>VG+</option><option>VG</option><option>G+</option><option>G</option></select>'
+ '<label>Price (AUD)</label><input id="in_price" type="number" step="0.01" placeholder="0.00" style="width:100%">'
+ '<div style="margin-top:14px"><button onclick="doIntake()">Stage item</button></div>'
+ '<div id="in_res" class="muted" style="margin-top:14px"></div></div>';
+ '<div class="tabrow" style="display:flex;gap:6px;margin-bottom:14px">'
+ ['find:🔎 Find &amp; stage','collection:💿 Discogs collection','sheet:📄 Google Sheet'].map(t=>{
const [k,l]=t.split(':'); return `<button class="tab${k===inkTab?' on':''}" onclick="inkGo('${k}')" style="padding:7px 13px;border-radius:8px;border:1px solid var(--line);background:${k===inkTab?'var(--accent,#c2185b)':'var(--panel)'};color:${k===inkTab?'#fff':'inherit'}">${l}</button>`;
}).join('')
+ '</div><div id="ink_body"></div>';
inkGo(inkTab);
}
async function doIntake(){
const rid = parseInt($('#in_rid').value, 10), res = $('#in_res');
if(!rid){ res.textContent = 'enter a Discogs release id'; return; }
res.textContent = 'enriching + staging…';
const r = await fetch('/wowplatter/v1/inventory/intake', { method:'POST', headers:hdr(),
body: JSON.stringify({ release_id: rid, condition: $('#in_cond').value, price: parseFloat($('#in_price').value)||null }) });
const d = await r.json();
if(d.ok){ res.innerHTML = `<span style="color:var(--ok)">✓ staged ${d.sku}</span> — ` + (d.enriched ? escapeH(d.title||'')+' · '+escapeH(d.artist||'') : '<span style="color:var(--warn)">not in catalog cache (un-enriched)</span>'); $('#in_rid').value=''; $('#in_price').value=''; }
else { res.innerHTML = `<span style="color:var(--warn)">${d.reason||'failed'}</span>`; }
function inkGo(t){ inkTab=t; if(t==='find')inkFind(); else if(t==='collection')inkColl(); else inkSheet();
document.querySelectorAll('.tabrow .tab').forEach(b=>{const on=b.getAttribute('onclick').includes("'"+t+"'");b.style.background=on?'var(--accent,#c2185b)':'var(--panel)';b.style.color=on?'#fff':'inherit';}); }
const inkBody = h => { if(inkTab&&$('#ink_body')) $('#ink_body').innerHTML = h; };
// --- 1. Find & stage (internal lookup + Discogs fallback) ---
function inkFind(){
inkBody('<div class="card"><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">'
+ '<div style="flex:2;min-width:220px"><label>Artist / title</label><input id="ik_q" placeholder="e.g. aphex twin selected ambient" style="width:100%" onkeydown="if(event.key===\'Enter\')inkSearch()"></div>'
+ '<div><label>Label</label><input id="ik_label" style="width:120px"></div>'
+ '<div><label>Year</label><input id="ik_year" type="number" style="width:78px"></div>'
+ '<div><label>Country</label><input id="ik_country" style="width:90px"></div>'
+ '<div><label>Format</label><input id="ik_fmt" placeholder="Vinyl / CD" style="width:100px"></div>'
+ '<button onclick="inkSearch()">Search</button></div>'
+ '<div id="ik_hint" class="muted" style="margin-top:8px;font-size:12px">Searches your local catalog first — instant. New titles? Hit “Search Discogs”.</div></div>'
+ '<div id="ik_results"></div>');
}
async function inkSearch(remote){
const q=$('#ik_q').value.trim();
const p=new URLSearchParams({q, label:$('#ik_label').value.trim(), year:$('#ik_year').value.trim(),
country:$('#ik_country').value.trim(), fmt:$('#ik_fmt').value.trim()});
for(const[k,v]of[...p])if(!v)p.delete(k);
$('#ik_results').innerHTML='<div class="muted" style="padding:12px">searching…</div>';
const url = remote ? '/admin/intake/discogs/search?q='+encodeURIComponent(q) : '/admin/intake/search?'+p;
let d; try{ d=await(await fetch(url,{headers:hdr()})).json(); }catch(e){ $('#ik_results').innerHTML='<div class="card">search failed</div>'; return; }
inkRenderCards(d.items||[], d.source, d.error);
}
function inkRenderCards(items, source, err){
let h='';
if(source!=='discogs') h+='<div style="margin:6px 0 10px"><button onclick="inkSearch(true)" style="background:var(--panel);border:1px solid var(--line)">Search Discogs ↗ (new titles)</button> <span class="muted" style="font-size:12px">'+(items.length?items.length+' local matches':'no local matches')+'</span></div>';
if(err) h+='<div class="card" style="color:var(--warn)">'+escapeH(err)+'</div>';
if(!items.length && !err){ inkBody2(h+'<div class="card muted">Nothing found.</div>'); return; }
h+='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px">';
items.forEach((it,i)=>{ window['_ik'+i]=it; h+=inkCard(it,'_ik'+i); });
h+='</div>';
inkBody2(h);
}
const inkBody2 = h => { $('#ik_results').innerHTML = h; };
function inkCard(it,ref){
return `<div class="card" style="display:flex;gap:10px;padding:12px">
${it.thumb?`<img src="${escapeH(it.thumb)}" style="width:64px;height:64px;object-fit:cover;border-radius:6px;flex-shrink:0">`:'<div style="width:64px;height:64px;background:var(--line);border-radius:6px;flex-shrink:0"></div>'}
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.title||'')}</div>
<div class="muted" style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.artist||'')}</div>
<div class="muted" style="font-size:11px">${[it.year,it.country,it.format].filter(Boolean).map(escapeH).join(' · ')}${it.in_stock?' · <span style="color:var(--ok)">in stock</span>':''}</div>
<div style="display:flex;gap:6px;margin-top:8px;align-items:center">
${condSel(it.media,'ik_c')}
<input class="ik_p" type="number" step="0.01" placeholder="$" style="width:70px">
<button onclick="inkStage(${ref?`window.${ref}.release_id`:it.release_id},this)" style="padding:5px 12px">Stage</button>
</div>
</div></div>`;
}
async function inkStage(rid, btn){
const row=btn.parentElement, cond=row.querySelector('.ik_c').value, price=parseFloat(row.querySelector('.ik_p').value)||null;
btn.disabled=true; btn.textContent='…';
const d=await(await fetch('/admin/intake/stage',{method:'POST',headers:hdr(),
body:JSON.stringify({release_id:rid,condition:cond,price})})).json();
btn.outerHTML = d.ok ? `<span style="color:var(--ok)">✓ ${escapeH(d.sku)}</span>` : '<span style="color:var(--warn)">failed</span>';
}
// --- 2. Discogs collection folder ---
async function inkColl(){
inkBody('<div class="card"><label>Collection folder</label><div id="ik_folders" class="muted">loading folders…</div></div><div id="ik_folitems"></div>');
let d; try{ d=await(await fetch('/admin/intake/discogs/folders',{headers:hdr()})).json(); }catch(e){ $('#ik_folders').innerHTML='failed'; return; }
if(d.error){ $('#ik_folders').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error)+'</span>'; return; }
$('#ik_folders').innerHTML = `<select id="ik_fsel" style="width:100%;max-width:480px" onchange="inkFolder(this.value)">`
+ '<option value="">— pick a folder —</option>'
+ d.folders.map(f=>`<option value="${f.id}">${escapeH(f.name)} (${f.count})</option>`).join('')+'</select>'
+ `<div class="muted" style="font-size:12px;margin-top:6px">Discogs user: ${escapeH(d.user)} · ${d.folders.length} folders with stock</div>`;
}
async function inkFolder(id,page){
if(!id) return; page=page||1;
$('#ik_folitems').innerHTML='<div class="muted" style="padding:12px">loading releases…</div>';
const d=await(await fetch(`/admin/intake/discogs/folder/${id}?page=${page}`,{headers:hdr()})).json();
if(d.error){ $('#ik_folitems').innerHTML='<div class="card" style="color:var(--warn)">'+escapeH(d.error)+'</div>'; return; }
let h='<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">'
+`<strong>${d.items.length} releases</strong> <button onclick="inkStageAll('${id}')">Stage all on this page</button></div>`
+'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px">';
d.items.forEach((it,i)=>{ window['_ikf'+i]=it; h+=inkCard(it,'_ikf'+i); });
h+='</div>';
if(d.pages>1) h+=`<div style="margin-top:12px;text-align:center" class="muted">page ${d.page} of ${d.pages} `
+(d.page>1?`<button onclick="inkFolder('${id}',${d.page-1})"></button> `:'')
+(d.page<d.pages?`<button onclick="inkFolder('${id}',${d.page+1})"></button>`:'')+'</div>';
h+='</div>';
$('#ik_folitems').innerHTML=h;
}
async function inkStageAll(id){
const sel=$('#ik_fsel'); if(!confirm('Stage all releases on this page with their collection condition/price?'))return;
const cards=[...document.querySelectorAll('#ik_folitems .card button')].filter(b=>b.textContent==='Stage');
for(const b of cards){ b.click(); await new Promise(r=>setTimeout(r,250)); }
}
// --- 3. Google Sheet (public CSV) ---
function inkSheet(){
inkBody('<div class="card"><label>Google Sheet URL <span class="muted" style="font-weight:400">(Share → anyone with link can view)</span></label>'
+'<input id="ik_url" placeholder="https://docs.google.com/spreadsheets/d/…" style="width:100%">'
+'<div style="margin-top:10px"><button onclick="inkPreview()">Preview</button></div>'
+'<div class="muted" style="font-size:12px;margin-top:8px">Auto-detects columns: release id · media/sleeve condition · price · comment · timestamp (→ SKU). Your Google-Form responses sheet works as-is.</div></div>'
+'<div id="ik_sheet"></div>');
}
async function inkPreview(){
const url=$('#ik_url').value.trim(); if(!url)return;
$('#ik_sheet').innerHTML='<div class="muted" style="padding:12px">reading sheet…</div>';
const d=await(await fetch('/admin/intake/sheet/preview',{method:'POST',headers:hdr(),body:JSON.stringify({url})})).json();
if(!d.ok){ $('#ik_sheet').innerHTML='<div class="card" style="color:var(--warn)">'+escapeH(d.error||'failed')+'</div>'; return; }
const cols=Object.keys(d.columns).map(k=>`${k}→col ${d.columns[k]+1}`).join(' · ');
let h=`<div class="card"><div style="margin-bottom:8px"><strong>${d.total}</strong> stageable rows · <span class="muted">${escapeH(cols)}</span></div>`
+'<table style="width:100%;font-size:12px;border-collapse:collapse"><tr style="text-align:left;color:var(--mut)"><th>release_id</th><th>sku</th><th>media</th><th>sleeve</th><th>price</th><th>notes</th></tr>'
+d.sample.map(r=>`<tr style="border-top:1px solid var(--line)"><td>${r.release_id}</td><td>${escapeH(r.sku||'auto')}</td><td>${escapeH(r.media||'')}</td><td>${escapeH(r.sleeve||'')}</td><td>${r.price??''}</td><td>${escapeH(r.notes||'')}</td></tr>`).join('')
+'</table><div style="margin-top:12px"><button onclick="inkImport()">Import all '+d.total+' rows</button> <span id="ik_imp" class="muted"></span></div></div>';
$('#ik_sheet').innerHTML=h; $('#ik_sheet').dataset.url=$('#ik_url').value.trim();
}
async function inkImport(){
const url=$('#ik_sheet').dataset.url, m=$('#ik_imp'); m.textContent='importing… (enriching new titles, please wait)';
const d=await(await fetch('/admin/intake/sheet/import',{method:'POST',headers:hdr(),body:JSON.stringify({url})})).json();
m.innerHTML = d.ok ? `<span style="color:var(--ok)">✓ staged ${d.staged} rows</span>` : '<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
}
// ---------- Crates ----------