feat: integrate Bunny Stream course videos
This commit is contained in:
@@ -18,6 +18,14 @@ AUTH_RATE_LIMIT_MAX=10
|
|||||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
|
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
|
||||||
SUPERADMIN_NAME=Compor HUB Superadmin
|
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.
|
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
|
||||||
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
||||||
WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web
|
WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web
|
||||||
|
|||||||
24
PORTAINER.md
24
PORTAINER.md
@@ -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.
|
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.
|
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
|
## Gitea Actions registry secrets
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db';
|
import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db';
|
||||||
import { LoginModal } from './LoginModal';
|
import { LoginModal } from './LoginModal';
|
||||||
import { MaterialCard } from './MaterialCard';
|
import { MaterialCard } from './MaterialCard';
|
||||||
|
import { courseApi, LessonPlayback } from '../services/api';
|
||||||
|
|
||||||
interface CoursePlayerModalProps {
|
interface CoursePlayerModalProps {
|
||||||
course: Course | null;
|
course: Course | null;
|
||||||
@@ -38,6 +39,8 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
|
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
const [playback, setPlayback] = useState<LessonPlayback | null>(null);
|
||||||
|
const [playbackError, setPlaybackError] = useState('');
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const lastProgressSave = useRef<Record<string, number>>({});
|
const lastProgressSave = useRef<Record<string, number>>({});
|
||||||
@@ -70,6 +73,27 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
}
|
}
|
||||||
}, [course, user]);
|
}, [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
|
// Persist completed lessons and recalculate progress
|
||||||
const updateCompletedLessons = (newCompleted: string[]) => {
|
const updateCompletedLessons = (newCompleted: string[]) => {
|
||||||
setCompletedLessonIds(newCompleted);
|
setCompletedLessonIds(newCompleted);
|
||||||
@@ -239,19 +263,38 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
{/* Video Player Section */}
|
{/* Video Player Section */}
|
||||||
<div className="w-full aspect-video bg-black rounded-[8px] overflow-hidden relative shadow-xl border border-white/10 group">
|
<div className="w-full aspect-video bg-black rounded-[8px] overflow-hidden relative shadow-xl border border-white/10 group">
|
||||||
{canWatch ? (
|
{canWatch ? (
|
||||||
<video
|
playback ? (
|
||||||
key={currentLesson?.id}
|
playback.kind === 'embed' && playback.embedUrl ? (
|
||||||
ref={videoRef}
|
<iframe
|
||||||
src={currentLesson?.videoUrl}
|
key={currentLesson?.id}
|
||||||
className="w-full h-full object-contain rounded-[8px]"
|
src={playback.embedUrl}
|
||||||
controls
|
title={currentLesson?.title || 'Vídeo da aula'}
|
||||||
autoPlay
|
className="w-full h-full border-0 rounded-[8px]"
|
||||||
onTimeUpdate={handleTimeUpdate}
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; fullscreen"
|
||||||
onEnded={handleVideoEnded}
|
allowFullScreen
|
||||||
onLoadedMetadata={restoreWatchPosition}
|
/>
|
||||||
>
|
) : playback.source ? (
|
||||||
Seu navegador não suporta a tag de vídeo.
|
<video
|
||||||
</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
|
// 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]">
|
<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]">
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ services:
|
|||||||
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
|
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
|
||||||
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD:-}
|
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD:-}
|
||||||
SUPERADMIN_NAME: ${SUPERADMIN_NAME:-Compor HUB Superadmin}
|
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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ server {
|
|||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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
|
# 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
|
# record. Resolving through Docker DNS at request time keeps Nginx alive while
|
||||||
@@ -14,6 +17,10 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://$api_upstream:3001;
|
proxy_pass http://$api_upstream:3001;
|
||||||
proxy_http_version 1.1;
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Download
|
Download
|
||||||
} from 'lucide-react';
|
} 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 { useToast } from '../context/ToastContext';
|
||||||
import { getManagedCourses, saveCourse, deleteCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
import { getManagedCourses, saveCourse, deleteCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
||||||
import { CATEGORIES } from '../constants';
|
import { CATEGORIES } from '../constants';
|
||||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||||
import { instructorApi, InstructorAnalytics } from '../services/api';
|
import { BunnyVideo, instructorApi, InstructorAnalytics } from '../services/api';
|
||||||
|
|
||||||
// 1. Create/Edit Course Modal
|
// 1. Create/Edit Course Modal
|
||||||
const CourseModal: React.FC<{
|
const CourseModal: React.FC<{
|
||||||
@@ -55,7 +55,11 @@ const CourseModal: React.FC<{
|
|||||||
const [newLessonDuration, setNewLessonDuration] = useState('');
|
const [newLessonDuration, setNewLessonDuration] = useState('');
|
||||||
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
|
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
|
||||||
const [newLessonProvider, setNewLessonProvider] = useState('external');
|
const [newLessonProvider, setNewLessonProvider] = useState('external');
|
||||||
|
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null);
|
||||||
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
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
|
// Tips & Global Attachments
|
||||||
const [tips, setTips] = useState<string[]>([]);
|
const [tips, setTips] = useState<string[]>([]);
|
||||||
@@ -96,16 +100,114 @@ const CourseModal: React.FC<{
|
|||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setLessons([]);
|
setLessons([]);
|
||||||
}
|
}
|
||||||
|
setNewLessonMedia(null);
|
||||||
|
setBunnyUploadState('idle');
|
||||||
|
setBunnyUploadMessage('');
|
||||||
|
instructorApi.bunnyConfiguration()
|
||||||
|
.then((response) => setBunnyConfigured(response.data.configured))
|
||||||
|
.catch(() => setBunnyConfigured(false));
|
||||||
}
|
}
|
||||||
}, [isOpen, initialData]);
|
}, [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 = () => {
|
const handleAddLesson = () => {
|
||||||
if (!newLessonTitle.trim()) {
|
if (!newLessonTitle.trim()) {
|
||||||
alert("Digite um título para a aula.");
|
alert("Digite um título para a aula.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!newLessonVideoUrl.trim()) {
|
if (!newLessonMedia && !newLessonVideoUrl.trim()) {
|
||||||
alert("Enter a hosted video URL.");
|
alert('Envie um vídeo para Bunny Stream ou informe uma URL de vídeo hospedado.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +215,9 @@ const CourseModal: React.FC<{
|
|||||||
id: `lesson-${Date.now()}`,
|
id: `lesson-${Date.now()}`,
|
||||||
title: newLessonTitle,
|
title: newLessonTitle,
|
||||||
duration: newLessonDuration || '15m',
|
duration: newLessonDuration || '15m',
|
||||||
videoUrl: newLessonVideoUrl,
|
videoUrl: newLessonMedia?.playbackUrl || newLessonVideoUrl,
|
||||||
mediaProvider: newLessonProvider || 'external',
|
mediaProvider: newLessonMedia?.provider || newLessonProvider || 'external',
|
||||||
|
media: newLessonMedia ? [newLessonMedia] : undefined,
|
||||||
isFree: newLessonIsFree,
|
isFree: newLessonIsFree,
|
||||||
description: ''
|
description: ''
|
||||||
};
|
};
|
||||||
@@ -125,7 +228,10 @@ const CourseModal: React.FC<{
|
|||||||
setNewLessonDuration('');
|
setNewLessonDuration('');
|
||||||
setNewLessonVideoUrl('');
|
setNewLessonVideoUrl('');
|
||||||
setNewLessonProvider('external');
|
setNewLessonProvider('external');
|
||||||
|
setNewLessonMedia(null);
|
||||||
setNewLessonIsFree(false);
|
setNewLessonIsFree(false);
|
||||||
|
setBunnyUploadState('idle');
|
||||||
|
setBunnyUploadMessage('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLesson = (id: string) => {
|
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>
|
<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">
|
<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>
|
<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 ? (
|
{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>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleLessonAccess(lesson.id)}
|
onClick={() => toggleLessonAccess(lesson.id)}
|
||||||
@@ -484,19 +605,46 @@ const CourseModal: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newLessonProvider}
|
value={newLessonProvider}
|
||||||
onChange={(event) => setNewLessonProvider(event.target.value)}
|
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"
|
className="w-40 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={newLessonVideoUrl}
|
value={newLessonVideoUrl}
|
||||||
onChange={(event) => setNewLessonVideoUrl(event.target.value)}
|
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"
|
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ import { courseRoutes } from './routes/courses.js';
|
|||||||
import { manageCourseRoutes } from './routes/manage-courses.js';
|
import { manageCourseRoutes } from './routes/manage-courses.js';
|
||||||
import { learningRoutes } from './routes/learning.js';
|
import { learningRoutes } from './routes/learning.js';
|
||||||
import { adminRoutes } from './routes/admin.js';
|
import { adminRoutes } from './routes/admin.js';
|
||||||
|
import { mediaRoutes } from './routes/media.js';
|
||||||
|
|
||||||
export function buildApp() {
|
export function buildApp() {
|
||||||
const app = Fastify({ logger: true });
|
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, {
|
app.register(cors, {
|
||||||
origin: config.FRONTEND_ORIGIN,
|
origin: config.FRONTEND_ORIGIN,
|
||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
@@ -42,6 +49,7 @@ export function buildApp() {
|
|||||||
app.register(authRoutes, { prefix: '/api/v1/auth' });
|
app.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||||
app.register(courseRoutes, { prefix: '/api/v1/courses' });
|
app.register(courseRoutes, { prefix: '/api/v1/courses' });
|
||||||
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/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(learningRoutes, { prefix: '/api/v1' });
|
||||||
app.register(adminRoutes, { prefix: '/api/v1/admin' });
|
app.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const environmentSchema = z.object({
|
|||||||
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
|
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_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),
|
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);
|
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') {
|
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.');
|
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 type { FastifyPluginAsync } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { pool } from '../db/pool.js';
|
import { pool } from '../db/pool.js';
|
||||||
|
import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js';
|
||||||
|
|
||||||
const paramsSchema = z.object({
|
const paramsSchema = z.object({
|
||||||
courseId: z.string().uuid(),
|
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
|
select
|
||||||
c.id,
|
c.id,
|
||||||
c.slug,
|
c.slug,
|
||||||
@@ -46,6 +51,7 @@ export const courseSelect = (publicOnly = false) => `
|
|||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
'id', lm.id,
|
'id', lm.id,
|
||||||
'provider', lm.provider,
|
'provider', lm.provider,
|
||||||
|
'externalId', lm.external_id,
|
||||||
'status', lm.status,
|
'status', lm.status,
|
||||||
'embedUrl', lm.embed_url,
|
'embedUrl', lm.embed_url,
|
||||||
'playbackUrl', lm.playback_url,
|
'playbackUrl', lm.playback_url,
|
||||||
@@ -53,7 +59,7 @@ export const courseSelect = (publicOnly = false) => `
|
|||||||
) order by lm.created_at
|
) order by lm.created_at
|
||||||
)
|
)
|
||||||
from lesson_media lm
|
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)
|
), '[]'::jsonb)
|
||||||
) order by l.position
|
) order by l.position
|
||||||
)
|
)
|
||||||
@@ -135,4 +141,42 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
return { data: authenticated ? course : withoutProtectedMedia(course) };
|
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'
|
const filters = request.user.role === 'admin'
|
||||||
? { sql: "where c.status <> 'archived' order by c.created_at desc", values: [] as string[] }
|
? { 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] };
|
: { 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 };
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -25,6 +25,8 @@ export interface ManagedUserDetail extends ManagedUser {
|
|||||||
export interface PlatformAnalytics { totalUsers: number; activeUsers: number; publishedCourses: number; completedLessons: number; comments: number; }
|
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 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 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 {
|
interface Session {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -72,6 +74,16 @@ export async function apiRequest<T>(path: string, options: RequestInit = {}): Pr
|
|||||||
return body as T;
|
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 = {
|
export const authApi = {
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string) {
|
||||||
return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
|
return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
|
||||||
@@ -113,4 +125,20 @@ export const adminApi = {
|
|||||||
|
|
||||||
export const instructorApi = {
|
export const instructorApi = {
|
||||||
async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); },
|
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`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
export const initDB = async (): Promise<void> => undefined;
|
export const initDB = async (): Promise<void> => undefined;
|
||||||
@@ -27,7 +27,15 @@ type ApiCourse = {
|
|||||||
description: string;
|
description: string;
|
||||||
durationSeconds: number | null;
|
durationSeconds: number | null;
|
||||||
accessLevel: 'public' | 'enrolled';
|
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[];
|
||||||
}>;
|
}>;
|
||||||
assets: ApiAsset[];
|
assets: ApiAsset[];
|
||||||
@@ -64,16 +72,28 @@ const toCourse = (course: ApiCourse): Course => ({
|
|||||||
progress: course.progress,
|
progress: course.progress,
|
||||||
lastActivity: course.lastActivity || undefined,
|
lastActivity: course.lastActivity || undefined,
|
||||||
duration: `${course.lessons.length} Aulas`,
|
duration: `${course.lessons.length} Aulas`,
|
||||||
lessons: course.lessons.map((lesson): Lesson => ({
|
lessons: course.lessons.map((lesson): Lesson => {
|
||||||
id: lesson.id,
|
const media: LessonMedia[] = lesson.media.map((item) => ({
|
||||||
title: lesson.title,
|
id: item.id,
|
||||||
description: lesson.description,
|
provider: item.provider,
|
||||||
duration: toDuration(lesson.durationSeconds),
|
externalId: item.externalId,
|
||||||
isFree: lesson.accessLevel === 'public',
|
playbackUrl: item.playbackUrl,
|
||||||
videoUrl: lesson.media[0]?.playbackUrl || lesson.media[0]?.embedUrl || '',
|
embedUrl: item.embedUrl,
|
||||||
mediaProvider: lesson.media[0]?.provider,
|
status: item.status,
|
||||||
attachments: lesson.assets.map(toAttachment),
|
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),
|
attachments: course.assets.map(toAttachment),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,9 +132,18 @@ const toCoursePayload = (course: Course) => ({
|
|||||||
description: lesson.description || '',
|
description: lesson.description || '',
|
||||||
durationSeconds: toSeconds(lesson.duration),
|
durationSeconds: toSeconds(lesson.duration),
|
||||||
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
|
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
|
||||||
media: lesson.videoUrl && !lesson.videoUrl.startsWith('blob:')
|
media: lesson.media?.length
|
||||||
? [{ provider: lesson.mediaProvider || 'external', externalId: lesson.videoUrl, playbackUrl: lesson.videoUrl, status: 'ready' as const }]
|
? 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),
|
assets: (lesson.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
13
types.ts
13
types.ts
@@ -13,12 +13,25 @@ export interface Attachment {
|
|||||||
downloadCount?: number;
|
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 {
|
export interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
mediaProvider?: string;
|
mediaProvider?: string;
|
||||||
|
media?: LessonMedia[];
|
||||||
isFree: boolean; // true = Public, false = Pro (Login required)
|
isFree: boolean; // true = Public, false = Pro (Login required)
|
||||||
description?: string;
|
description?: string;
|
||||||
attachments?: Attachment[]; // Lesson-specific downloadable materials
|
attachments?: Attachment[]; // Lesson-specific downloadable materials
|
||||||
|
|||||||
Reference in New Issue
Block a user