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
119 changes: 119 additions & 0 deletions app/api/routes-f/camera-angles/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { GET, POST } from "../route";
import { getViewerAngle, resetStore } from "../store";

function getReq(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/routes-f/camera-angles${query}`
);
}

function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/routes-f/camera-angles", {
method: "POST",
body: JSON.stringify(body),
});
}

beforeEach(() => {
resetStore();
});

describe("GET /api/routes-f/camera-angles", () => {
it("requires stream_id", async () => {
const res = await GET(getReq());
expect(res.status).toBe(400);
});

it("404s for an unknown stream", async () => {
const res = await GET(getReq("?stream_id=nope"));
expect(res.status).toBe(404);
});

it("lists angles for a multi-angle stream", async () => {
const res = await GET(getReq("?stream_id=stream_multi_1"));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.angles).toHaveLength(3);
expect(body.angles[0]).toMatchObject({
id: expect.any(String),
label: expect.any(String),
playback_id: expect.any(String),
});
expect(body.angles.map((a: { id: string }) => a.id)).toEqual([
"main",
"caster",
"map",
]);
});
});

describe("POST /api/routes-f/camera-angles", () => {
it("requires viewer_id, stream_id, and angle_id", async () => {
const res = await POST(postReq({ stream_id: "stream_multi_1" }));
expect(res.status).toBe(400);
});

it("404s for an unknown stream", async () => {
const res = await POST(
postReq({
viewer_id: "v1",
stream_id: "nope",
angle_id: "main",
})
);
expect(res.status).toBe(404);
});

it("rejects an unknown angle", async () => {
const res = await POST(
postReq({
viewer_id: "v1",
stream_id: "stream_multi_1",
angle_id: "backstage",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain("angle_id");
});

it("stores the viewer's angle selection", async () => {
const res = await POST(
postReq({
viewer_id: "v1",
stream_id: "stream_multi_1",
angle_id: "caster",
})
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({
viewer_id: "v1",
stream_id: "stream_multi_1",
angle_id: "caster",
});
expect(getViewerAngle("v1", "stream_multi_1")?.angle_id).toBe("caster");
});

it("overwrites a previous selection for the same viewer+stream", async () => {
await POST(
postReq({
viewer_id: "v2",
stream_id: "stream_multi_2",
angle_id: "stage",
})
);
await POST(
postReq({
viewer_id: "v2",
stream_id: "stream_multi_2",
angle_id: "crowd",
})
);
expect(getViewerAngle("v2", "stream_multi_2")?.angle_id).toBe("crowd");
});
});
94 changes: 94 additions & 0 deletions app/api/routes-f/camera-angles/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { findAngle, getStreamById } from "./seed";
import { setViewerAngle } from "./store";
import type {
AnglesListResponse,
SelectAngleBody,
SelectAngleResponse,
} from "./types";

/**
* GET /api/routes-f/camera-angles?stream_id=stream_multi_1
*
* Lists available camera angles for a multi-angle stream.
*/
export async function GET(req: NextRequest): Promise<NextResponse> {
const streamId = req.nextUrl.searchParams.get("stream_id");
if (!streamId) {
return NextResponse.json(
{ error: "stream_id is required" },
{ status: 400 }
);
}

const stream = getStreamById(streamId);
if (!stream) {
return NextResponse.json(
{ error: `unknown stream_id: ${streamId}` },
{ status: 404 }
);
}

return NextResponse.json({
angles: stream.angles,
} satisfies AnglesListResponse);
}

/**
* POST /api/routes-f/camera-angles
* Body: { viewer_id, stream_id, angle_id }
*
* Stores the viewer's selected camera angle for a stream.
*/
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: SelectAngleBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}

const { viewer_id, stream_id, angle_id } = body;

if (!viewer_id || typeof viewer_id !== "string") {
return NextResponse.json(
{ error: "viewer_id is required" },
{ status: 400 }
);
}
if (!stream_id || typeof stream_id !== "string") {
return NextResponse.json(
{ error: "stream_id is required" },
{ status: 400 }
);
}
if (!angle_id || typeof angle_id !== "string") {
return NextResponse.json(
{ error: "angle_id is required" },
{ status: 400 }
);
}

const match = findAngle(stream_id, angle_id);
if (!match) {
const stream = getStreamById(stream_id);
if (!stream) {
return NextResponse.json(
{ error: `unknown stream_id: ${stream_id}` },
{ status: 404 }
);
}
return NextResponse.json(
{ error: `unknown angle_id: ${angle_id}` },
{ status: 400 }
);
}

setViewerAngle(viewer_id, stream_id, angle_id);

return NextResponse.json({
viewer_id,
stream_id,
angle_id,
} satisfies SelectAngleResponse);
}
46 changes: 46 additions & 0 deletions app/api/routes-f/camera-angles/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { MultiAngleStream } from "./types";

/** Seed streams that broadcast from multiple Mux camera angles. */
export function getMultiAngleStreams(): MultiAngleStream[] {
return [
{
stream_id: "stream_multi_1",
title: "Esports Finals — Multi-Cam",
angles: [
{ id: "main", label: "Main Stage", playback_id: "mux_playback_main_01" },
{ id: "caster", label: "Caster Desk", playback_id: "mux_playback_caster_01" },
{ id: "map", label: "Map Overview", playback_id: "mux_playback_map_01" },
],
},
{
stream_id: "stream_multi_2",
title: "Music Festival Live",
angles: [
{ id: "stage", label: "Main Stage", playback_id: "mux_playback_stage_02" },
{ id: "crowd", label: "Crowd Cam", playback_id: "mux_playback_crowd_02" },
],
},
{
stream_id: "stream_single_1",
title: "Solo Creator Stream",
angles: [
{ id: "primary", label: "Primary", playback_id: "mux_playback_solo_01" },
],
},
];
}

export function getStreamById(streamId: string): MultiAngleStream | undefined {
return getMultiAngleStreams().find(s => s.stream_id === streamId);
}

export function findAngle(
streamId: string,
angleId: string
): { stream: MultiAngleStream; angle: MultiAngleStream["angles"][number] } | undefined {
const stream = getStreamById(streamId);
if (!stream) return undefined;
const angle = stream.angles.find(a => a.id === angleId);
if (!angle) return undefined;
return { stream, angle };
}
34 changes: 34 additions & 0 deletions app/api/routes-f/camera-angles/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ViewerAngleSelection } from "./types";

const selections = new Map<string, ViewerAngleSelection>();

function selectionKey(viewerId: string, streamId: string): string {
return `${viewerId}:${streamId}`;
}

export function setViewerAngle(
viewerId: string,
streamId: string,
angleId: string,
now: number = Date.now()
): ViewerAngleSelection {
const record: ViewerAngleSelection = {
viewer_id: viewerId,
stream_id: streamId,
angle_id: angleId,
selected_at: new Date(now).toISOString(),
};
selections.set(selectionKey(viewerId, streamId), record);
return record;
}

export function getViewerAngle(
viewerId: string,
streamId: string
): ViewerAngleSelection | undefined {
return selections.get(selectionKey(viewerId, streamId));
}

export function resetStore(): void {
selections.clear();
}
34 changes: 34 additions & 0 deletions app/api/routes-f/camera-angles/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export interface CameraAngle {
id: string;
label: string;
playback_id: string;
}

export interface MultiAngleStream {
stream_id: string;
title: string;
angles: CameraAngle[];
}

export interface AnglesListResponse {
angles: CameraAngle[];
}

export interface SelectAngleBody {
viewer_id: string;
stream_id: string;
angle_id: string;
}

export interface SelectAngleResponse {
viewer_id: string;
stream_id: string;
angle_id: string;
}

export interface ViewerAngleSelection {
viewer_id: string;
stream_id: string;
angle_id: string;
selected_at: string;
}
Loading
Loading