diff --git a/apps/web/__tests__/fixtures/developer-video-takedown.ts b/apps/web/__tests__/fixtures/developer-video-takedown.ts new file mode 100644 index 00000000000..b580466e24b --- /dev/null +++ b/apps/web/__tests__/fixtures/developer-video-takedown.ts @@ -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 + >, +) { + 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, + )[0]; + return marker?.metadata ?? null; + } + throw new Error(`The fake cannot evaluate ${query.sql}`); + }; + + const wrapped = { + ...database, + update: () => ({ + set: (updates: Record) => ({ + where: async () => { + const current = store.video; + const applied = Object.fromEntries( + Object.entries(updates).map(([column, value]) => [ + column, + evaluate( + value, + current?.[column as keyof NonNullable], + ), + ]), + ); + store.updates.push(updates); + if (current) store.video = { ...current, ...applied }; + }, + }), + }), + transaction: async (run: (tx: unknown) => Promise) => run(wrapped), + }; + return wrapped; +} diff --git a/apps/web/__tests__/unit/agent-developer-response-size.test.ts b/apps/web/__tests__/unit/agent-developer-response-size.test.ts index 1156f7c0cf9..3c548515689 100644 --- a/apps/web/__tests__/unit/agent-developer-response-size.test.ts +++ b/apps/web/__tests__/unit/agent-developer-response-size.test.ts @@ -27,6 +27,7 @@ const row = (index: number): AgentDeveloperVideoRow => ({ height: 720, fps: 30, maxResolutionTier: "1080p", + takenDown: false, renditions: [ { name: "720p", diff --git a/apps/web/__tests__/unit/agent-developer-routes.test.ts b/apps/web/__tests__/unit/agent-developer-routes.test.ts index c658799349e..352a8a6ddf8 100644 --- a/apps/web/__tests__/unit/agent-developer-routes.test.ts +++ b/apps/web/__tests__/unit/agent-developer-routes.test.ts @@ -346,6 +346,7 @@ describe("developer routes of the agent API", () => { }, ], transcriptionStatus: null, + takenDown: false, processing: { hls: { state: "complete" }, mp4: { state: "complete" }, @@ -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: diff --git a/apps/web/__tests__/unit/developer-video-api.test.ts b/apps/web/__tests__/unit/developer-video-api.test.ts index a63ac5a4a16..b4d5d406f62 100644 --- a/apps/web/__tests__/unit/developer-video-api.test.ts +++ b/apps/web/__tests__/unit/developer-video-api.test.ts @@ -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`, @@ -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`, diff --git a/apps/web/__tests__/unit/developer-video-page.test.ts b/apps/web/__tests__/unit/developer-video-page.test.ts index e3797926c09..532b7db4605 100644 --- a/apps/web/__tests__/unit/developer-video-page.test.ts +++ b/apps/web/__tests__/unit/developer-video-page.test.ts @@ -42,6 +42,11 @@ 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>/g)].map((match) => match[0]); const render = ( player: DeveloperVideoPlayer, @@ -49,7 +54,12 @@ const render = ( playback?: DeveloperVideoPlayback, ) => renderToStaticMarkup( - createElement(DeveloperVideoView, { player, variant, playback }), + createElement(DeveloperVideoView, { + playbackId: PLAYBACK_ID, + player, + variant, + playback, + }), ); const params = (query: DeveloperVideoQuery = {}, playbackId = PLAYBACK_ID) => ({ @@ -150,7 +160,8 @@ describe("developer video page states", () => { expect(embed).not.toContain("Cap Infra"); expect(embed).not.toContain("Launch demo'); - expect(embed).not.toContain(" { }); }); +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"); + 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]))_&]: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(/} +

+ {DEVELOPER_VIDEO_UNAVAILABLE} +

+ + ); + } + + const reportHref = developerVideoReportHref(playbackId); + if (variant === "embed") { return ( -
+

{player.name}

+ + Report +
); } @@ -160,9 +207,18 @@ export function DeveloperVideoView({ >
-

- {player.name} -

+
+

+ {player.name} +

+ + Report + +
@@ -182,6 +238,7 @@ export async function DeveloperVideoPage({ if (!player) notFound(); return ( & + TakedownSource; + +export function developerVideoTakenDown(video: TakedownSource): boolean { + return "takenDown" in video + ? video.takenDown + : developerVideoIsTakenDown(video); +} + export function developerVideoOutputs( - video: Pick, + video: PlayableFields, ): DeveloperVideoOutputs { const processing = video.processing; - if (video.status !== "ready" || !video.playbackId || !processing) { + if ( + video.status !== "ready" || + !video.playbackId || + !processing || + developerVideoTakenDown(video) + ) { return { hls: false, mp4: false, poster: false }; } return { @@ -40,16 +65,19 @@ export interface DeveloperVideoOutputStates { } // A null hlsUrl or mp4Url alone cannot say whether that output is still being -// encoded or will never exist. "ready" matches a non-null URL exactly. A part -// waiting for a busy encoder to retry is still "processing". "none" is only -// for videos uploaded before encoding existed, which never get these outputs. +// encoded or will never exist. "ready" matches a non-null URL exactly, except +// on a video Cap took down: its URLs are null while takenDown is true, and its +// outputs keep saying what was encoded, so a restore needs no new encode and a +// client is not told to keep waiting. A part waiting for a busy encoder to +// retry is still "processing". "none" is only for videos uploaded before +// encoding existed, which never get these outputs. export function developerVideoOutputStates( video: Pick< DeveloperVideoRow, "status" | "playbackId" | "processing" | "duration" | "width" >, ): DeveloperVideoOutputStates { - const outputs = developerVideoOutputs(video); + const outputs = developerVideoOutputs({ ...video, takenDown: false }); const processing = video.processing; const state = (part: "hls" | "mp4"): DeveloperVideoOutputState => { if (outputs[part]) return "ready"; @@ -84,9 +112,11 @@ export function developerPlaybackBaseUrl(): string { return base.replace(/\/+$/, ""); } -export function developerVideoPlaybackUrls( - video: Pick, -): { hlsUrl: string | null; mp4Url: string | null; posterUrl: string | null } { +export function developerVideoPlaybackUrls(video: PlayableFields): { + hlsUrl: string | null; + mp4Url: string | null; + posterUrl: string | null; +} { const outputs = developerVideoOutputs(video); const base = `${developerPlaybackBaseUrl()}/${video.playbackId}`; return { @@ -123,7 +153,7 @@ export type AgentDeveloperVideoRow = Pick< | "processing" | "createdAt" | "updatedAt" ->; +> & { takenDown: boolean }; export function toAgentDeveloperVideo(video: AgentDeveloperVideoRow) { const status = developerVideoIsReady(video) ? "ready" : video.status; @@ -142,6 +172,7 @@ export function toAgentDeveloperVideo(video: AgentDeveloperVideoRow) { maxResolutionTier: video.maxResolutionTier, renditions: video.renditions ?? [], transcriptionStatus: video.transcriptionStatus, + takenDown: video.takenDown === true, ...developerVideoPlaybackUrls(video), ...(video.playbackId ? developerVideoPageUrls(video.playbackId) @@ -160,8 +191,10 @@ export function serializeDeveloperVideo(video: DeveloperVideoRow) { } = video; return { ...fields, + metadata: developerVideoPublicMetadata(video.metadata), duration: developerVideoReportedDuration(video), renditions: video.renditions ?? [], + takenDown: developerVideoIsTakenDown(video), ...developerVideoPlaybackUrls(video), outputs: developerVideoOutputStates(video), ...(video.playbackId @@ -197,6 +230,7 @@ export function serializeDeveloperVideoStatus(video: DeveloperVideoRow) { errorReason: video.errorReason, playbackId: video.playbackId, renditions: video.renditions ?? [], + takenDown: developerVideoIsTakenDown(video), ...developerVideoPlaybackUrls(video), outputs: developerVideoOutputStates(video), }; diff --git a/apps/web/lib/developer-video-playback.ts b/apps/web/lib/developer-video-playback.ts index b987a9f5327..3591a88acfb 100644 --- a/apps/web/lib/developer-video-playback.ts +++ b/apps/web/lib/developer-video-playback.ts @@ -14,6 +14,10 @@ import { readDeveloperVideoText, signedDeveloperVideoUrl, } from "./developer-video-storage"; +import { + DEVELOPER_VIDEO_UNAVAILABLE, + developerVideoIsTakenDown, +} from "./developer-video-takedown"; import { getInfraAppStanding } from "./infra-standing"; export const PLAYBACK_ID_PATTERN = /^[A-Za-z0-9_-]{16,64}$/; @@ -49,6 +53,18 @@ function notFound(): Response { }); } +// Never cached, so restoring a video brings it back on the next request. +function gone(): Response { + return new Response(DEVELOPER_VIDEO_UNAVAILABLE, { + status: 410, + headers: { + ...PLAYBACK_CORS_HEADERS, + "Cache-Control": "no-store", + "Content-Type": "text/plain; charset=utf-8", + }, + }); +} + function paused(): Response { return new Response( "Playback is paused because the account that hosts this video is out of credit.", @@ -277,6 +293,7 @@ export async function developerPlaybackResponse( if (!PLAYBACK_ID_PATTERN.test(playbackId)) return notFound(); const video = await findPlayableVideo(playbackId); + if (video && developerVideoIsTakenDown(video)) return gone(); if (!video?.processing) return notFound(); if ((await getInfraAppStanding(video.appId)).state === "paused") { return paused(); diff --git a/apps/web/lib/developer-video-player.ts b/apps/web/lib/developer-video-player.ts index 69b8c076ae2..2caeab88dc0 100644 --- a/apps/web/lib/developer-video-player.ts +++ b/apps/web/lib/developer-video-player.ts @@ -5,6 +5,7 @@ import { } from "./developer-video-playback"; import { redriveDeveloperVideoProcessing } from "./developer-video-start"; import { developerVideoRedrive } from "./developer-video-state"; +import { developerVideoIsTakenDown } from "./developer-video-takedown"; import { getInfraAppStanding } from "./infra-standing"; const DEFAULT_WIDTH = 16; @@ -26,6 +27,7 @@ export type DeveloperVideoPlayer = DeveloperVideoFrame & | { kind: "processing"; uploading: boolean } | { kind: "failed" } | { kind: "paused" } + | { kind: "taken_down" } ); type PlayableVideo = NonNullable>>; @@ -48,6 +50,14 @@ async function redriveStalled(video: PlayableVideo, playbackId: string) { return started ? findPlayableVideo(playbackId) : video; } +// Nothing about the video reaches a page that cannot play it. +const TAKEN_DOWN: DeveloperVideoPlayer = { + name: "", + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + kind: "taken_down", +}; + export async function loadDeveloperVideoPlayer( playbackId: string, ): Promise { @@ -55,12 +65,15 @@ export async function loadDeveloperVideoPlayer( const found = await findPlayableVideo(playbackId); if (!found) return null; + if (developerVideoIsTakenDown(found)) return TAKEN_DOWN; const standing = await getInfraAppStanding(found.appId); if (standing.state === "paused") return { ...frameOf(found), kind: "paused" }; const video = await redriveStalled(found, playbackId); if (!video) return null; + // The retry reads the row again, so a takedown made meanwhile counts too. + if (developerVideoIsTakenDown(video)) return TAKEN_DOWN; const frame = frameOf(video); if (video.status === "errored") return { ...frame, kind: "failed" }; diff --git a/apps/web/lib/developer-video-takedown-store.ts b/apps/web/lib/developer-video-takedown-store.ts new file mode 100644 index 00000000000..b670bfb7311 --- /dev/null +++ b/apps/web/lib/developer-video-takedown-store.ts @@ -0,0 +1,84 @@ +import { db } from "@cap/database"; +import { + restoredMetadataSql, + takenDownMetadataSql, +} from "@cap/database/developer-video-takedown"; +import { developerVideos } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { + developerVideoIsTakenDown, + developerVideoReference, + developerVideoTakenDownAt, +} from "./developer-video-takedown"; + +export type DeveloperVideoTakedownResult = { + videoId: string; + appId: string; + playbackId: string | null; + takenDown: boolean; + takenDownAt: string | null; + changed: boolean; +}; + +type Executor = Pick, "select" | "update">; + +async function lockVideo( + tx: Executor, + column: "playbackId" | "id", + value: string, +) { + const [video] = await tx + .select({ + id: developerVideos.id, + appId: developerVideos.appId, + playbackId: developerVideos.playbackId, + metadata: developerVideos.metadata, + }) + .from(developerVideos) + .where( + and( + eq(developerVideos[column], value), + isNull(developerVideos.deletedAt), + ), + ) + .for("update"); + return video ?? null; +} + +export async function setDeveloperVideoTakedown( + input: string, + takenDown: boolean, + now: Date = new Date(), +): Promise { + const reference = developerVideoReference(input); + if (!reference) return null; + return db().transaction(async (tx) => { + const video = + (await lockVideo(tx, "playbackId", reference)) ?? + (await lockVideo(tx, "id", reference)); + if (!video) return null; + + const wasTakenDown = developerVideoIsTakenDown(video); + const changed = wasTakenDown !== takenDown; + if (changed) { + await tx + .update(developerVideos) + .set({ + metadata: takenDown + ? takenDownMetadataSql(now) + : restoredMetadataSql(), + }) + .where(eq(developerVideos.id, video.id)); + } + return { + videoId: video.id, + appId: video.appId, + playbackId: video.playbackId, + takenDown, + takenDownAt: takenDown + ? (developerVideoTakenDownAt(video.metadata) ?? now.toISOString()) + : null, + changed, + }; + }); +} diff --git a/apps/web/lib/developer-video-takedown.ts b/apps/web/lib/developer-video-takedown.ts new file mode 100644 index 00000000000..15f8c226555 --- /dev/null +++ b/apps/web/lib/developer-video-takedown.ts @@ -0,0 +1,69 @@ +import { DEVELOPER_VIDEO_TAKEDOWN_KEY } from "@cap/database/types"; + +export const DEVELOPER_VIDEO_UNAVAILABLE = "This video is not available."; + +export const DEVELOPER_VIDEO_REPORT_EMAIL = "hello@cap.so"; + +type Marker = { at: string; metadata: unknown }; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function markerOf(metadata: unknown): Marker | null { + const marker = asRecord(asRecord(metadata)?.[DEVELOPER_VIDEO_TAKEDOWN_KEY]); + if (!marker || typeof marker.at !== "string" || !("metadata" in marker)) { + return null; + } + return { at: marker.at, metadata: marker.metadata }; +} + +export function developerVideoTakenDownAt(metadata: unknown): string | null { + return markerOf(metadata)?.at ?? null; +} + +export function developerVideoIsTakenDown(video: { + metadata: unknown; +}): boolean { + return markerOf(video.metadata) !== null; +} + +export function developerVideoPublicMetadata(metadata: unknown): unknown { + const marker = markerOf(metadata); + return marker ? marker.metadata : metadata; +} + +export function withoutReservedMetadata>( + metadata: T | undefined, +): T | undefined { + if (!metadata || !(DEVELOPER_VIDEO_TAKEDOWN_KEY in metadata)) return metadata; + const { [DEVELOPER_VIDEO_TAKEDOWN_KEY]: _reserved, ...rest } = metadata; + return rest as T; +} + +export function developerVideoReportHref(playbackId: string): string { + const subject = encodeURIComponent(`Report video ${playbackId}`); + return `mailto:${DEVELOPER_VIDEO_REPORT_EMAIL}?subject=${subject}`; +} + +const REFERENCE_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + +// Staff paste whatever the report gave them: a playback id, a video id, the +// hosted or embed page, or a playback file URL. +export function developerVideoReference(input: string): string | null { + const trimmed = input.trim(); + let candidate = trimmed; + try { + const segments = new URL(trimmed).pathname.split("/").filter(Boolean); + const dev = segments.indexOf("dev"); + const playback = segments.indexOf("playback"); + candidate = + (dev >= 0 ? segments[dev + 1] : undefined) ?? + (playback >= 0 ? segments[playback + 1] : undefined) ?? + segments[0] ?? + ""; + } catch {} + return REFERENCE_PATTERN.test(candidate) ? candidate : null; +} diff --git a/apps/web/lib/infra-video-upload.ts b/apps/web/lib/infra-video-upload.ts index 52072dcf651..0e0476a842e 100644 --- a/apps/web/lib/infra-video-upload.ts +++ b/apps/web/lib/infra-video-upload.ts @@ -24,6 +24,10 @@ import { releaseDeveloperVideoUpload, startDeveloperVideoProcessing, } from "@/lib/developer-video-start"; +import { + developerVideoPublicMetadata, + withoutReservedMetadata, +} from "@/lib/developer-video-takedown"; import { canUseInfra } from "@/lib/infra-access"; import { lockInfraAccount } from "@/lib/infra-balance"; import { @@ -390,8 +394,9 @@ async function replayKeyedVideo( ? "userId" : video.maxResolutionTier !== request.maxResolutionTier ? "maxResolutionTier" - : canonicalJson(storedJson(video.metadata)) !== - canonicalJson(request.metadata) + : canonicalJson( + developerVideoPublicMetadata(storedJson(video.metadata)), + ) !== canonicalJson(request.metadata) ? "metadata" : null; if (differs) { @@ -444,7 +449,8 @@ export async function createInfraVideo({ const request: VideoRequest = { name: name ?? "Untitled", externalUserId: userId?.trim() || null, - metadata: metadata ?? null, + // The takedown marker key is Cap's own, so a client cannot set it. + metadata: withoutReservedMetadata(metadata) ?? null, maxResolutionTier: maxResolutionTier ?? DEVELOPER_VIDEO_DEFAULT_TIER, }; const videoId = idempotencyKey @@ -465,7 +471,7 @@ export async function createInfraVideo({ externalUserId: request.externalUserId, name: request.name, s3Key: `developer/${appId}/${videoId}/video`, - metadata, + metadata: request.metadata, playbackId, maxResolutionTier: request.maxResolutionTier, }); diff --git a/apps/web/lib/infra-video-view.ts b/apps/web/lib/infra-video-view.ts index 62220e56212..05b5eee0aba 100644 --- a/apps/web/lib/infra-video-view.ts +++ b/apps/web/lib/infra-video-view.ts @@ -1,10 +1,16 @@ -export type InfraVideoState = "uploading" | "processing" | "ready" | "failed"; +export type InfraVideoState = + | "uploading" + | "processing" + | "ready" + | "failed" + | "taken_down"; export const INFRA_VIDEO_STATE_LABELS: Record = { uploading: "Uploading", processing: "Processing", ready: "Ready", failed: "Failed", + taken_down: "Taken down", }; export type InfraLocalUpload = "sending" | "sent" | null; @@ -93,6 +99,7 @@ export type InfraVideoDetail = InfraVideoListItem & { type StateInput = { status: string; legacy: boolean; + takenDown: boolean; duration: number | null; width: number | null; }; @@ -100,6 +107,7 @@ type StateInput = { // Mirrors developerVideoIsReady: a video uploaded before encoding existed has // no playback id and no processing state, and is ready once it was measured. export function infraVideoState(video: StateInput): InfraVideoState { + if (video.takenDown) return "taken_down"; if (video.status === "ready") return "ready"; if (video.legacy && video.duration !== null && video.width !== null) { return "ready"; @@ -194,7 +202,8 @@ export function toInfraVideoDetail( page: { shareUrl: string; embedUrl: string } | null = null, ): InfraVideoDetail { const item = toInfraVideoListItem(row); - const shared = page !== null && item.state !== "failed"; + const shared = + page !== null && item.state !== "failed" && item.state !== "taken_down"; return { ...item, width: row.width, diff --git a/packages/database/developer-video-takedown.ts b/packages/database/developer-video-takedown.ts new file mode 100644 index 00000000000..d0b7405b1db --- /dev/null +++ b/packages/database/developer-video-takedown.ts @@ -0,0 +1,31 @@ +import { type SQL, sql } from "drizzle-orm"; +import { developerVideos } from "./schema"; +import { DEVELOPER_VIDEO_TAKEDOWN_KEY } from "./types/developer-video"; + +// A takedown is stored in the metadata column so it needs no migration and +// leaves status alone: storage billing groups by status, and the encoding +// workflows rewrite processing, so neither can carry it. The developer's own +// metadata is kept whole inside the marker and written back on restore. +const MARKER_PATH = `$."${DEVELOPER_VIDEO_TAKEDOWN_KEY}"`; +const AT_PATH = `${MARKER_PATH}.at`; +const KEPT_PATH = `${MARKER_PATH}.metadata`; + +// Both rewrites run in SQL so the developer's metadata never passes through +// JSON.parse, which would round integers past 2^53 on the way back. A video +// with no metadata goes back to SQL NULL rather than a JSON null. +export function takenDownMetadataSql(now: Date): SQL { + return sql`JSON_OBJECT(${DEVELOPER_VIDEO_TAKEDOWN_KEY}, JSON_OBJECT('at', ${now.toISOString()}, 'metadata', ${developerVideos.metadata}))`; +} + +export function restoredMetadataSql(): SQL { + return sql`CASE WHEN JSON_TYPE(JSON_EXTRACT(${developerVideos.metadata}, ${KEPT_PATH})) = 'NULL' THEN NULL ELSE JSON_EXTRACT(${developerVideos.metadata}, ${KEPT_PATH}) END`; +} + +// For queries that list videos without loading their metadata. It matches +// developerVideoIsTakenDown in the web app. Without the COALESCE a row with no +// metadata reads as NULL, which drizzle passes through without mapWith. +export function developerVideoTakenDownSql(): SQL { + return sql`COALESCE((JSON_TYPE(JSON_EXTRACT(${developerVideos.metadata}, ${AT_PATH})) = 'STRING' AND JSON_CONTAINS_PATH(${developerVideos.metadata}, 'one', ${KEPT_PATH})), FALSE)`.mapWith( + (value) => Number(value) === 1, + ); +} diff --git a/packages/database/package.json b/packages/database/package.json index caf5703f489..87e190b3122 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -76,6 +76,7 @@ "./emails/config": "./emails/config.ts", "./emails/*": "./emails/*.tsx", "./types": "./types/index.ts", + "./developer-video-takedown": "./developer-video-takedown.ts", "./migrate": "./migrate.ts" }, "publishConfig": { diff --git a/packages/database/types/developer-video.ts b/packages/database/types/developer-video.ts index 7efc8051b2d..646a741045a 100644 --- a/packages/database/types/developer-video.ts +++ b/packages/database/types/developer-video.ts @@ -66,3 +66,7 @@ export interface DeveloperVideoProcessingState { poster: number; }; } + +// A video Cap staff took down keeps its own metadata under this key. See +// developer-video-takedown.ts for why the flag lives in metadata. +export const DEVELOPER_VIDEO_TAKEDOWN_KEY = "__capTakedown"; diff --git a/packages/web-backend/src/AgentManagement.ts b/packages/web-backend/src/AgentManagement.ts index 8298434efad..04c535d791c 100644 --- a/packages/web-backend/src/AgentManagement.ts +++ b/packages/web-backend/src/AgentManagement.ts @@ -1,3 +1,4 @@ +import { developerVideoTakenDownSql } from "@cap/database/developer-video-takedown"; import * as Db from "@cap/database/schema"; import { type Folder, @@ -29,6 +30,7 @@ const developerVideoColumns = { renditions: Db.developerVideos.renditions, transcriptionStatus: Db.developerVideos.transcriptionStatus, processing: Db.developerVideos.processing, + takenDown: developerVideoTakenDownSql(), createdAt: Db.developerVideos.createdAt, updatedAt: Db.developerVideos.updatedAt, }; diff --git a/packages/web-domain/src/Agent.ts b/packages/web-domain/src/Agent.ts index 1d7452e556c..6692dc3d7c7 100644 --- a/packages/web-domain/src/Agent.ts +++ b/packages/web-domain/src/Agent.ts @@ -1115,6 +1115,7 @@ export const AgentDeveloperVideo = Schema.Struct({ }), ), transcriptionStatus: Schema.NullOr(Schema.String), + takenDown: Schema.optional(Schema.Boolean), hlsUrl: Schema.NullOr(Schema.String), mp4Url: Schema.NullOr(Schema.String), posterUrl: Schema.NullOr(Schema.String), diff --git a/scripts/infra-video-takedown.ts b/scripts/infra-video-takedown.ts new file mode 100644 index 00000000000..47988610a13 --- /dev/null +++ b/scripts/infra-video-takedown.ts @@ -0,0 +1,99 @@ +import { pathToFileURL } from "node:url"; + +const USAGE = `Take a Cap Infra video down, or restore it. + + bun scripts/infra-video-takedown.ts down