feat: derive course durations from video media
All checks were successful
CI / Validate frontend and API (push) Successful in 38s
CI / Build and publish Docker images (push) Successful in 22s

This commit is contained in:
Cauê Faleiros
2026-09-04 14:52:47 -03:00
parent 7a01be2b59
commit 1a79fa447b
3 changed files with 40 additions and 23 deletions

View File

@@ -40,6 +40,22 @@ import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getCommen
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard'; import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api'; import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
const formatVideoDuration = (seconds: number | null | undefined) => {
if (!seconds || seconds < 1) return 'Processando';
const totalSeconds = Math.round(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainingSeconds = totalSeconds % 60;
if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}min`;
if (minutes) return `${minutes}min`;
return `${remainingSeconds}s`;
};
const totalVideoDuration = (lessons: Lesson[]) => {
const seconds = lessons.reduce((total, lesson) => total + (lesson.media?.[0]?.durationSeconds || 0), 0);
return seconds ? formatVideoDuration(seconds) : 'Em processamento';
};
// 1. Create/Edit Course Modal // 1. Create/Edit Course Modal
const CourseModal: React.FC<{ const CourseModal: React.FC<{
isOpen: boolean; isOpen: boolean;
@@ -72,7 +88,6 @@ const CourseModal: React.FC<{
// New Lesson State // New Lesson State
const [newLessonTitle, setNewLessonTitle] = useState(''); const [newLessonTitle, setNewLessonTitle] = useState('');
const [newLessonDuration, setNewLessonDuration] = useState('');
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null); 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 [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
@@ -179,10 +194,6 @@ const CourseModal: React.FC<{
const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file); const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file);
const media = toBunnyMedia(uploaded.data); const media = toBunnyMedia(uploaded.data);
setNewLessonMedia(media); setNewLessonMedia(media);
if (!newLessonDuration.trim() && uploaded.data.durationSeconds) {
const minutes = Math.max(1, Math.round(uploaded.data.durationSeconds / 60));
setNewLessonDuration(`${minutes}m`);
}
setBunnyUploadState('uploaded'); setBunnyUploadState('uploaded');
setBunnyUploadMessage(uploaded.data.status === 'ready' setBunnyUploadMessage(uploaded.data.status === 'ready'
? 'Vídeo pronto para ser adicionado à aula.' ? 'Vídeo pronto para ser adicionado à aula.'
@@ -257,7 +268,7 @@ const CourseModal: React.FC<{
const refreshed = toBunnyMedia(response.data); const refreshed = toBunnyMedia(response.data);
setLessons((current) => current.map((item) => item.id === lessonId ? { setLessons((current) => current.map((item) => item.id === lessonId ? {
...item, ...item,
duration: refreshed.durationSeconds ? `${Math.max(1, Math.round(refreshed.durationSeconds / 60))}m` : item.duration, duration: formatVideoDuration(refreshed.durationSeconds),
media: [refreshed], media: [refreshed],
} : item)); } : item));
} catch (error) { } catch (error) {
@@ -290,7 +301,7 @@ const CourseModal: React.FC<{
if (!update) return lesson; if (!update) return lesson;
return { return {
...lesson, ...lesson,
duration: update.media.durationSeconds ? `${Math.max(1, Math.round(update.media.durationSeconds / 60))}m` : lesson.duration, duration: formatVideoDuration(update.media.durationSeconds),
media: [update.media], media: [update.media],
}; };
})); }));
@@ -312,7 +323,7 @@ const CourseModal: React.FC<{
const newLesson: Lesson = { const newLesson: Lesson = {
id: `lesson-${Date.now()}`, id: `lesson-${Date.now()}`,
title: newLessonTitle, title: newLessonTitle,
duration: newLessonDuration || '15m', duration: formatVideoDuration(newLessonMedia.durationSeconds),
videoUrl: newLessonMedia.playbackUrl || '', videoUrl: newLessonMedia.playbackUrl || '',
mediaProvider: newLessonMedia.provider, mediaProvider: newLessonMedia.provider,
media: [newLessonMedia], media: [newLessonMedia],
@@ -323,7 +334,6 @@ const CourseModal: React.FC<{
setLessons([...lessons, newLesson]); setLessons([...lessons, newLesson]);
setNewLessonTitle(''); setNewLessonTitle('');
setNewLessonDuration('');
setNewLessonMedia(null); setNewLessonMedia(null);
setNewLessonIsFree(false); setNewLessonIsFree(false);
setBunnyUploadState('idle'); setBunnyUploadState('idle');
@@ -406,7 +416,7 @@ const CourseModal: React.FC<{
setIsSubmitting(true); setIsSubmitting(true);
const durationStr = `${lessons.length} Aulas`; const durationStr = totalVideoDuration(lessons);
try { try {
await onSave({ await onSave({
@@ -785,20 +795,13 @@ const CourseModal: React.FC<{
Adicionar aula Adicionar aula
</h4> </h4>
<div className="flex gap-2"> <div>
<input <input
type="text" type="text"
value={newLessonTitle} value={newLessonTitle}
onChange={(e) => setNewLessonTitle(e.target.value)} onChange={(e) => setNewLessonTitle(e.target.value)}
placeholder="Ex.: Aula 01 — Configuração inicial do Pixel" placeholder="Título da aula"
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50" className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
/>
<input
type="text"
value={newLessonDuration}
onChange={(e) => setNewLessonDuration(e.target.value)}
placeholder="Ex.: 25 min"
className="w-28 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
/> />
</div> </div>

View File

@@ -40,6 +40,14 @@ async function persistBunnyStatus(video: { id: string; status: string; durationS
where provider = 'bunny' and external_id = $1`, where provider = 'bunny' and external_id = $1`,
[video.id, video.status, video.durationSeconds], [video.id, video.status, video.durationSeconds],
); );
if (video.durationSeconds !== null) {
await pool.query(
`update lessons
set duration_seconds = $2
where id = (select lesson_id from lesson_media where provider = 'bunny' and external_id = $1)`,
[video.id, video.durationSeconds],
);
}
} }
export const mediaRoutes: FastifyPluginAsync = async (app) => { export const mediaRoutes: FastifyPluginAsync = async (app) => {

View File

@@ -64,6 +64,12 @@ const toDuration = (seconds: number | null) => {
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`; return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
}; };
const toCourseDuration = (lessons: ApiCourse['lessons']) => {
const seconds = lessons.reduce((total, lesson) => total + (lesson.media[0]?.durationSeconds ?? lesson.durationSeconds ?? 0), 0);
if (seconds) return toDuration(seconds);
return lessons.length ? 'Processando' : '—';
};
const toCourse = (course: ApiCourse): Course => ({ const toCourse = (course: ApiCourse): Course => ({
id: course.id, id: course.id,
title: course.title, title: course.title,
@@ -77,7 +83,7 @@ const toCourse = (course: ApiCourse): Course => ({
status: course.status === 'draft' ? 'draft' : 'published', status: course.status === 'draft' ? 'draft' : 'published',
progress: course.progress, progress: course.progress,
lastActivity: course.lastActivity || undefined, lastActivity: course.lastActivity || undefined,
duration: `${course.lessons.length} Aulas`, duration: toCourseDuration(course.lessons),
lessons: course.lessons.map((lesson): Lesson => { lessons: course.lessons.map((lesson): Lesson => {
const media: LessonMedia[] = lesson.media.map((item) => ({ const media: LessonMedia[] = lesson.media.map((item) => ({
id: item.id, id: item.id,
@@ -92,7 +98,7 @@ const toCourse = (course: ApiCourse): Course => ({
id: lesson.id, id: lesson.id,
title: lesson.title, title: lesson.title,
description: lesson.description, description: lesson.description,
duration: toDuration(lesson.durationSeconds), duration: toDuration(media[0]?.durationSeconds ?? lesson.durationSeconds),
isFree: lesson.accessLevel === 'public', isFree: lesson.accessLevel === 'public',
videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '', videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '',
mediaProvider: media[0]?.provider, mediaProvider: media[0]?.provider,
@@ -139,7 +145,7 @@ const toCoursePayload = (course: Course) => ({
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined, id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
title: lesson.title, title: lesson.title,
description: lesson.description || '', description: lesson.description || '',
durationSeconds: toSeconds(lesson.duration), durationSeconds: lesson.media?.[0]?.durationSeconds ?? toSeconds(lesson.duration),
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const, accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
media: lesson.media?.length media: lesson.media?.length
? lesson.media.map((media) => ({ ? lesson.media.map((media) => ({