@hed-hog/lms 0.0.350 → 0.0.353
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/certificate/certificate.controller.d.ts +2 -2
- package/dist/certificate/certificate.controller.d.ts.map +1 -1
- package/dist/certificate/certificate.controller.js +8 -6
- package/dist/certificate/certificate.controller.js.map +1 -1
- package/dist/certificate/certificate.service.d.ts +5 -2
- package/dist/certificate/certificate.service.d.ts.map +1 -1
- package/dist/certificate/certificate.service.js +70 -6
- package/dist/certificate/certificate.service.js.map +1 -1
- package/dist/course/course-structure.controller.d.ts +24 -10
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +23 -2
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +16 -8
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +61 -30
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-video-conversion.service.d.ts +37 -0
- package/dist/course/course-video-conversion.service.d.ts.map +1 -0
- package/dist/course/course-video-conversion.service.js +308 -0
- package/dist/course/course-video-conversion.service.js.map +1 -0
- package/dist/course/course.controller.d.ts +17 -0
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.controller.js +23 -0
- package/dist/course/course.controller.js.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +15 -2
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +15 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +103 -49
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts +5 -1
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js +16 -2
- package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +1 -0
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js +9 -0
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/enterprise/enterprise.controller.d.ts +3 -3
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +0 -1
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +3 -3
- package/dist/evaluation/evaluation.service.d.ts.map +1 -1
- package/dist/evaluation/evaluation.service.js +9 -2
- package/dist/evaluation/evaluation.service.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +3 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.d.ts +6 -0
- package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.d.ts.map +1 -0
- package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.js +33 -0
- package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.js.map +1 -0
- package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.d.ts +6 -0
- package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.d.ts.map +1 -0
- package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.js +33 -0
- package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.js.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.controller.d.ts +38 -0
- package/dist/video-resolution-profile/video-resolution-profile.controller.d.ts.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.controller.js +89 -0
- package/dist/video-resolution-profile/video-resolution-profile.controller.js.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.d.ts +26 -0
- package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.d.ts.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.js +160 -0
- package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.js.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.module.d.ts +3 -0
- package/dist/video-resolution-profile/video-resolution-profile.module.d.ts.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.module.js +26 -0
- package/dist/video-resolution-profile/video-resolution-profile.module.js.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.service.d.ts +45 -0
- package/dist/video-resolution-profile/video-resolution-profile.service.d.ts.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.service.js +117 -0
- package/dist/video-resolution-profile/video-resolution-profile.service.js.map +1 -0
- package/hedhog/data/menu.yaml +17 -0
- package/hedhog/data/route.yaml +133 -0
- package/hedhog/data/video_resolution_profile.yaml +7 -0
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +269 -324
- package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +124 -70
- package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +7 -4
- package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +2 -2
- package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +2 -2
- package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +34 -4
- package/hedhog/frontend/app/_lib/editor/types.ts.ejs +28 -3
- package/hedhog/frontend/app/achievements/page.tsx.ejs +9 -3
- package/hedhog/frontend/app/bitcodes/page.tsx.ejs +9 -3
- package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +7 -3
- package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +29 -8
- package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +14 -0
- package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +194 -9
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +15 -5
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +9 -5
- package/hedhog/frontend/app/classes/page.tsx.ejs +73 -47
- package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +19 -9
- package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +24 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +28 -16
- package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +11 -6
- package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +7 -4
- package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +24 -87
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +892 -411
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1004 -293
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +11 -11
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +62 -52
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +2 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -6
- package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +86 -1
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +1 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +112 -89
- package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +10 -3
- package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +8 -4
- package/hedhog/frontend/app/enterprise/_components/enterprise-person-edit-sheet.tsx.ejs +2 -2
- package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +10 -4
- package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +10 -3
- package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +10 -3
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +10 -3
- package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +23 -9
- package/hedhog/frontend/app/exams/page.tsx.ejs +14 -6
- package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +9 -3
- package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +190 -17
- package/hedhog/frontend/app/layout.tsx.ejs +5 -1
- package/hedhog/frontend/app/paths/page.tsx.ejs +13 -5
- package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +10 -10
- package/hedhog/frontend/app/training/page.tsx.ejs +13 -5
- package/hedhog/frontend/app/video-resolution-profiles/page.tsx.ejs +607 -0
- package/hedhog/frontend/messages/en.json +250 -9
- package/hedhog/frontend/messages/pt.json +250 -9
- package/hedhog/table/course.yaml +4 -0
- package/hedhog/table/course_lesson_file.yaml +8 -0
- package/hedhog/table/course_video_resolution_profile.yaml +22 -0
- package/hedhog/table/video_resolution_profile.yaml +18 -0
- package/package.json +9 -8
- package/src/certificate/certificate.controller.ts +19 -14
- package/src/certificate/certificate.service.ts +106 -11
- package/src/course/course-structure.controller.ts +24 -2
- package/src/course/course-structure.service.ts +21 -4
- package/src/course/course-video-conversion.service.ts +415 -0
- package/src/course/course.controller.ts +18 -0
- package/src/course/course.module.ts +15 -2
- package/src/course/course.service.ts +72 -2
- package/src/course/dto/create-course-structure-lesson.dto.ts +13 -2
- package/src/course/dto/create-course.dto.ts +8 -0
- package/src/enterprise/enterprise.controller.ts +0 -1
- package/src/evaluation/evaluation.service.ts +9 -2
- package/src/index.ts +1 -0
- package/src/lms.module.ts +3 -0
- package/src/video-resolution-profile/dto/create-video-resolution-profile.dto.ts +16 -0
- package/src/video-resolution-profile/dto/update-video-resolution-profile.dto.ts +16 -0
- package/src/video-resolution-profile/video-resolution-profile.controller.ts +62 -0
- package/src/video-resolution-profile/video-resolution-profile.mcp-tools.ts +128 -0
- package/src/video-resolution-profile/video-resolution-profile.module.ts +13 -0
- package/src/video-resolution-profile/video-resolution-profile.service.ts +117 -0
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
Post,
|
|
11
11
|
} from '@nestjs/common';
|
|
12
12
|
import { CourseStructureService } from './course-structure.service';
|
|
13
|
-
import {
|
|
13
|
+
import { CourseVideoConversionService } from './course-video-conversion.service';
|
|
14
|
+
import {
|
|
15
|
+
CreateCourseStructureLessonDto,
|
|
16
|
+
CreateLessonVideoConversionDto,
|
|
17
|
+
} from './dto/create-course-structure-lesson.dto';
|
|
14
18
|
import { CreateCourseStructureSessionDto } from './dto/create-course-structure-session.dto';
|
|
15
19
|
import { MoveLessonDto } from './dto/move-lesson.dto';
|
|
16
20
|
import { PasteLessonsDto } from './dto/paste-lessons.dto';
|
|
@@ -23,7 +27,10 @@ import { UpdateCourseStructureSessionDto } from './dto/update-course-structure-s
|
|
|
23
27
|
@Role()
|
|
24
28
|
@Controller('lms/courses/:id/structure')
|
|
25
29
|
export class CourseStructureController {
|
|
26
|
-
constructor(
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly courseStructureService: CourseStructureService,
|
|
32
|
+
private readonly courseVideoConversionService: CourseVideoConversionService,
|
|
33
|
+
) {}
|
|
27
34
|
|
|
28
35
|
@Get()
|
|
29
36
|
getStructure(@Param('id', ParseIntPipe) courseId: number) {
|
|
@@ -142,6 +149,21 @@ export class CourseStructureController {
|
|
|
142
149
|
return this.courseStructureService.moveLesson(courseId, sessionId, lessonId, dto);
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
@Post('sessions/:sessionId/lessons/:lessonId/video-conversions')
|
|
153
|
+
createLessonVideoConversion(
|
|
154
|
+
@Param('id', ParseIntPipe) courseId: number,
|
|
155
|
+
@Param('sessionId', ParseIntPipe) sessionId: number,
|
|
156
|
+
@Param('lessonId', ParseIntPipe) lessonId: number,
|
|
157
|
+
@Body() dto: CreateLessonVideoConversionDto,
|
|
158
|
+
) {
|
|
159
|
+
return this.courseVideoConversionService.enqueueConversion({
|
|
160
|
+
courseId,
|
|
161
|
+
sessionId,
|
|
162
|
+
lessonId,
|
|
163
|
+
originalFileId: dto.originalFileId,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
145
167
|
@Delete('sessions/:sessionId/lessons/:lessonId')
|
|
146
168
|
deleteLesson(
|
|
147
169
|
@Param('id', ParseIntPipe) courseId: number,
|
|
@@ -105,6 +105,7 @@ export class CourseStructureService {
|
|
|
105
105
|
videoUrl: parsedContent?.videoUrl,
|
|
106
106
|
duracaoAutomatica: parsedContent?.duracaoAutomatica,
|
|
107
107
|
transcricao: parsedContent?.transcricao,
|
|
108
|
+
videoConversionJobId: parsedContent?.videoConversionJobId,
|
|
108
109
|
exameVinculado:
|
|
109
110
|
parsedContent?.exameVinculado != null
|
|
110
111
|
? String(parsedContent.exameVinculado)
|
|
@@ -115,9 +116,10 @@ export class CourseStructureService {
|
|
|
115
116
|
recursos: lesson.course_lesson_file.map((fileLink) => ({
|
|
116
117
|
id: String(fileLink.id),
|
|
117
118
|
nome: fileLink.title,
|
|
119
|
+
fileId: fileLink.file_id,
|
|
118
120
|
tamanho: '',
|
|
119
|
-
tipo: '',
|
|
120
|
-
publico: true,
|
|
121
|
+
tipo: (fileLink as any).tipo ?? '',
|
|
122
|
+
publico: (fileLink as any).publico ?? true,
|
|
121
123
|
})),
|
|
122
124
|
instrutores: lesson.course_lesson_instructor.map((item) => ({
|
|
123
125
|
id: String(item.instructor_id),
|
|
@@ -375,6 +377,7 @@ export class CourseStructureService {
|
|
|
375
377
|
videoUrl: dto.videoUrl,
|
|
376
378
|
duracaoAutomatica: dto.duracaoAutomatica,
|
|
377
379
|
transcricao: dto.transcricao,
|
|
380
|
+
videoConversionJobId: dto.videoConversionJobId,
|
|
378
381
|
conteudoPost: dto.conteudoPost,
|
|
379
382
|
exameVinculado: dto.exameVinculado,
|
|
380
383
|
descricaoPrivada: dto.descricaoPrivada,
|
|
@@ -610,6 +613,8 @@ export class CourseStructureService {
|
|
|
610
613
|
course_lesson_id: newLesson.id,
|
|
611
614
|
title: f.title,
|
|
612
615
|
file_id: f.file_id,
|
|
616
|
+
tipo: (f as any).tipo ?? null,
|
|
617
|
+
publico: (f as any).publico ?? true,
|
|
613
618
|
})),
|
|
614
619
|
});
|
|
615
620
|
}
|
|
@@ -711,6 +716,8 @@ export class CourseStructureService {
|
|
|
711
716
|
course_lesson_id: newLesson.id,
|
|
712
717
|
title: f.title,
|
|
713
718
|
file_id: f.file_id,
|
|
719
|
+
tipo: (f as any).tipo ?? null,
|
|
720
|
+
publico: (f as any).publico ?? true,
|
|
714
721
|
})),
|
|
715
722
|
});
|
|
716
723
|
}
|
|
@@ -799,6 +806,8 @@ export class CourseStructureService {
|
|
|
799
806
|
course_lesson_id: newLesson.id,
|
|
800
807
|
title: f.title,
|
|
801
808
|
file_id: f.file_id,
|
|
809
|
+
tipo: (f as any).tipo ?? null,
|
|
810
|
+
publico: (f as any).publico ?? true,
|
|
802
811
|
})),
|
|
803
812
|
});
|
|
804
813
|
}
|
|
@@ -883,6 +892,8 @@ export class CourseStructureService {
|
|
|
883
892
|
course_lesson_id: lessonId,
|
|
884
893
|
title: recurso.nome,
|
|
885
894
|
file_id: recurso.fileId ?? null,
|
|
895
|
+
tipo: recurso.tipo ?? null,
|
|
896
|
+
publico: recurso.publico ?? true,
|
|
886
897
|
})),
|
|
887
898
|
});
|
|
888
899
|
}
|
|
@@ -969,6 +980,7 @@ export class CourseStructureService {
|
|
|
969
980
|
videoUrl: parsedContent?.videoUrl,
|
|
970
981
|
duracaoAutomatica: parsedContent?.duracaoAutomatica,
|
|
971
982
|
transcricao: parsedContent?.transcricao,
|
|
983
|
+
videoConversionJobId: parsedContent?.videoConversionJobId,
|
|
972
984
|
exameVinculado:
|
|
973
985
|
parsedContent?.exameVinculado != null
|
|
974
986
|
? String(parsedContent.exameVinculado)
|
|
@@ -979,9 +991,10 @@ export class CourseStructureService {
|
|
|
979
991
|
recursos: lesson.course_lesson_file.map((fileLink) => ({
|
|
980
992
|
id: String(fileLink.id),
|
|
981
993
|
nome: fileLink.title,
|
|
994
|
+
fileId: fileLink.file_id,
|
|
982
995
|
tamanho: '',
|
|
983
|
-
tipo: '',
|
|
984
|
-
publico: true,
|
|
996
|
+
tipo: (fileLink as any).tipo ?? '',
|
|
997
|
+
publico: (fileLink as any).publico ?? true,
|
|
985
998
|
})),
|
|
986
999
|
instrutores: lesson.course_lesson_instructor.map((item) => ({
|
|
987
1000
|
id: String(item.instructor_id),
|
|
@@ -1055,6 +1068,7 @@ export class CourseStructureService {
|
|
|
1055
1068
|
videoUrl?: string;
|
|
1056
1069
|
duracaoAutomatica?: boolean;
|
|
1057
1070
|
transcricao?: string;
|
|
1071
|
+
videoConversionJobId?: number;
|
|
1058
1072
|
conteudoPost?: string;
|
|
1059
1073
|
exameVinculado?: number;
|
|
1060
1074
|
descricaoPrivada?: string;
|
|
@@ -1069,6 +1083,9 @@ export class CourseStructureService {
|
|
|
1069
1083
|
payload.videoUrl = data.videoUrl ?? '';
|
|
1070
1084
|
payload.duracaoAutomatica = data.duracaoAutomatica ?? false;
|
|
1071
1085
|
payload.transcricao = data.transcricao ?? '';
|
|
1086
|
+
if (data.videoConversionJobId != null) {
|
|
1087
|
+
payload.videoConversionJobId = data.videoConversionJobId;
|
|
1088
|
+
}
|
|
1072
1089
|
}
|
|
1073
1090
|
|
|
1074
1091
|
if (data.tipo === 'post' || data.tipo === 'exercicio') {
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
2
|
+
import { FileService } from '@hed-hog/core';
|
|
3
|
+
import { IJobHandler, QueueHandlerRegistry, QueueJobService } from '@hed-hog/queue';
|
|
4
|
+
import {
|
|
5
|
+
BadRequestException,
|
|
6
|
+
Inject,
|
|
7
|
+
Injectable,
|
|
8
|
+
Logger,
|
|
9
|
+
NotFoundException,
|
|
10
|
+
OnModuleInit,
|
|
11
|
+
forwardRef,
|
|
12
|
+
} from '@nestjs/common';
|
|
13
|
+
import { execFile } from 'child_process';
|
|
14
|
+
import { promises as fs } from 'fs';
|
|
15
|
+
import { tmpdir } from 'os';
|
|
16
|
+
import { basename, join } from 'path';
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
|
|
19
|
+
export const LMS_VIDEO_CONVERSION_JOB = 'lms.video.convert';
|
|
20
|
+
|
|
21
|
+
type VideoProfilePayload = {
|
|
22
|
+
id: number;
|
|
23
|
+
name: string;
|
|
24
|
+
ffmpeg_params: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const execFileAsync = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
@Injectable()
|
|
30
|
+
export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
31
|
+
private readonly logger = new Logger(CourseVideoConversionService.name);
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
@Inject(forwardRef(() => PrismaService))
|
|
35
|
+
private readonly prisma: PrismaService,
|
|
36
|
+
@Inject(forwardRef(() => FileService))
|
|
37
|
+
private readonly fileService: FileService,
|
|
38
|
+
@Inject(forwardRef(() => QueueHandlerRegistry))
|
|
39
|
+
private readonly registry: QueueHandlerRegistry,
|
|
40
|
+
@Inject(forwardRef(() => QueueJobService))
|
|
41
|
+
private readonly queueJob: QueueJobService,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
onModuleInit() {
|
|
45
|
+
this.registry.register(LMS_VIDEO_CONVERSION_JOB, this);
|
|
46
|
+
this.logger.log(`Registered handler for "${LMS_VIDEO_CONVERSION_JOB}"`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async enqueueConversion(params: {
|
|
50
|
+
courseId: number;
|
|
51
|
+
sessionId: number;
|
|
52
|
+
lessonId: number;
|
|
53
|
+
originalFileId: number;
|
|
54
|
+
}) {
|
|
55
|
+
const lesson = await (this.prisma as any).course_lesson.findFirst({
|
|
56
|
+
where: {
|
|
57
|
+
id: params.lessonId,
|
|
58
|
+
course_module_id: params.sessionId,
|
|
59
|
+
course_module: { course_id: params.courseId },
|
|
60
|
+
},
|
|
61
|
+
include: { course_module: true },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!lesson) {
|
|
65
|
+
throw new NotFoundException('Lesson not found for this course session');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const content = this.parseContent(lesson.content);
|
|
69
|
+
if (lesson.type !== 'video' || content?.sourceType !== 'video') {
|
|
70
|
+
throw new BadRequestException('Video conversion is only available for video lessons');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (content?.videoProvedor !== 'file_storage') {
|
|
74
|
+
throw new BadRequestException('Video conversion requires File Storage provider');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const original = await (this.prisma as any).file.findUnique({
|
|
78
|
+
where: { id: params.originalFileId },
|
|
79
|
+
include: { file_mimetype: true },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!original) {
|
|
83
|
+
throw new NotFoundException('Original video file not found');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const mimetype = original.file_mimetype?.name ?? '';
|
|
87
|
+
if (!mimetype.startsWith('video/')) {
|
|
88
|
+
throw new BadRequestException('Original file must be a video');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const profiles = await this.getCourseProfiles(params.courseId);
|
|
92
|
+
if (profiles.length === 0) {
|
|
93
|
+
throw new BadRequestException('Course has no video resolution profiles');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await this.upsertLessonFile({
|
|
97
|
+
lessonId: params.lessonId,
|
|
98
|
+
type: 'video_original',
|
|
99
|
+
fileId: params.originalFileId,
|
|
100
|
+
title: original.filename ?? `Original video ${params.originalFileId}`,
|
|
101
|
+
public: false,
|
|
102
|
+
overwrite: true,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const job = await this.queueJob.enqueue({
|
|
106
|
+
type: LMS_VIDEO_CONVERSION_JOB,
|
|
107
|
+
queueName: LMS_VIDEO_CONVERSION_JOB,
|
|
108
|
+
payload: {
|
|
109
|
+
courseId: params.courseId,
|
|
110
|
+
sessionId: params.sessionId,
|
|
111
|
+
lessonId: params.lessonId,
|
|
112
|
+
originalFileId: params.originalFileId,
|
|
113
|
+
profiles,
|
|
114
|
+
overwrite: true,
|
|
115
|
+
},
|
|
116
|
+
sourceModule: 'lms',
|
|
117
|
+
sourceEntity: 'course_lesson',
|
|
118
|
+
sourceEntityId: String(params.lessonId),
|
|
119
|
+
maxAttempts: 3,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await (this.prisma as any).course_lesson.update({
|
|
123
|
+
where: { id: params.lessonId },
|
|
124
|
+
data: {
|
|
125
|
+
content: JSON.stringify({
|
|
126
|
+
...(content ?? {}),
|
|
127
|
+
sourceType: 'video',
|
|
128
|
+
videoConversionJobId: job.id,
|
|
129
|
+
}),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return { queueJobId: job.id, status: 'queued' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async handle(job: {
|
|
137
|
+
id: number;
|
|
138
|
+
payload: Record<string, any>;
|
|
139
|
+
}): Promise<any> {
|
|
140
|
+
const { courseId, sessionId, lessonId, originalFileId, profiles, overwrite } =
|
|
141
|
+
job.payload as {
|
|
142
|
+
courseId?: number;
|
|
143
|
+
sessionId?: number;
|
|
144
|
+
lessonId?: number;
|
|
145
|
+
originalFileId?: number;
|
|
146
|
+
profiles?: VideoProfilePayload[];
|
|
147
|
+
overwrite?: boolean;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (!courseId || !sessionId || !lessonId || !originalFileId || !profiles?.length) {
|
|
151
|
+
throw new Error('Invalid LMS video conversion payload');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const currentProfiles = await this.getCourseProfiles(courseId);
|
|
155
|
+
const profileIds = new Set(currentProfiles.map((profile) => profile.id));
|
|
156
|
+
const profilesToProcess = profiles.filter((profile) => profileIds.has(profile.id));
|
|
157
|
+
|
|
158
|
+
if (profilesToProcess.length === 0) {
|
|
159
|
+
throw new Error('Course has no active profiles to process');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const maxInputBytes = this.getPositiveIntegerEnv('LMS_VIDEO_MAX_INPUT_BYTES');
|
|
163
|
+
const ffmpegTimeoutMs =
|
|
164
|
+
this.getPositiveIntegerEnv('LMS_VIDEO_FFMPEG_TIMEOUT_MS') ?? 1000 * 60 * 60;
|
|
165
|
+
|
|
166
|
+
const workDir = await fs.mkdtemp(join(tmpdir(), `lms-video-${job.id}-`));
|
|
167
|
+
const inputPath = join(workDir, `original-${originalFileId}.mp4`);
|
|
168
|
+
|
|
169
|
+
this.logger.log(
|
|
170
|
+
`Queue job ${job.id}: starting LMS video conversion (lesson=${lessonId}, profiles=${profilesToProcess.length})`,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const downloadedOriginal = await this.fileService.downloadToPath(
|
|
174
|
+
originalFileId,
|
|
175
|
+
inputPath,
|
|
176
|
+
maxInputBytes ? { maxBytes: maxInputBytes } : undefined,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
this.logger.log(
|
|
180
|
+
`Queue job ${job.id}: original file downloaded (lesson=${lessonId}, size=${downloadedOriginal.size})`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const jobStartedAt = Date.now();
|
|
184
|
+
const results: Array<{
|
|
185
|
+
profileId: number;
|
|
186
|
+
fileId: number;
|
|
187
|
+
outputBytes: number;
|
|
188
|
+
conversionMs: number;
|
|
189
|
+
conversionMBps: number;
|
|
190
|
+
uploadMs: number;
|
|
191
|
+
uploadMBps: number;
|
|
192
|
+
totalMs: number;
|
|
193
|
+
}> = [];
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
for (const profile of profilesToProcess) {
|
|
197
|
+
const profileStartedAt = Date.now();
|
|
198
|
+
const outputPath = join(
|
|
199
|
+
workDir,
|
|
200
|
+
`${this.slugify(profile.name || String(profile.id))}-${profile.id}.mp4`,
|
|
201
|
+
);
|
|
202
|
+
this.logger.log(
|
|
203
|
+
`Queue job ${job.id}: converting profile ${profile.id} for lesson ${lessonId}`,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const conversionStartedAt = Date.now();
|
|
207
|
+
await this.convert(inputPath, outputPath, profile.ffmpeg_params, ffmpegTimeoutMs);
|
|
208
|
+
const conversionMs = Date.now() - conversionStartedAt;
|
|
209
|
+
|
|
210
|
+
const outputStats = await fs.stat(outputPath);
|
|
211
|
+
|
|
212
|
+
const uploadStartedAt = Date.now();
|
|
213
|
+
const uploaded = await this.fileService.uploadFromPath('lms/lessons/videos', outputPath, {
|
|
214
|
+
originalname: basename(outputPath),
|
|
215
|
+
mimetype: 'video/mp4',
|
|
216
|
+
});
|
|
217
|
+
const uploadMs = Date.now() - uploadStartedAt;
|
|
218
|
+
const totalMs = Date.now() - profileStartedAt;
|
|
219
|
+
const conversionMBps = this.calculateMBps(outputStats.size, conversionMs);
|
|
220
|
+
const uploadMBps = this.calculateMBps(outputStats.size, uploadMs);
|
|
221
|
+
|
|
222
|
+
await this.upsertLessonFile({
|
|
223
|
+
lessonId,
|
|
224
|
+
type: this.profileResourceType(profile.id),
|
|
225
|
+
fileId: uploaded.id,
|
|
226
|
+
title: uploaded.filename ?? basename(outputPath),
|
|
227
|
+
public: false,
|
|
228
|
+
overwrite: overwrite !== false,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
results.push({
|
|
232
|
+
profileId: profile.id,
|
|
233
|
+
fileId: uploaded.id,
|
|
234
|
+
outputBytes: outputStats.size,
|
|
235
|
+
conversionMs,
|
|
236
|
+
conversionMBps,
|
|
237
|
+
uploadMs,
|
|
238
|
+
uploadMBps,
|
|
239
|
+
totalMs,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
this.logger.log(
|
|
243
|
+
`Queue job ${job.id}: profile ${profile.id} done for lesson ${lessonId} (fileId=${uploaded.id}, outputBytes=${outputStats.size}, conversionMs=${conversionMs}, conversionMBps=${conversionMBps.toFixed(2)}, uploadMs=${uploadMs}, uploadMBps=${uploadMBps.toFixed(2)}, totalMs=${totalMs})`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
248
|
+
this.logger.log(`Queue job ${job.id}: cleaned temporary folder ${workDir}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const totalMs = Date.now() - jobStartedAt;
|
|
252
|
+
const totalOutputBytes = results.reduce((acc, item) => acc + item.outputBytes, 0);
|
|
253
|
+
|
|
254
|
+
this.logger.log(
|
|
255
|
+
`Queue job ${job.id}: LMS conversion finished (lesson=${lessonId}, profiles=${results.length}, totalMs=${totalMs}, totalOutputBytes=${totalOutputBytes})`,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
lessonId,
|
|
260
|
+
converted: results.length,
|
|
261
|
+
results,
|
|
262
|
+
metrics: {
|
|
263
|
+
totalMs,
|
|
264
|
+
totalOutputBytes,
|
|
265
|
+
averageProfileMs:
|
|
266
|
+
results.length > 0
|
|
267
|
+
? Math.round(results.reduce((acc, item) => acc + item.totalMs, 0) / results.length)
|
|
268
|
+
: 0,
|
|
269
|
+
averageConversionMBps:
|
|
270
|
+
results.length > 0
|
|
271
|
+
? Number(
|
|
272
|
+
(
|
|
273
|
+
results.reduce((acc, item) => acc + item.conversionMBps, 0) / results.length
|
|
274
|
+
).toFixed(2),
|
|
275
|
+
)
|
|
276
|
+
: 0,
|
|
277
|
+
averageUploadMBps:
|
|
278
|
+
results.length > 0
|
|
279
|
+
? Number(
|
|
280
|
+
(results.reduce((acc, item) => acc + item.uploadMBps, 0) / results.length).toFixed(2),
|
|
281
|
+
)
|
|
282
|
+
: 0,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async getCourseProfiles(courseId: number): Promise<VideoProfilePayload[]> {
|
|
288
|
+
const rows = await (this.prisma as any).course_video_resolution_profile.findMany({
|
|
289
|
+
where: { course_id: courseId },
|
|
290
|
+
include: { video_resolution_profile: true },
|
|
291
|
+
orderBy: { video_resolution_profile: { name: 'asc' } },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return (rows as any[])
|
|
295
|
+
.map((row) => row.video_resolution_profile)
|
|
296
|
+
.filter((profile) => profile?.status !== 'inactive')
|
|
297
|
+
.map((profile) => ({
|
|
298
|
+
id: Number(profile.id),
|
|
299
|
+
name: profile.name,
|
|
300
|
+
ffmpeg_params: profile.ffmpeg_params,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private async upsertLessonFile(params: {
|
|
305
|
+
lessonId: number;
|
|
306
|
+
type: string;
|
|
307
|
+
fileId: number;
|
|
308
|
+
title: string;
|
|
309
|
+
public: boolean;
|
|
310
|
+
overwrite: boolean;
|
|
311
|
+
}) {
|
|
312
|
+
if (params.overwrite) {
|
|
313
|
+
await (this.prisma as any).course_lesson_file.deleteMany({
|
|
314
|
+
where: {
|
|
315
|
+
course_lesson_id: params.lessonId,
|
|
316
|
+
tipo: params.type,
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return (this.prisma as any).course_lesson_file.create({
|
|
322
|
+
data: {
|
|
323
|
+
course_lesson_id: params.lessonId,
|
|
324
|
+
file_id: params.fileId,
|
|
325
|
+
title: params.title,
|
|
326
|
+
tipo: params.type,
|
|
327
|
+
publico: params.public,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async convert(
|
|
333
|
+
inputPath: string,
|
|
334
|
+
outputPath: string,
|
|
335
|
+
params: string,
|
|
336
|
+
timeoutMs: number,
|
|
337
|
+
) {
|
|
338
|
+
const args = ['-y', '-i', inputPath, ...this.splitFfmpegParams(params), outputPath];
|
|
339
|
+
try {
|
|
340
|
+
await execFileAsync('ffmpeg', args, {
|
|
341
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
342
|
+
timeout: timeoutMs,
|
|
343
|
+
windowsHide: true,
|
|
344
|
+
});
|
|
345
|
+
} catch (error: any) {
|
|
346
|
+
if (error?.killed && error?.signal === 'SIGTERM') {
|
|
347
|
+
throw new Error(`FFmpeg timed out after ${timeoutMs}ms`);
|
|
348
|
+
}
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private getPositiveIntegerEnv(name: string): number | undefined {
|
|
354
|
+
const raw = process.env[name];
|
|
355
|
+
if (!raw) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const value = Number(raw);
|
|
360
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return Math.floor(value);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private calculateMBps(bytes: number, durationMs: number): number {
|
|
368
|
+
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const seconds = durationMs / 1000;
|
|
377
|
+
return Number((bytes / (1024 * 1024) / seconds).toFixed(2));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private splitFfmpegParams(params: string): string[] {
|
|
381
|
+
const result: string[] = [];
|
|
382
|
+
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
383
|
+
let match: RegExpExecArray | null;
|
|
384
|
+
|
|
385
|
+
while ((match = pattern.exec(params ?? ''))) {
|
|
386
|
+
result.push(match[1] ?? match[2] ?? match[3] ?? '');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return result.filter(Boolean);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private profileResourceType(profileId: number) {
|
|
393
|
+
return `video_profile:${profileId}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private slugify(value: string) {
|
|
397
|
+
return (
|
|
398
|
+
value
|
|
399
|
+
.normalize('NFD')
|
|
400
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
401
|
+
.toLowerCase()
|
|
402
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
403
|
+
.replace(/^-+|-+$/g, '') || 'video'
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private parseContent(content?: string | null): any {
|
|
408
|
+
if (!content) return null;
|
|
409
|
+
try {
|
|
410
|
+
return JSON.parse(content);
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
Controller,
|
|
6
6
|
Delete,
|
|
7
7
|
Get,
|
|
8
|
+
HttpCode,
|
|
9
|
+
HttpStatus,
|
|
8
10
|
Param,
|
|
9
11
|
ParseIntPipe,
|
|
10
12
|
Patch,
|
|
@@ -84,4 +86,20 @@ export class CourseController {
|
|
|
84
86
|
remove(@Param('id', ParseIntPipe) id: number) {
|
|
85
87
|
return this.courseService.remove(id);
|
|
86
88
|
}
|
|
89
|
+
|
|
90
|
+
@Get(':courseId/video-resolution-profiles')
|
|
91
|
+
getCourseVideoProfiles(
|
|
92
|
+
@Param('courseId', ParseIntPipe) courseId: number,
|
|
93
|
+
) {
|
|
94
|
+
return this.courseService.getCourseVideoProfiles(courseId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Post(':courseId/video-resolution-profiles/sync')
|
|
98
|
+
@HttpCode(HttpStatus.OK)
|
|
99
|
+
syncCourseVideoProfiles(
|
|
100
|
+
@Param('courseId', ParseIntPipe) courseId: number,
|
|
101
|
+
@Body() body: { profileIds: number[] },
|
|
102
|
+
) {
|
|
103
|
+
return this.courseService.syncCourseVideoProfiles(courseId, body.profileIds ?? []);
|
|
104
|
+
}
|
|
87
105
|
}
|
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import { PrismaModule } from '@hed-hog/api-prisma';
|
|
2
|
+
import { CoreModule } from '@hed-hog/core';
|
|
3
|
+
import { QueueModule } from '@hed-hog/queue';
|
|
2
4
|
import { forwardRef, Module } from '@nestjs/common';
|
|
3
5
|
import { CourseOperationsIntegrationService } from './course-operations-integration.service';
|
|
4
6
|
import { CourseStructureController } from './course-structure.controller';
|
|
5
7
|
import { CourseStructureService } from './course-structure.service';
|
|
8
|
+
import { CourseVideoConversionService } from './course-video-conversion.service';
|
|
6
9
|
import { LmsCoursesMcpTools } from './course.mcp-tools';
|
|
7
10
|
import { CourseController } from './course.controller';
|
|
8
11
|
import { CourseService } from './course.service';
|
|
9
12
|
import { InstructorModule } from '../instructor/instructor.module';
|
|
10
13
|
|
|
11
14
|
@Module({
|
|
12
|
-
imports: [
|
|
15
|
+
imports: [
|
|
16
|
+
forwardRef(() => PrismaModule),
|
|
17
|
+
forwardRef(() => InstructorModule),
|
|
18
|
+
forwardRef(() => CoreModule),
|
|
19
|
+
forwardRef(() => QueueModule),
|
|
20
|
+
],
|
|
13
21
|
controllers: [CourseController, CourseStructureController],
|
|
14
22
|
providers: [
|
|
15
23
|
CourseOperationsIntegrationService,
|
|
16
24
|
CourseService,
|
|
17
25
|
CourseStructureService,
|
|
26
|
+
CourseVideoConversionService,
|
|
18
27
|
LmsCoursesMcpTools,
|
|
19
28
|
],
|
|
20
|
-
exports: [
|
|
29
|
+
exports: [
|
|
30
|
+
forwardRef(() => CourseService),
|
|
31
|
+
forwardRef(() => CourseStructureService),
|
|
32
|
+
forwardRef(() => CourseVideoConversionService),
|
|
33
|
+
],
|
|
21
34
|
})
|
|
22
35
|
export class CourseModule {}
|