From adf3207993e56ae6fa52890d4f1a374d043d7adc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 20:54:00 +0000 Subject: [PATCH] Serve a rendered ad into a streaming break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the endpoint a player calls when it wants something to play in an ad break, and the lookup that turns a chosen creative into a file to fetch. Selection, the auction and the impression are not redone. They are serveAd's, exactly as they are for a banner, because a second selection path would be a second set of numbers and the one not wired to billing is the one that quietly gives inventory away. This only answers the question serveAd cannot: given the creative it picked, which file should this player load? Audio by default. The properties most likely to call this are music and radio players — nixamp already fetches an endpoint of this shape from its ad break handler — and handing a video URL to something with nowhere to show a picture is worse than handing it audio it can certainly play. A video request gets the 720p rendition rather than the 1080p master: the master is the download an advertiser keeps, and making a phone fetch it to watch five seconds spends their data on pixels the screen cannot show. Only published_revision is servable, and there is no fallback to another revision or another profile. A revision becomes published when the worker has validated it; serving the newest instead would put media on air that was never approved, and a silent fallback would do it without saying so. An unfilled break returns 200 with a null url rather than an error. A break nobody can fill simply does not happen and the listener keeps their content, which is the only safe default when the alternative is dead air. The one case that is logged is a creative that won the auction and has no media: that is a campaign winning inventory it cannot fill, which is worth knowing about. Nothing is wired to a property yet. nixamp's client calls /api/ads/next and no route implements it; that bridge is the next piece. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/ads/stream/route.ts | 106 +++++++++++++++++++++++++++ lib/ads/video/serve.ts | 124 +++++++++++++++++++++++++++++++ tests/ads-video-serve.test.ts | 133 ++++++++++++++++++++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 app/api/ads/stream/route.ts create mode 100644 lib/ads/video/serve.ts create mode 100644 tests/ads-video-serve.test.ts diff --git a/app/api/ads/stream/route.ts b/app/api/ads/stream/route.ts new file mode 100644 index 00000000..c9c4ace5 --- /dev/null +++ b/app/api/ads/stream/route.ts @@ -0,0 +1,106 @@ +// Public streaming-break endpoint. +// +// A player asks for something to play in an ad break; this answers with one +// media URL, or with nothing. Nothing is a perfectly good answer and is what +// every failure returns: a break that cannot be filled simply does not happen +// and the listener keeps their content, which is the only behaviour that is +// safe to default to when the alternative is dead air. +// +// Selection, the auction and the impression are serveAd's, exactly as they are +// for a banner. A second selection path would be a second set of numbers, and +// the one not wired to billing is the one that quietly gives inventory away. +// This route only turns the creative serveAd chose into a file to fetch. + +import { NextRequest, NextResponse } from "next/server"; +import { serveAd } from "@/lib/ads/serve"; +import { serviceClient } from "@/lib/supabase/service"; +import { streamMediaFor, type StreamKind } from "@/lib/ads/video/serve"; +import { VIDEO_FORMAT_ID } from "@/lib/ads/formats"; +import { ASSET_BUCKET } from "@/lib/ads/video/storage"; +import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; +import { parseDevice } from "@/lib/tracker/device"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function cors(request: Request): Record { + const origin = request.headers.get("origin"); + return { + "access-control-allow-origin": origin ?? "*", + "access-control-allow-methods": "GET, OPTIONS", + "access-control-allow-headers": "content-type", + // Never cached: every fill is metered, and a cached one is an impression + // that happened without being counted. + "cache-control": "no-store", + vary: "Origin", + }; +} + +export function OPTIONS(request: NextRequest) { + return new NextResponse(null, { status: 204, headers: cors(request) }); +} + +/** An unfilled break. 200, not 404: there is no error here, just no advert. */ +function empty(headers: Record) { + return NextResponse.json({ url: null }, { headers }); +} + +export async function GET(request: NextRequest) { + const headers = cors(request); + + try { + const url = new URL(request.url); + const slotId = url.searchParams.get("slot"); + const kindParam = url.searchParams.get("kind"); + // Audio by default. The properties most likely to call this are music and + // radio players, and handing a