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

@@ -18,6 +18,14 @@ AUTH_RATE_LIMIT_MAX=10
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
SUPERADMIN_NAME=Compor HUB Superadmin
# Bunny Stream. Set all three in Portainer to enable instructor uploads and
# signed embedded playback. Keep every value server-side; none are VITE_ vars.
BUNNY_STREAM_LIBRARY_ID=
BUNNY_STREAM_API_KEY=
BUNNY_EMBED_TOKEN_KEY=
# Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf.
BUNNY_MAX_UPLOAD_MB=5120
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web

View File

@@ -2,6 +2,30 @@
Use `docker-compose.yml` as a Portainer Stack from this repository. It deploys the frontend, API, and PostgreSQL as one internal Docker network. Only the web container exposes a port; it proxies `/api` to the API container.
## Bunny Stream video hosting
To let instructors upload protected course videos, configure these API environment variables in the Portainer Stack. They are server secrets: never add a `VITE_` version or place them in the frontend container.
```env
BUNNY_STREAM_LIBRARY_ID=123456
BUNNY_STREAM_API_KEY=your-bunny-library-api-key
BUNNY_EMBED_TOKEN_KEY=your-bunny-embed-view-token-key
BUNNY_MAX_UPLOAD_MB=5120
```
In Bunny Stream, create one Video Library using the Volume tier. In that library's security settings, enable MediaCage Basic DRM and Embed View Token Authentication, then copy its token key into `BUNNY_EMBED_TOKEN_KEY`. Disable Direct Play and MP4 fallback if they are not needed, and add the Academy production hostname to Bunny's allowed referrers. This keeps playback inside Bunny's protected iframe and makes the Academy API issue a short-lived embed token only after a logged-in user requests a lesson.
The deployed Nginx proxy permits uploads up to **5 GB**. Keep `BUNNY_MAX_UPLOAD_MB` at or below `5120`; reduce it if you want a smaller application-level limit. Large uploads stream through the API rather than being held in memory.
After deploying the new images:
1. Sign in as an instructor and open **Gerenciar Cursos**.
2. Enter the lesson title first, then choose **Selecionar vídeo** in the Bunny Stream section.
3. Wait for the upload message, add the lesson, and save the course. Bunny encoding continues asynchronously.
4. The editor refreshes Bunny processing status automatically; it also has an **Atualizar** action for a pending lesson.
The API key and embed-token key never reach the browser. The instructor browser uploads to the authenticated Academy API, which sends the file to Bunny; learners receive only a signed iframe URL.
This is a Docker Swarm stack: Portainer pulls prebuilt API and web images from the Gitea Container Registry. It never builds Dockerfiles itself.
## Gitea Actions registry secrets

View File

@@ -22,6 +22,7 @@ import { useAuth } from '../context/AuthContext';
import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db';
import { LoginModal } from './LoginModal';
import { MaterialCard } from './MaterialCard';
import { courseApi, LessonPlayback } from '../services/api';
interface CoursePlayerModalProps {
course: Course | null;
@@ -38,6 +39,8 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
const [copiedLink, setCopiedLink] = useState(false);
const [playback, setPlayback] = useState<LessonPlayback | null>(null);
const [playbackError, setPlaybackError] = useState('');
const videoRef = useRef<HTMLVideoElement>(null);
const lastProgressSave = useRef<Record<string, number>>({});
@@ -70,6 +73,27 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
}
}, [course, user]);
// The API verifies the session before returning a time-limited Bunny embed
// URL. A video provider key is never sent to the browser.
useEffect(() => {
if (!course || !user || !currentLesson) {
setPlayback(null);
setPlaybackError('');
return;
}
let cancelled = false;
setPlayback(null);
setPlaybackError('');
courseApi.playback(course.id, currentLesson.id)
.then((response) => {
if (!cancelled) setPlayback(response.data);
})
.catch((error) => {
if (!cancelled) setPlaybackError(error instanceof Error ? error.message : 'Não foi possível carregar este vídeo.');
});
return () => { cancelled = true; };
}, [course, user, currentLesson]);
// Persist completed lessons and recalculate progress
const updateCompletedLessons = (newCompleted: string[]) => {
setCompletedLessonIds(newCompleted);
@@ -239,19 +263,38 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
{/* Video Player Section */}
<div className="w-full aspect-video bg-black rounded-[8px] overflow-hidden relative shadow-xl border border-white/10 group">
{canWatch ? (
<video
key={currentLesson?.id}
ref={videoRef}
src={currentLesson?.videoUrl}
className="w-full h-full object-contain rounded-[8px]"
controls
autoPlay
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onLoadedMetadata={restoreWatchPosition}
>
Seu navegador não suporta a tag de vídeo.
</video>
playback ? (
playback.kind === 'embed' && playback.embedUrl ? (
<iframe
key={currentLesson?.id}
src={playback.embedUrl}
title={currentLesson?.title || 'Vídeo da aula'}
className="w-full h-full border-0 rounded-[8px]"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
/>
) : playback.source ? (
<video
key={currentLesson?.id}
ref={videoRef}
src={playback.source}
className="w-full h-full object-contain rounded-[8px]"
controls
autoPlay
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onLoadedMetadata={restoreWatchPosition}
>
Seu navegador não suporta a tag de vídeo.
</video>
) : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-gray-400">Vídeo indisponível.</div>
)
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-6 text-sm text-gray-400">
{playbackError ? <span className="text-amber-300">{playbackError}</span> : <span>Carregando vídeo seguro...</span>}
</div>
)
) : (
// Locked State
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-10 text-center p-6 rounded-[8px]">

View File

@@ -28,6 +28,10 @@ services:
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD:-}
SUPERADMIN_NAME: ${SUPERADMIN_NAME:-Compor HUB Superadmin}
BUNNY_STREAM_LIBRARY_ID: ${BUNNY_STREAM_LIBRARY_ID:-}
BUNNY_STREAM_API_KEY: ${BUNNY_STREAM_API_KEY:-}
BUNNY_EMBED_TOKEN_KEY: ${BUNNY_EMBED_TOKEN_KEY:-}
BUNNY_MAX_UPLOAD_MB: ${BUNNY_MAX_UPLOAD_MB:-5120}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
interval: 15s

View File

@@ -3,6 +3,9 @@ server {
server_name _;
root /usr/share/nginx/html;
index index.html;
# Bunny uploads pass through this reverse proxy. Keep the same 5 GB ceiling
# as the API default and stream bodies instead of buffering them on disk.
client_max_body_size 5g;
# Docker Swarm may start this container before the API service receives a DNS
# record. Resolving through Docker DNS at request time keeps Nginx alive while
@@ -14,6 +17,10 @@ server {
location /api/ {
proxy_pass http://$api_upstream:3001;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -25,12 +25,12 @@ import {
Globe,
Download
} from 'lucide-react';
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
import { useToast } from '../context/ToastContext';
import { getManagedCourses, saveCourse, deleteCourse, getComments, replyToComment, moderateComment } from '../services/db';
import { CATEGORIES } from '../constants';
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
import { instructorApi, InstructorAnalytics } from '../services/api';
import { BunnyVideo, instructorApi, InstructorAnalytics } from '../services/api';
// 1. Create/Edit Course Modal
const CourseModal: React.FC<{
@@ -55,7 +55,11 @@ const CourseModal: React.FC<{
const [newLessonDuration, setNewLessonDuration] = useState('');
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
const [newLessonProvider, setNewLessonProvider] = useState('external');
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null);
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle');
const [bunnyUploadMessage, setBunnyUploadMessage] = useState('');
// Tips & Global Attachments
const [tips, setTips] = useState<string[]>([]);
@@ -96,16 +100,114 @@ const CourseModal: React.FC<{
setAttachments([]);
setLessons([]);
}
setNewLessonMedia(null);
setBunnyUploadState('idle');
setBunnyUploadMessage('');
instructorApi.bunnyConfiguration()
.then((response) => setBunnyConfigured(response.data.configured))
.catch(() => setBunnyConfigured(false));
}
}, [isOpen, initialData]);
const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({
provider: 'bunny',
externalId: video.id,
status: video.status,
durationSeconds: video.durationSeconds,
});
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
if (!newLessonTitle.trim()) {
alert('Informe o título da aula antes de enviar o vídeo.');
return;
}
if (!bunnyConfigured) {
alert('A integração com Bunny Stream ainda não foi configurada pelo administrador.');
return;
}
setBunnyUploadState('uploading');
setBunnyUploadMessage(`Enviando ${file.name} para Bunny Stream...`);
try {
const created = await instructorApi.createBunnyVideo(newLessonTitle.trim());
const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file);
const media = toBunnyMedia(uploaded.data);
setNewLessonMedia(media);
if (!newLessonDuration.trim() && uploaded.data.durationSeconds) {
const minutes = Math.max(1, Math.round(uploaded.data.durationSeconds / 60));
setNewLessonDuration(`${minutes}m`);
}
setBunnyUploadState('uploaded');
setBunnyUploadMessage(uploaded.data.status === 'ready'
? 'Vídeo pronto para ser adicionado à aula.'
: 'Upload concluído. Bunny Stream está processando o vídeo; você já pode salvar a aula.');
} catch (error) {
setBunnyUploadState('error');
setBunnyUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar o vídeo para Bunny Stream.');
}
};
const refreshLessonMedia = async (lessonId: string) => {
const lesson = lessons.find((item) => item.id === lessonId);
const media = lesson?.media?.[0];
if (!lesson || !media || media.provider !== 'bunny') return;
try {
const response = await instructorApi.bunnyVideoStatus(media.externalId);
const refreshed = toBunnyMedia(response.data);
setLessons((current) => current.map((item) => item.id === lessonId ? {
...item,
duration: refreshed.durationSeconds ? `${Math.max(1, Math.round(refreshed.durationSeconds / 60))}m` : item.duration,
media: [refreshed],
} : item));
} catch (error) {
alert(error instanceof Error ? error.message : 'Não foi possível atualizar o status do vídeo.');
}
};
// Bunny encodes asynchronously. While the course editor stays open, keep
// pending lessons up to date and persist the latest state on the API.
useEffect(() => {
if (!isOpen) return;
const pending = lessons
.map((lesson) => ({ lesson, media: lesson.media?.[0] }))
.filter((item): item is { lesson: Lesson; media: LessonMedia } => item.media?.provider === 'bunny' && item.media.status === 'processing');
if (!pending.length) return;
const timer = window.setTimeout(() => {
Promise.all(pending.map(async ({ lesson, media }) => {
try {
const response = await instructorApi.bunnyVideoStatus(media.externalId);
return { lessonId: lesson.id, media: toBunnyMedia(response.data) };
} catch {
return null;
}
})).then((updates) => {
const available = updates.filter((update): update is { lessonId: string; media: LessonMedia } => Boolean(update));
if (!available.length) return;
setLessons((current) => current.map((lesson) => {
const update = available.find((item) => item.lessonId === lesson.id);
if (!update) return lesson;
return {
...lesson,
duration: update.media.durationSeconds ? `${Math.max(1, Math.round(update.media.durationSeconds / 60))}m` : lesson.duration,
media: [update.media],
};
}));
});
}, 10_000);
return () => window.clearTimeout(timer);
}, [isOpen, lessons]);
const handleAddLesson = () => {
if (!newLessonTitle.trim()) {
alert("Digite um título para a aula.");
return;
}
if (!newLessonVideoUrl.trim()) {
alert("Enter a hosted video URL.");
if (!newLessonMedia && !newLessonVideoUrl.trim()) {
alert('Envie um vídeo para Bunny Stream ou informe uma URL de vídeo hospedado.');
return;
}
@@ -113,8 +215,9 @@ const CourseModal: React.FC<{
id: `lesson-${Date.now()}`,
title: newLessonTitle,
duration: newLessonDuration || '15m',
videoUrl: newLessonVideoUrl,
mediaProvider: newLessonProvider || 'external',
videoUrl: newLessonMedia?.playbackUrl || newLessonVideoUrl,
mediaProvider: newLessonMedia?.provider || newLessonProvider || 'external',
media: newLessonMedia ? [newLessonMedia] : undefined,
isFree: newLessonIsFree,
description: ''
};
@@ -125,7 +228,10 @@ const CourseModal: React.FC<{
setNewLessonDuration('');
setNewLessonVideoUrl('');
setNewLessonProvider('external');
setNewLessonMedia(null);
setNewLessonIsFree(false);
setBunnyUploadState('idle');
setBunnyUploadMessage('');
};
const removeLesson = (id: string) => {
@@ -431,6 +537,11 @@ const CourseModal: React.FC<{
<h4 className="text-white font-medium text-xs md:text-sm truncate">{lesson.title}</h4>
<div className="flex items-center gap-2 text-[11px] text-gray-500 mt-0.5">
<span className="flex items-center gap-1"><Video className="w-3 h-3 text-orange-400/70"/> {lesson.duration}</span>
{lesson.media?.[0]?.provider === 'bunny' && (
<span className={`font-bold px-1.5 py-0.2 rounded text-[9px] uppercase ${lesson.media[0].status === 'ready' ? 'text-emerald-400 bg-emerald-500/10' : lesson.media[0].status === 'failed' ? 'text-red-400 bg-red-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
Bunny: {lesson.media[0].status === 'ready' ? 'pronto' : lesson.media[0].status === 'failed' ? 'falhou' : 'processando'}
</span>
)}
{lesson.isFree ? (
<span className="text-emerald-400 font-bold bg-emerald-500/10 px-1.5 py-0.2 rounded text-[9px] uppercase">GRÁTIS</span>
) : (
@@ -440,6 +551,16 @@ const CourseModal: React.FC<{
</div>
<div className="flex items-center gap-1">
{lesson.media?.[0]?.provider === 'bunny' && lesson.media[0].status !== 'ready' && (
<button
type="button"
onClick={() => refreshLessonMedia(lesson.id)}
className="px-2 py-1 text-[10px] text-orange-300 hover:bg-orange-500/10 rounded-lg transition-colors"
title="Atualizar status do vídeo"
>
Atualizar
</button>
)}
<button
type="button"
onClick={() => toggleLessonAccess(lesson.id)}
@@ -484,19 +605,46 @@ const CourseModal: React.FC<{
/>
</div>
<div className="flex gap-2 items-center">
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.04] p-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-bold text-orange-200">Vídeo Bunny Stream</p>
<p className="text-[11px] text-gray-500">O vídeo será protegido e reproduzido no player seguro da Bunny.</p>
</div>
<label className={`cursor-pointer rounded-lg px-3 py-2 text-xs font-bold transition-colors ${bunnyConfigured && bunnyUploadState !== 'uploading' ? 'bg-orange-500 hover:bg-orange-600 text-white' : 'bg-white/10 text-gray-500 cursor-not-allowed'}`}>
{bunnyUploadState === 'uploading' ? 'Enviando...' : 'Selecionar vídeo'}
<input
type="file"
accept="video/*"
className="hidden"
disabled={!bunnyConfigured || bunnyUploadState === 'uploading'}
onChange={handleBunnyUpload}
/>
</label>
</div>
{bunnyConfigured === false && <p className="text-[11px] text-amber-300">Bunny Stream não está configurado no servidor. Peça ao administrador para adicionar as variáveis no Portainer.</p>}
{bunnyUploadMessage && <p className={`text-[11px] ${bunnyUploadState === 'error' ? 'text-red-300' : bunnyUploadState === 'uploaded' ? 'text-emerald-300' : 'text-gray-400'}`}>{bunnyUploadMessage}</p>}
{newLessonMedia && (
<div className="flex items-center justify-between rounded-lg bg-black/30 px-3 py-2 text-[11px] text-gray-300">
<span>Vídeo Bunny vinculado ({newLessonMedia.status === 'ready' ? 'pronto' : 'processando'})</span>
<button type="button" onClick={() => setNewLessonMedia(null)} className="text-gray-400 hover:text-red-300">Remover</button>
</div>
)}
</div>
<div className="flex gap-2 items-center pt-1">
<input
type="text"
value={newLessonProvider}
onChange={(event) => setNewLessonProvider(event.target.value)}
placeholder="Provider (external, Panda, Vimeo...)"
placeholder="Provider externo"
className="w-40 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>
<input
type="url"
value={newLessonVideoUrl}
onChange={(event) => setNewLessonVideoUrl(event.target.value)}
placeholder="Hosted video URL"
placeholder="Ou cole uma URL externa de vídeo"
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>

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);
}
});
};

View File

@@ -25,6 +25,8 @@ export interface ManagedUserDetail extends ManagedUser {
export interface PlatformAnalytics { totalUsers: number; activeUsers: number; publishedCourses: number; completedLessons: number; comments: number; }
export interface InstructorAnalytics { courses: number; lessons: number; learners: number; completedLessons: number; comments: number; }
export interface AuditEntry { id: string; action: string; subjectType: string; subjectId: string | null; metadata: Record<string, unknown>; createdAt: string; actorName: string; }
export interface BunnyVideo { id: string; title: string; status: 'processing' | 'ready' | 'failed'; providerStatus: number; encodeProgress: number; durationSeconds: number | null; }
export interface LessonPlayback { provider: string; kind: 'embed' | 'video'; embedUrl?: string; source?: string; expiresAt?: string; }
interface Session {
token: string;
@@ -72,6 +74,16 @@ export async function apiRequest<T>(path: string, options: RequestInit = {}): Pr
return body as T;
}
async function uploadRequest<T>(path: string, file: File): Promise<T> {
const session = getSession();
const headers = new Headers({ 'Content-Type': file.type || 'application/octet-stream' });
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { method: 'PUT', headers, body: file });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(body.error || 'The video upload could not be completed', response.status);
return body as T;
}
export const authApi = {
async login(email: string, password: string) {
return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
@@ -113,4 +125,20 @@ export const adminApi = {
export const instructorApi = {
async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); },
async bunnyConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/bunny/config'); },
async createBunnyVideo(title: string) {
return apiRequest<{ data: BunnyVideo }>('/manage/media/bunny/videos', { method: 'POST', body: JSON.stringify({ title }) });
},
async uploadBunnyVideo(videoId: string, file: File) {
return uploadRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}/upload`, file);
},
async bunnyVideoStatus(videoId: string) {
return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`);
},
};
export const courseApi = {
async playback(courseId: string, lessonId: string) {
return apiRequest<{ data: LessonPlayback }>(`/courses/${courseId}/lessons/${lessonId}/playback`);
},
};

View File

@@ -1,4 +1,4 @@
import { Attachment, AttachmentType, Comment, Course, Lesson } from '../types';
import { Attachment, AttachmentType, Comment, Course, Lesson, LessonMedia } from '../types';
import { apiRequest } from './api';
export const initDB = async (): Promise<void> => undefined;
@@ -27,7 +27,15 @@ type ApiCourse = {
description: string;
durationSeconds: number | null;
accessLevel: 'public' | 'enrolled';
media: Array<{ provider: string; playbackUrl: string | null; embedUrl: string | null }>;
media: Array<{
id: string;
provider: string;
externalId: string;
playbackUrl: string | null;
embedUrl: string | null;
status: 'processing' | 'ready' | 'failed';
durationSeconds: number | null;
}>;
assets: ApiAsset[];
}>;
assets: ApiAsset[];
@@ -64,16 +72,28 @@ const toCourse = (course: ApiCourse): Course => ({
progress: course.progress,
lastActivity: course.lastActivity || undefined,
duration: `${course.lessons.length} Aulas`,
lessons: course.lessons.map((lesson): Lesson => ({
id: lesson.id,
title: lesson.title,
description: lesson.description,
duration: toDuration(lesson.durationSeconds),
isFree: lesson.accessLevel === 'public',
videoUrl: lesson.media[0]?.playbackUrl || lesson.media[0]?.embedUrl || '',
mediaProvider: lesson.media[0]?.provider,
attachments: lesson.assets.map(toAttachment),
})),
lessons: course.lessons.map((lesson): Lesson => {
const media: LessonMedia[] = lesson.media.map((item) => ({
id: item.id,
provider: item.provider,
externalId: item.externalId,
playbackUrl: item.playbackUrl,
embedUrl: item.embedUrl,
status: item.status,
durationSeconds: item.durationSeconds,
}));
return {
id: lesson.id,
title: lesson.title,
description: lesson.description,
duration: toDuration(lesson.durationSeconds),
isFree: lesson.accessLevel === 'public',
videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '',
mediaProvider: media[0]?.provider,
media,
attachments: lesson.assets.map(toAttachment),
};
}),
attachments: course.assets.map(toAttachment),
});
@@ -112,9 +132,18 @@ const toCoursePayload = (course: Course) => ({
description: lesson.description || '',
durationSeconds: toSeconds(lesson.duration),
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
media: lesson.videoUrl && !lesson.videoUrl.startsWith('blob:')
? [{ provider: lesson.mediaProvider || 'external', externalId: lesson.videoUrl, playbackUrl: lesson.videoUrl, status: 'ready' as const }]
: [],
media: lesson.media?.length
? lesson.media.map((media) => ({
provider: media.provider,
externalId: media.externalId,
playbackUrl: media.playbackUrl || null,
embedUrl: media.embedUrl || null,
status: media.status,
durationSeconds: media.durationSeconds || null,
}))
: lesson.videoUrl && !lesson.videoUrl.startsWith('blob:')
? [{ provider: lesson.mediaProvider || 'external', externalId: lesson.videoUrl, playbackUrl: lesson.videoUrl, status: 'ready' as const }]
: [],
assets: (lesson.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
})),
});

View File

@@ -13,12 +13,25 @@ export interface Attachment {
downloadCount?: number;
}
export type LessonMediaStatus = 'processing' | 'ready' | 'failed';
export interface LessonMedia {
id?: string;
provider: string;
externalId: string;
playbackUrl?: string | null;
embedUrl?: string | null;
status: LessonMediaStatus;
durationSeconds?: number | null;
}
export interface Lesson {
id: string;
title: string;
duration: string;
videoUrl: string;
mediaProvider?: string;
media?: LessonMedia[];
isFree: boolean; // true = Public, false = Pro (Login required)
description?: string;
attachments?: Attachment[]; // Lesson-specific downloadable materials