feat: integrate Bunny Stream course videos
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
115
server/src/providers/bunny.ts
Normal file
115
server/src/providers/bunny.ts
Normal 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() };
|
||||
}
|
||||
@@ -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 } };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
82
server/src/routes/media.ts
Normal file
82
server/src/routes/media.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user