Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b50f5a5
feat: staff takedown switch for Cap Infra videos
richiemcilroy Nov 13, 2026
e598500
feat: 410 for taken down Infra videos and a report link on the player
richiemcilroy Nov 13, 2026
6fe2bf7
feat: report takedowns in the developer API and the Infra console
richiemcilroy Nov 13, 2026
4d5a190
fix: read takedown as false for videos with no metadata
richiemcilroy Nov 13, 2026
3e24de7
docs: say how long already loaded media links last after a takedown
richiemcilroy Nov 13, 2026
c5d653b
Merge remote-tracking branch 'origin/main' into fix/infra-report-take…
richiemcilroy Nov 13, 2026
1187735
refactor: drop the takedown lookup from the proxy
richiemcilroy Nov 13, 2026
2ca50ab
feat: plain unavailable page for a taken down video
richiemcilroy Nov 13, 2026
04a034a
docs: say only playback files answer 410 for a taken down video
richiemcilroy Nov 13, 2026
1202113
Merge remote-tracking branch 'origin/main' into HEAD
richiemcilroy Nov 13, 2026
c607088
Merge remote-tracking branch 'origin/main' into HEAD
richiemcilroy Nov 13, 2026
2b68d06
fix(infra): keep outputs describing the encodes on a taken down video
richiemcilroy Nov 13, 2026
3cd05fd
fix(infra): check the takedown flag again after a stalled video is re…
richiemcilroy Nov 13, 2026
5fceb79
Merge remote-tracking branch 'origin/main' into HEAD
richiemcilroy Nov 13, 2026
1e2bfb2
Merge remote-tracking branch 'origin/main' into HEAD
richiemcilroy Nov 13, 2026
49c81b6
test(web): a client cannot store the takedown marker through create u…
richiemcilroy Nov 13, 2026
725705f
fix(infra): a takedown does not change which request an idempotency k…
richiemcilroy Nov 13, 2026
3be64e0
Merge remote-tracking branch 'origin/main' into HEAD
richiemcilroy Nov 13, 2026
9dda488
Merge remote-tracking branch 'origin/main' into HEAD
richiemcilroy Nov 13, 2026
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
55 changes: 55 additions & 0 deletions apps/web/__tests__/fixtures/developer-video-takedown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { DeveloperVideoStore } from "./developer-video";

// The takedown rewrites metadata in SQL. This applies the same two rewrites
// to the fake's stored row, so a test can take a video down and restore it.
// The SQL itself was run against MySQL 8 when it was written.
export async function withTakedownSql(
store: DeveloperVideoStore,
database: Awaited<
ReturnType<typeof import("./developer-video").createDeveloperVideoDatabase>
>,
) {
const { MySqlDialect } = await import("drizzle-orm/mysql-core");
const { SQL } = await import("drizzle-orm");
const dialect = new MySqlDialect();

const evaluate = (value: unknown, current: unknown): unknown => {
if (!(value instanceof SQL)) return value;
const query = dialect.sqlToQuery(value);
if (query.sql.startsWith("JSON_OBJECT(")) {
const [key, at] = query.params as [string, string];
return { [key]: { at, metadata: current } };
}
if (query.sql.startsWith("CASE WHEN JSON_TYPE(")) {
const marker = Object.values(
(current ?? {}) as Record<string, { metadata?: unknown }>,
)[0];
return marker?.metadata ?? null;
}
throw new Error(`The fake cannot evaluate ${query.sql}`);
};

const wrapped = {
...database,
update: () => ({
set: (updates: Record<string, unknown>) => ({
where: async () => {
const current = store.video;
const applied = Object.fromEntries(
Object.entries(updates).map(([column, value]) => [
column,
evaluate(
value,
current?.[column as keyof NonNullable<typeof current>],
),
]),
);
store.updates.push(updates);
if (current) store.video = { ...current, ...applied };
},
}),
}),
transaction: async (run: (tx: unknown) => Promise<unknown>) => run(wrapped),
};
return wrapped;
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const row = (index: number): AgentDeveloperVideoRow => ({
height: 720,
fps: 30,
maxResolutionTier: "1080p",
takenDown: false,
renditions: [
{
name: "720p",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/__tests__/unit/agent-developer-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ describe("developer routes of the agent API", () => {
},
],
transcriptionStatus: null,
takenDown: false,
processing: {
hls: { state: "complete" },
mp4: { state: "complete" },
Expand Down Expand Up @@ -380,6 +381,7 @@ describe("developer routes of the agent API", () => {
maxResolutionTier: "1080p",
renditions: videoRow().renditions,
transcriptionStatus: null,
takenDown: false,
hlsUrl:
"http://localhost/api/developer/playback/playback-id-000000000001/master.m3u8",
mp4Url:
Expand Down
2 changes: 2 additions & 0 deletions apps/web/__tests__/unit/developer-video-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ describe("developer video API", () => {
deletedAt: null,
createdAt: "2026-11-01T00:00:00.000Z",
updatedAt: "2026-11-01T00:00:00.000Z",
takenDown: false,
hlsUrl: `${BASE}/master.m3u8`,
mp4Url: `${BASE}/video.mp4`,
posterUrl: `${BASE}/poster.jpg`,
Expand Down Expand Up @@ -861,6 +862,7 @@ describe("developer video API", () => {
errorReason: null,
playbackId: "playback-id-0123456789ab",
renditions: ready().renditions,
takenDown: false,
hlsUrl: `${BASE}/master.m3u8`,
mp4Url: `${BASE}/video.mp4`,
posterUrl: `${BASE}/poster.jpg`,
Expand Down
128 changes: 126 additions & 2 deletions apps/web/__tests__/unit/developer-video-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,24 @@ const processing: DeveloperVideoPlayer = {
};
const failed: DeveloperVideoPlayer = { ...frame, kind: "failed" };
const paused: DeveloperVideoPlayer = { ...frame, kind: "paused" };
const takenDown: DeveloperVideoPlayer = { ...frame, kind: "taken_down" };
const REPORT_HREF = `mailto:hello@cap.so?subject=Report%20video%20${PLAYBACK_ID}`;

const links = (html: string) =>
[...html.matchAll(/<a [^>]*>[^<]*<\/a>/g)].map((match) => match[0]);

const render = (
player: DeveloperVideoPlayer,
variant: "share" | "embed" = "share",
playback?: DeveloperVideoPlayback,
) =>
renderToStaticMarkup(
createElement(DeveloperVideoView, { player, variant, playback }),
createElement(DeveloperVideoView, {
playbackId: PLAYBACK_ID,
player,
variant,
playback,
}),
);

const params = (query: DeveloperVideoQuery = {}, playbackId = PLAYBACK_ID) => ({
Expand Down Expand Up @@ -150,7 +160,8 @@ describe("developer video page states", () => {
expect(embed).not.toContain("Cap Infra");
expect(embed).not.toContain("<header");
expect(embed).toContain('<h1 class="sr-only">Launch demo</h1>');
expect(embed).not.toContain("<a ");
expect(embed.match(/<a /g)).toHaveLength(1);
expect(embed).toContain(`href="${REPORT_HREF}"`);
},
);

Expand All @@ -163,6 +174,119 @@ describe("developer video page states", () => {
});
});

describe("developer video report link", () => {
it.each([
["playing", playing],
["processing", processing],
["failed", failed],
["paused", paused],
])(
"offers a quiet Report link under the %s video that emails the support address with the playback id",
(_, player) => {
const [report, ...others] = links(render(player));

expect(others).toEqual([]);
expect(report).toContain(`href="${REPORT_HREF}"`);
expect(report).toContain('aria-label="Report this video"');
expect(report).toContain(">Report</a>");
expect(report).not.toContain("target=");
expect(report).not.toMatch(/uppercase|tracking-\[0\.|font-semibold/);
},
);

it("tucks the embed link into the top corner, hidden until the pointer is over a player with its controls showing", () => {
const html = render(playing, "embed");
const [report] = links(html);

expect(html).toContain('class="group fixed inset-0 bg-black"');
expect(report).toContain(`href="${REPORT_HREF}"`);
expect(report).toContain('target="_blank"');
expect(report).toContain('rel="noopener noreferrer"');
expect(report).toContain("absolute right-2 top-2");
expect(report).toContain("opacity-0");
expect(report).toContain("pointer-events-none");
expect(report).toContain(
"[.group:hover:not(:has([data-idle=true]))_&amp;]:opacity-100",
);
expect(report).toContain("focus-visible:opacity-100");
});
});

describe("a taken down video page", () => {
const takenDownFromLoader: DeveloperVideoPlayer = {
name: "",
width: 16,
height: 9,
kind: "taken_down",
};

it.each(["share", "embed"] as const)(
"shows one plain sentence on the %s page and nothing from the video",
async (variant) => {
mocks.load.mockResolvedValue(takenDownFromLoader);
const page = variant === "share" ? sharePage : embedPage;
const route = await page.default(params());
expect(route.props).toMatchObject({ playbackId: PLAYBACK_ID, variant });

const html = renderToStaticMarkup(
await DeveloperVideoPage({
playbackId: PLAYBACK_ID,
variant,
query: {},
}),
);
const text = html
.replace(/<style>.*?<\/style>/g, "")
.replace(/<[^>]+>/g, " ")
.trim();

expect(text).toBe("This video is not available.");
expect(html).toContain("<h1");
for (const absent of [
"<video",
"<img",
"<a ",
"poster",
"m3u8",
"video.mp4",
"/api/developer/playback",
PLAYBACK_ID,
"Launch demo",
"aspect-ratio",
'role="status"',
'aria-label="Cap Infra"',
]) {
expect(html).not.toContain(absent);
}
},
);

it("never renders the video's own name or size even if the loader passed them", () => {
const html = render(takenDown, "share");

expect(html).not.toContain("Launch demo");
expect(html).not.toContain("1920");
expect(html).not.toContain("mailto:");
});

it.each([
["share", sharePage],
["embed", embedPage],
])(
"keeps the %s page out of search and previews with no title or image from the video",
async (_, page) => {
mocks.load.mockResolvedValue(takenDownFromLoader);

expect(await page.generateMetadata(params())).toMatchObject({
title: "Video not available",
robots: { index: false, follow: false },
openGraph: { title: "Video not available", images: [] },
twitter: { card: "summary", title: "Video not available", images: [] },
});
},
);
});

describe("developer video page parameters", () => {
const embed = (query: DeveloperVideoQuery) =>
render(playing, "embed", developerVideoPlayback(query, "embed"));
Expand Down
50 changes: 50 additions & 0 deletions apps/web/__tests__/unit/developer-video-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,56 @@ const uploadedLongAgo = () =>
});

describe("developer video player data", () => {
it.each([
["a ready video", () => ready()],
["a paused app's video", () => ready()],
["a video still uploading", uploadedLongAgo],
])(
"shows %s that Cap took down as unavailable, without redriving it",
async (name, video) => {
if (name === "a paused app's video") {
mocks.standing.mockResolvedValue({ state: "paused", pausesAt: null });
}
mocks.store.video = {
...video(),
metadata: {
__capTakedown: { at: "2026-11-13T12:00:00.000Z", metadata: null },
},
};

expect(await loadDeveloperVideoPlayer(PLAYBACK_ID)).toEqual({
kind: "taken_down",
name: "",
width: 16,
height: 9,
});
expect(mocks.standing).not.toHaveBeenCalled();
expect(mocks.redrive).not.toHaveBeenCalled();
},
);

it("shows a video taken down while its stalled processing was retried as unavailable", async () => {
mocks.store.video = uploadedLongAgo();
mocks.redrive.mockImplementation(async () => {
if (!mocks.store.video) throw new Error("missing video");
mocks.store.video = {
...mocks.store.video,
metadata: {
__capTakedown: { at: "2026-11-13T12:00:00.000Z", metadata: null },
},
};
return true;
});

expect(await loadDeveloperVideoPlayer(PLAYBACK_ID)).toEqual({
kind: "taken_down",
name: "",
width: 16,
height: 9,
});
expect(mocks.redrive).toHaveBeenCalledTimes(1);
});

it("plays the adaptive stream with the MP4 behind it and the poster", async () => {
expect(await loadDeveloperVideoPlayer(PLAYBACK_ID)).toEqual({
kind: "playing",
Expand Down
Loading
Loading