feat: integrate Bunny Stream course videos
All checks were successful
CI / Validate frontend and API (push) Successful in 1m50s
CI / Build and publish Docker images (push) Successful in 18s

This commit is contained in:
Cauê Faleiros
2026-09-02 10:09:36 -03:00
parent 3d4c72e85c
commit 2c137529bc
15 changed files with 607 additions and 40 deletions

View File

@@ -9,10 +9,17 @@ import { courseRoutes } from './routes/courses.js';
import { manageCourseRoutes } from './routes/manage-courses.js';
import { learningRoutes } from './routes/learning.js';
import { adminRoutes } from './routes/admin.js';
import { mediaRoutes } from './routes/media.js';
export function buildApp() {
const app = Fastify({ logger: true });
// Bunny uploads are streamed through the authenticated API. Keeping the body
// as a stream avoids loading a whole course video into Node's memory.
const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload);
app.addContentTypeParser('application/octet-stream', rawUploadParser);
app.addContentTypeParser(/^video\/.+$/, rawUploadParser);
app.register(cors, {
origin: config.FRONTEND_ORIGIN,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
@@ -42,6 +49,7 @@ export function buildApp() {
app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
app.register(mediaRoutes, { prefix: '/api/v1/manage/media' });
app.register(learningRoutes, { prefix: '/api/v1' });
app.register(adminRoutes, { prefix: '/api/v1/admin' });
return app;

View File

@@ -15,6 +15,10 @@ const environmentSchema = z.object({
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10),
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
BUNNY_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(5120).default(5120),
});
export const config = environmentSchema.parse(process.env);
@@ -22,3 +26,13 @@ export const config = environmentSchema.parse(process.env);
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') {
throw new Error('JWT_SECRET must be set to a unique value in production.');
}
const bunnyConfigurationValues = [
config.BUNNY_STREAM_LIBRARY_ID,
config.BUNNY_STREAM_API_KEY,
config.BUNNY_EMBED_TOKEN_KEY,
];
if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) {
throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY, and BUNNY_EMBED_TOKEN_KEY must be configured together.');
}

View File

@@ -0,0 +1,115 @@
import { createHash } from 'node:crypto';
import { config } from '../config.js';
const BUNNY_VIDEO_API = 'https://video.bunnycdn.com';
export type BunnyMediaStatus = 'processing' | 'ready' | 'failed';
export type BunnyVideo = {
id: string;
title: string;
status: BunnyMediaStatus;
providerStatus: number;
encodeProgress: number;
durationSeconds: number | null;
};
type BunnyApiVideo = {
guid: string;
title: string;
status: number;
encodeProgress?: number;
length?: number;
};
export class BunnyConfigurationError extends Error {}
export class BunnyRequestError extends Error {}
function getBunnyConfiguration() {
if (!config.BUNNY_STREAM_LIBRARY_ID || !config.BUNNY_STREAM_API_KEY || !config.BUNNY_EMBED_TOKEN_KEY) {
throw new BunnyConfigurationError('Bunny Stream is not configured. Ask an administrator to configure the Bunny environment variables.');
}
return {
libraryId: config.BUNNY_STREAM_LIBRARY_ID,
apiKey: config.BUNNY_STREAM_API_KEY,
embedTokenKey: config.BUNNY_EMBED_TOKEN_KEY,
};
}
export function isBunnyConfigured() {
return Boolean(config.BUNNY_STREAM_LIBRARY_ID && config.BUNNY_STREAM_API_KEY && config.BUNNY_EMBED_TOKEN_KEY);
}
function toMediaStatus(status: number): BunnyMediaStatus {
if (status === 3 || status === 4) return 'ready';
if (status === 5 || status === 8) return 'failed';
return 'processing';
}
function toVideo(video: BunnyApiVideo): BunnyVideo {
return {
id: video.guid,
title: video.title,
status: toMediaStatus(video.status),
providerStatus: video.status,
encodeProgress: Math.max(0, Math.min(100, Math.round(video.encodeProgress ?? 0))),
durationSeconds: typeof video.length === 'number' && video.length > 0 ? Math.round(video.length) : null,
};
}
async function bunnyRequest(path: string, init: RequestInit = {}) {
const { libraryId, apiKey } = getBunnyConfiguration();
const response = await fetch(`${BUNNY_VIDEO_API}/library/${libraryId}${path}`, {
...init,
headers: {
AccessKey: apiKey,
...init.headers,
},
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyRequestError(`Bunny Stream request failed (${response.status})${detail ? `: ${detail.slice(0, 300)}` : ''}`);
}
return response;
}
export async function createBunnyVideo(title: string) {
const response = await bunnyRequest('/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
return toVideo(await response.json() as BunnyApiVideo);
}
export async function uploadBunnyVideo(videoId: string, body: ReadableStream, contentType: string | undefined) {
await bunnyRequest(`/videos/${encodeURIComponent(videoId)}`, {
method: 'PUT',
headers: { 'Content-Type': contentType || 'application/octet-stream' },
body,
// Required by Node's fetch implementation when a request body is streamed.
duplex: 'half',
} as RequestInit);
return getBunnyVideo(videoId);
}
export async function getBunnyVideo(videoId: string) {
const response = await bunnyRequest(`/videos/${encodeURIComponent(videoId)}`);
return toVideo(await response.json() as BunnyApiVideo);
}
export function signedBunnyEmbedUrl(videoId: string, validitySeconds = 10 * 60) {
const { libraryId, embedTokenKey } = getBunnyConfiguration();
const expires = Math.floor(Date.now() / 1000) + validitySeconds;
const token = createHash('sha256').update(`${embedTokenKey}${videoId}${expires}`).digest('hex');
const url = new URL(`https://iframe.mediadelivery.net/embed/${libraryId}/${encodeURIComponent(videoId)}`);
url.searchParams.set('token', token);
url.searchParams.set('expires', String(expires));
url.searchParams.set('autoplay', 'true');
url.searchParams.set('responsive', 'true');
return { embedUrl: url.toString(), expiresAt: new Date(expires * 1000).toISOString() };
}

View File

@@ -1,12 +1,17 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js';
const paramsSchema = z.object({
courseId: z.string().uuid(),
});
const playbackParamsSchema = z.object({
courseId: z.string().uuid(),
lessonId: z.string().uuid(),
});
export const courseSelect = (publicOnly = false) => `
export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => `
select
c.id,
c.slug,
@@ -46,6 +51,7 @@ export const courseSelect = (publicOnly = false) => `
jsonb_build_object(
'id', lm.id,
'provider', lm.provider,
'externalId', lm.external_id,
'status', lm.status,
'embedUrl', lm.embed_url,
'playbackUrl', lm.playback_url,
@@ -53,7 +59,7 @@ export const courseSelect = (publicOnly = false) => `
) order by lm.created_at
)
from lesson_media lm
where lm.lesson_id = l.id and lm.status = 'ready'
where lm.lesson_id = l.id ${includeUnreadyMedia ? '' : "and lm.status = 'ready'"}
), '[]'::jsonb)
) order by l.position
)
@@ -135,4 +141,42 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
return { data: authenticated ? course : withoutProtectedMedia(course) };
});
app.get('/:courseId/lessons/:lessonId/playback', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId, lessonId } = playbackParamsSchema.parse(request.params);
const result = await pool.query<{
provider: string;
external_id: string;
playback_url: string | null;
embed_url: string | null;
status: string;
}>(
`select lm.provider, lm.external_id, lm.playback_url, lm.embed_url, lm.status
from lesson_media lm
join lessons l on l.id = lm.lesson_id
join courses c on c.id = l.course_id
where c.id = $1 and l.id = $2 and c.status = 'published'
order by lm.created_at
limit 1`,
[courseId, lessonId],
);
const media = result.rows[0];
if (!media) return reply.code(404).send({ error: 'Lesson media was not found' });
if (media.status === 'processing') return reply.code(409).send({ error: 'This video is still being processed by Bunny Stream.' });
if (media.status === 'failed') return reply.code(409).send({ error: 'This video could not be processed. Please contact the instructor.' });
if (media.provider === 'bunny') {
try {
const playback = signedBunnyEmbedUrl(media.external_id);
return { data: { provider: 'bunny', kind: 'embed', ...playback } };
} catch (error) {
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: 'Bunny playback is not configured yet.' });
throw error;
}
}
const source = media.playback_url || media.embed_url;
if (!source) return reply.code(409).send({ error: 'This lesson does not have a playable video URL.' });
return { data: { provider: media.provider, kind: 'video', source } };
});
};

View File

@@ -159,7 +159,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const filters = request.user.role === 'admin'
? { sql: "where c.status <> 'archived' order by c.created_at desc", values: [] as string[] }
: { sql: "where c.instructor_id = $1 and c.status <> 'archived' order by c.created_at desc", values: [request.user.id] };
const result = await pool.query(`${courseSelect()} ${filters.sql}`, filters.values);
const result = await pool.query(`${courseSelect(false, true)} ${filters.sql}`, filters.values);
return { data: result.rows };
});

View File

@@ -0,0 +1,82 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { recordAudit } from '../audit.js';
import { pool } from '../db/pool.js';
import {
BunnyConfigurationError,
BunnyRequestError,
createBunnyVideo,
getBunnyVideo,
isBunnyConfigured,
uploadBunnyVideo,
} from '../providers/bunny.js';
import { config } from '../config.js';
const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) });
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'Bunny Stream could not complete this request. Please try again.' });
throw error;
}
async function persistBunnyStatus(video: { id: string; status: string; durationSeconds: number | null }) {
await pool.query(
`update lesson_media
set status = $2::media_status, duration_seconds = coalesce($3, duration_seconds)
where provider = 'bunny' and external_id = $1`,
[video.id, video.status, video.durationSeconds],
);
}
export const mediaRoutes: FastifyPluginAsync = async (app) => {
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
app.get('/bunny/config', manageAccess, async () => ({
data: {
configured: isBunnyConfigured(),
maxUploadBytes: config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024,
},
}));
app.post('/bunny/videos', manageAccess, async (request, reply) => {
const input = createVideoSchema.parse(request.body);
try {
const video = await createBunnyVideo(input.title);
await recordAudit({ actorId: request.user.id, action: 'media.bunny.created', subjectType: 'video', subjectId: video.id, metadata: { title: video.title }, ipAddress: request.ip });
return reply.code(201).send({ data: video });
} catch (error) {
return providerError(reply, error);
}
});
app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params);
const contentLength = Number(request.headers['content-length'] || 0);
const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024;
if (contentLength > maxBytes) {
return reply.code(413).send({ error: `Video is larger than the ${config.BUNNY_MAX_UPLOAD_MB} MB upload limit.` });
}
try {
const video = await uploadBunnyVideo(videoId, request.body as ReadableStream, request.headers['content-type']);
await persistBunnyStatus(video);
await recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip });
return { data: video };
} catch (error) {
return providerError(reply, error);
}
});
app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params);
try {
const video = await getBunnyVideo(videoId);
await persistBunnyStatus(video);
return { data: video };
} catch (error) {
return providerError(reply, error);
}
});
};