Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions app/api/ads/stream/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<string, string>) {
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 <video> URL to something with nowhere to
// show it is worse than handing it audio it can definitely play.
const kind: StreamKind = kindParam === "video" ? "video" : "audio";

if (!slotId) return empty(headers);

const ip = clientIpFromHeaders(request.headers);
const geo = await lookupGeo(ip).catch(() => null);
const fill = await serveAd(slotId, VIDEO_FORMAT_ID, {
ip,
country: geo?.countryCode ?? null,
device: parseDevice(request.headers.get("user-agent")).deviceType,
});
if (!fill) return empty(headers);

const sb = serviceClient();
const media = await streamMediaFor(sb, {
creativeId: fill.creativeId,
kind,
publicUrlFor: (key) => sb.storage.from(ASSET_BUCKET).getPublicUrl(key).data.publicUrl,
});

// Chosen, metered, but the media is not there. Returning an empty break is
// right — there is nothing to play — but it is worth being loud about,
// because it means a campaign is winning auctions it cannot fill.
if (!media) {
console.warn(
`[ads] stream break unfilled: creative ${fill.creativeId} has no published ${kind} media`,
);
return empty(headers);
}

return NextResponse.json(
{
url: media.url,
kind: media.kind,
durationMs: media.durationMs,
posterUrl: media.posterUrl,
captionsUrl: media.captionsUrl,
// The player opens this when the listener interacts with the break.
clickUrl: fill.clickUrl,
impressionId: fill.impressionId,
},
{ headers },
);
} catch {
// Same rule as the banner tag: a failure here must never be visible to the
// person listening.
return empty(headers);
}
}
124 changes: 124 additions & 0 deletions lib/ads/video/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Resolving a chosen creative to the media a player can actually load.
//
// Selection, the auction and impression accounting are NOT redone here. They
// live in serveAd and are the same for a banner and a pre-roll: a second
// serving path would be a second set of numbers, and the one that is not
// wired to billing is the one that quietly gives inventory away.
//
// This module answers only the question serveAd cannot: given the creative it
// picked, which file should this player fetch?

import type { SupabaseClient } from "@supabase/supabase-js";
import { GIF_PROFILE_IDS, type VideoProfileId } from "./profiles";

/** What the caller wants to play. A music player has nowhere to put a picture. */
export type StreamKind = "audio" | "video";

export type StreamMedia = {
kind: StreamKind;
url: string;
durationMs: number | null;
/** Shown before the first frame; null for an audio-only break. */
posterUrl: string | null;
/** WebVTT for the narration, when the revision has it. */
captionsUrl: string | null;
revision: number;
};

/**
* Which profile serves which request.
*
* 720p rather than the 1080p master: the master is the download an advertiser
* keeps, and making a viewer on a phone fetch it to watch five seconds is
* spending their data on pixels their screen cannot show.
*/
const VIDEO_PROFILE: VideoProfileId = "mp4_720p";
const AUDIO_PROFILE: VideoProfileId = "audio";

/**
* The media for a creative's published revision.
*
* Returns null rather than falling back to another revision or another
* profile. A break that cannot be filled correctly should not happen at all —
* every player in this system treats null as "play the content" — and a silent
* fallback to an unpublished revision would serve media that was never
* approved for serving.
*/
export async function streamMediaFor(
sb: SupabaseClient,
args: { creativeId: string; kind: StreamKind; publicUrlFor: (objectKey: string) => string },
): Promise<StreamMedia | null> {
// published_revision, not the newest: a revision becomes servable only when
// the worker has validated it, and "newest" would serve a render that is
// still being written.
const { data: creative } = await sb
.from("ad_creatives")
.select("published_revision")
.eq("id", args.creativeId)
.maybeSingle();

const revision = Number(creative?.published_revision ?? 0);
if (!revision) return null;

const { data: rows } = await sb
.from("ad_video_assets")
.select("profile, object_key, duration_ms, published")
.eq("creative_id", args.creativeId)
.eq("revision", revision)
.eq("published", true);

const assets = rows ?? [];
const wanted = args.kind === "audio" ? AUDIO_PROFILE : VIDEO_PROFILE;
const primary = assets.find((a) => a.profile === wanted);
if (!primary) return null;

const poster = assets.find((a) => a.profile === "poster");
const captions = assets.find((a) => a.profile === "captions");

return {
kind: args.kind,
url: args.publicUrlFor(primary.object_key as string),
durationMs: primary.duration_ms === null ? null : Number(primary.duration_ms),
// An audio break has nothing to show a poster on.
posterUrl:
args.kind === "video" && poster ? args.publicUrlFor(poster.object_key as string) : null,
captionsUrl: captions ? args.publicUrlFor(captions.object_key as string) : null,
revision,
};
}

/**
* The animated banners of a creative's published revision, by profile.
*
* Separate from the break media because a display slot asks a different
* question: it wants a unit of a particular size, not "whatever plays".
*/
export async function animatedBannerFor(
sb: SupabaseClient,
args: {
creativeId: string;
profile: (typeof GIF_PROFILE_IDS)[number];
publicUrlFor: (objectKey: string) => string;
},
): Promise<{ url: string; revision: number } | null> {
const { data: creative } = await sb
.from("ad_creatives")
.select("published_revision")
.eq("id", args.creativeId)
.maybeSingle();

const revision = Number(creative?.published_revision ?? 0);
if (!revision) return null;

const { data: row } = await sb
.from("ad_video_assets")
.select("object_key")
.eq("creative_id", args.creativeId)
.eq("revision", revision)
.eq("profile", args.profile)
.eq("published", true)
.maybeSingle();

if (!row) return null;
return { url: args.publicUrlFor(row.object_key as string), revision };
}
133 changes: 133 additions & 0 deletions tests/ads-video-serve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { streamMediaFor, animatedBannerFor } from "@/lib/ads/video/serve";

/**
* A Supabase stand-in narrow enough to be read at a glance: it answers the two
* queries this module makes and records nothing else.
*/
function db(opts: {
publishedRevision: number | null;
assets?: { profile: string; object_key: string; duration_ms?: number | null; published?: boolean }[];
}) {
const assets = opts.assets ?? [];
return {
from(table: string) {
if (table === "ad_creatives") {
const b: Record<string, unknown> = {};
Object.assign(b, {
select: () => b,
eq: () => b,
maybeSingle: async () => ({
data: { published_revision: opts.publishedRevision },
}),
});
return b;
}
// ad_video_assets: collect the eq() filters so the fake honours them.
const filters: Record<string, unknown> = {};
const b: Record<string, unknown> = {};
Object.assign(b, {
select: () => b,
eq: (col: string, val: unknown) => {
filters[col] = val;
return b;
},
maybeSingle: async () => {
const hit = assets.find(
(a) =>
a.profile === filters.profile &&
(a.published ?? true) === (filters.published ?? true),
);
return { data: hit ?? null };
},
then: undefined,
});
// The list query is awaited directly, so the builder resolves as a promise.
(b as { then?: unknown }).then = (resolve: (v: unknown) => void) =>
resolve({
data: assets.filter((a) => (a.published ?? true) === (filters.published ?? true)),
});
return b;
},
};
}

const publicUrlFor = (key: string) => `https://cdn.example/${key}`;

describe("only a published revision is servable", () => {
it("returns nothing when the creative has never published one", async () => {
// published_revision is set by the worker after validation. Serving the
// newest revision instead would put media on air that was never approved.
const res = await streamMediaFor(db({ publishedRevision: null }) as never, {
creativeId: "c",
kind: "audio",
publicUrlFor,
});
expect(res).toBeNull();
});

it("returns nothing when the published revision has no media of that kind", async () => {
const res = await streamMediaFor(
db({ publishedRevision: 2, assets: [{ profile: "poster", object_key: "p.webp" }] }) as never,
{ creativeId: "c", kind: "audio", publicUrlFor },
);
// A break that cannot be filled correctly should not happen at all.
expect(res).toBeNull();
});
});

describe("the right file for the right player", () => {
const assets = [
{ profile: "audio", object_key: "r2/audio.m4a", duration_ms: 5000 },
{ profile: "mp4_720p", object_key: "r2/720.mp4", duration_ms: 5000 },
{ profile: "master_1080p", object_key: "r2/master.mp4", duration_ms: 5000 },
{ profile: "poster", object_key: "r2/poster.webp" },
{ profile: "captions", object_key: "r2/cc.vtt" },
];

it("gives a music player the audio companion and no poster", async () => {
const res = await streamMediaFor(db({ publishedRevision: 2, assets }) as never, {
creativeId: "c",
kind: "audio",
publicUrlFor,
});
expect(res!.url).toBe("https://cdn.example/r2/audio.m4a");
// There is nowhere to show a poster during an audio break.
expect(res!.posterUrl).toBeNull();
expect(res!.captionsUrl).toBe("https://cdn.example/r2/cc.vtt");
expect(res!.revision).toBe(2);
});

it("gives a video player 720p, not the master", async () => {
// The master is the advertiser's download. Making a phone fetch it to watch
// five seconds spends their data on pixels the screen cannot show.
const res = await streamMediaFor(db({ publishedRevision: 2, assets }) as never, {
creativeId: "c",
kind: "video",
publicUrlFor,
});
expect(res!.url).toBe("https://cdn.example/r2/720.mp4");
expect(res!.posterUrl).toBe("https://cdn.example/r2/poster.webp");
});
});

describe("animated banners are addressed by size", () => {
it("returns the requested unit from the published revision", async () => {
const res = await animatedBannerFor(
db({
publishedRevision: 3,
assets: [{ profile: "gif_728x90", object_key: "r3/gif_728x90.gif" }],
}) as never,
{ creativeId: "c", profile: "gif_728x90", publicUrlFor },
);
expect(res).toEqual({ url: "https://cdn.example/r3/gif_728x90.gif", revision: 3 });
});

it("returns nothing for a size this revision does not have", async () => {
const res = await animatedBannerFor(
db({ publishedRevision: 3, assets: [{ profile: "gif_728x90", object_key: "x" }] }) as never,
{ creativeId: "c", profile: "gif_300x250", publicUrlFor },
);
expect(res).toBeNull();
});
});