import { isCutName, readSong, type Song } from "@/lib/browse"; import { appendCompare, blindView, getPairing, headToHead, isChoice, mintPairing, readCompares, tally, } from "@/lib/compares"; export const dynamic = "force-dynamic"; // A dated observation about a pair, and the blind pairing that makes one worth // having. See lib/compare-types.ts for why this is not verdicts.json. const rels = (song: Song): string[] => [...song.cuts.flatMap((c) => [c.shipped, ...c.variants]), ...song.unattributed] .filter((r): r is NonNullable => !!r) .map((r) => r.rel); export async function GET(request: Request) { const url = new URL(request.url); const id = url.searchParams.get("song") ?? ""; const cut = url.searchParams.get("cut"); const song = await readSong(id); if (!song) return Response.json({ error: "no such song" }, { status: 404 }); const doc = await readCompares(id); const scoped = cut ? { ...doc, compares: doc.compares.filter((c) => c.cut === cut) } : doc; return Response.json( { compares: scoped.compares, tally: tally(doc, cut ?? undefined) }, { headers: { "cache-control": "no-store" } }, ); } export async function POST(request: Request) { let body: { song?: string; cut?: string; left?: string; right?: string; winner?: string; note?: string; pairing?: string; mint?: boolean; }; try { body = await request.json(); } catch { return Response.json({ error: "bad json" }, { status: 400 }); } // --- deciding a blind pairing ------------------------------------------- // // The record is written FIRST and the mapping is returned in the response, so // there is no ordering in which a label reaches the client before the call is // on disk. A pairing is spent once decided: re-deciding the same one would // record a second observation of one listen. if (body.pairing) { const p = getPairing(body.pairing); if (!p) return Response.json({ error: "that pairing has expired — mint a new one" }, { status: 410 }); if (p.spent) return Response.json({ error: "already decided" }, { status: 409 }); if (!body.winner || !isChoice(body.winner)) { return Response.json({ error: "winner must be left, right or tie" }, { status: 400 }); } p.spent = true; const compare = await appendCompare(p.song, { cut: p.cut, left: p.left, right: p.right, winner: body.winner, blind: true, ...(body.note?.trim() ? { note: body.note.trim() } : {}), }); const doc = await readCompares(p.song); return Response.json( { ok: true, compare, reveal: { left: p.left, right: p.right }, head: headToHead(doc, p.left, p.right), tally: tally(doc, p.cut), }, { headers: { "cache-control": "no-store" } }, ); } const { song: id, cut, left, right } = body; if (!id || !cut || !left || !right) { return Response.json({ error: "song, cut, left and right are required" }, { status: 400 }); } if (!isCutName(cut)) return Response.json({ error: "not a cut name" }, { status: 400 }); const song = await readSong(id); if (!song) return Response.json({ error: "no such song" }, { status: 404 }); // Membership in the current scan, as everywhere else here: a well-formed name // for a file that is not there would otherwise accumulate evidence about // nothing. const known = rels(song); if (!known.includes(left) || !known.includes(right)) { return Response.json({ error: "not a file in this song" }, { status: 404 }); } if (left === right) return Response.json({ error: "a file cannot face itself" }, { status: 400 }); // --- minting a blind pairing -------------------------------------------- if (body.mint) return Response.json(blindView(mintPairing(id, cut, left, right)), { headers: { "cache-control": "no-store" }, }); // --- a sighted call ------------------------------------------------------ if (!body.winner || !isChoice(body.winner)) { return Response.json({ error: "winner must be left, right or tie" }, { status: 400 }); } const compare = await appendCompare(id, { cut, left, right, winner: body.winner, blind: false, ...(body.note?.trim() ? { note: body.note.trim() } : {}), }); const doc = await readCompares(id); return Response.json( { ok: true, compare, head: headToHead(doc, left, right), tally: tally(doc, cut) }, { headers: { "cache-control": "no-store" } }, ); }