@hed-hog/lms 0.0.350 → 0.0.351

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.
Files changed (160) hide show
  1. package/dist/certificate/certificate.controller.d.ts +2 -2
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +8 -6
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +5 -2
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +70 -6
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/course/course-structure.controller.d.ts +24 -10
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.controller.js +23 -2
  12. package/dist/course/course-structure.controller.js.map +1 -1
  13. package/dist/course/course-structure.service.d.ts +16 -8
  14. package/dist/course/course-structure.service.d.ts.map +1 -1
  15. package/dist/course/course-structure.service.js +61 -30
  16. package/dist/course/course-structure.service.js.map +1 -1
  17. package/dist/course/course-video-conversion.service.d.ts +37 -0
  18. package/dist/course/course-video-conversion.service.d.ts.map +1 -0
  19. package/dist/course/course-video-conversion.service.js +308 -0
  20. package/dist/course/course-video-conversion.service.js.map +1 -0
  21. package/dist/course/course.controller.d.ts +17 -0
  22. package/dist/course/course.controller.d.ts.map +1 -1
  23. package/dist/course/course.controller.js +23 -0
  24. package/dist/course/course.controller.js.map +1 -1
  25. package/dist/course/course.module.d.ts.map +1 -1
  26. package/dist/course/course.module.js +15 -2
  27. package/dist/course/course.module.js.map +1 -1
  28. package/dist/course/course.service.d.ts +15 -0
  29. package/dist/course/course.service.d.ts.map +1 -1
  30. package/dist/course/course.service.js +103 -49
  31. package/dist/course/course.service.js.map +1 -1
  32. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +5 -1
  33. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  34. package/dist/course/dto/create-course-structure-lesson.dto.js +16 -2
  35. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  36. package/dist/course/dto/create-course.dto.d.ts +1 -0
  37. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  38. package/dist/course/dto/create-course.dto.js +9 -0
  39. package/dist/course/dto/create-course.dto.js.map +1 -1
  40. package/dist/enterprise/enterprise.controller.d.ts +3 -3
  41. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  42. package/dist/enterprise/enterprise.controller.js +0 -1
  43. package/dist/enterprise/enterprise.controller.js.map +1 -1
  44. package/dist/enterprise/enterprise.service.d.ts +3 -3
  45. package/dist/evaluation/evaluation.service.d.ts.map +1 -1
  46. package/dist/evaluation/evaluation.service.js +9 -2
  47. package/dist/evaluation/evaluation.service.js.map +1 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/lms.module.d.ts.map +1 -1
  53. package/dist/lms.module.js +3 -0
  54. package/dist/lms.module.js.map +1 -1
  55. package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.d.ts +6 -0
  56. package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.d.ts.map +1 -0
  57. package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.js +33 -0
  58. package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.js.map +1 -0
  59. package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.d.ts +6 -0
  60. package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.d.ts.map +1 -0
  61. package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.js +33 -0
  62. package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.js.map +1 -0
  63. package/dist/video-resolution-profile/video-resolution-profile.controller.d.ts +38 -0
  64. package/dist/video-resolution-profile/video-resolution-profile.controller.d.ts.map +1 -0
  65. package/dist/video-resolution-profile/video-resolution-profile.controller.js +89 -0
  66. package/dist/video-resolution-profile/video-resolution-profile.controller.js.map +1 -0
  67. package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.d.ts +26 -0
  68. package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.d.ts.map +1 -0
  69. package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.js +160 -0
  70. package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.js.map +1 -0
  71. package/dist/video-resolution-profile/video-resolution-profile.module.d.ts +3 -0
  72. package/dist/video-resolution-profile/video-resolution-profile.module.d.ts.map +1 -0
  73. package/dist/video-resolution-profile/video-resolution-profile.module.js +26 -0
  74. package/dist/video-resolution-profile/video-resolution-profile.module.js.map +1 -0
  75. package/dist/video-resolution-profile/video-resolution-profile.service.d.ts +45 -0
  76. package/dist/video-resolution-profile/video-resolution-profile.service.d.ts.map +1 -0
  77. package/dist/video-resolution-profile/video-resolution-profile.service.js +117 -0
  78. package/dist/video-resolution-profile/video-resolution-profile.service.js.map +1 -0
  79. package/hedhog/data/menu.yaml +17 -0
  80. package/hedhog/data/route.yaml +133 -0
  81. package/hedhog/data/video_resolution_profile.yaml +7 -0
  82. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +269 -324
  83. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +124 -70
  84. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +7 -4
  85. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +2 -2
  86. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +2 -2
  87. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +34 -4
  88. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +28 -3
  89. package/hedhog/frontend/app/achievements/page.tsx.ejs +9 -3
  90. package/hedhog/frontend/app/bitcodes/page.tsx.ejs +9 -3
  91. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +7 -3
  92. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +29 -8
  93. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +14 -0
  94. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +194 -9
  95. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +15 -5
  96. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +9 -5
  97. package/hedhog/frontend/app/classes/page.tsx.ejs +73 -47
  98. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +19 -9
  99. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +24 -1
  100. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +1 -1
  101. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +1 -1
  102. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +28 -16
  103. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +11 -6
  104. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +7 -4
  105. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -0
  106. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +24 -87
  107. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +892 -411
  108. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1004 -293
  109. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +11 -11
  110. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +62 -52
  111. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +2 -0
  112. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -6
  113. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +86 -1
  114. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +3 -0
  115. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +1 -0
  116. package/hedhog/frontend/app/courses/page.tsx.ejs +112 -89
  117. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +1 -1
  118. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +10 -3
  119. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +8 -4
  120. package/hedhog/frontend/app/enterprise/_components/enterprise-person-edit-sheet.tsx.ejs +2 -2
  121. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +10 -4
  122. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +10 -3
  123. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +10 -3
  124. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +10 -3
  125. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +23 -9
  126. package/hedhog/frontend/app/exams/page.tsx.ejs +14 -6
  127. package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +9 -3
  128. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +190 -17
  129. package/hedhog/frontend/app/layout.tsx.ejs +5 -1
  130. package/hedhog/frontend/app/paths/page.tsx.ejs +13 -5
  131. package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +10 -10
  132. package/hedhog/frontend/app/training/page.tsx.ejs +13 -5
  133. package/hedhog/frontend/app/video-resolution-profiles/page.tsx.ejs +607 -0
  134. package/hedhog/frontend/messages/en.json +250 -9
  135. package/hedhog/frontend/messages/pt.json +250 -9
  136. package/hedhog/table/course.yaml +4 -0
  137. package/hedhog/table/course_lesson_file.yaml +8 -0
  138. package/hedhog/table/course_video_resolution_profile.yaml +22 -0
  139. package/hedhog/table/video_resolution_profile.yaml +18 -0
  140. package/package.json +7 -6
  141. package/src/certificate/certificate.controller.ts +19 -14
  142. package/src/certificate/certificate.service.ts +106 -11
  143. package/src/course/course-structure.controller.ts +24 -2
  144. package/src/course/course-structure.service.ts +21 -4
  145. package/src/course/course-video-conversion.service.ts +415 -0
  146. package/src/course/course.controller.ts +18 -0
  147. package/src/course/course.module.ts +15 -2
  148. package/src/course/course.service.ts +72 -2
  149. package/src/course/dto/create-course-structure-lesson.dto.ts +13 -2
  150. package/src/course/dto/create-course.dto.ts +8 -0
  151. package/src/enterprise/enterprise.controller.ts +0 -1
  152. package/src/evaluation/evaluation.service.ts +9 -2
  153. package/src/index.ts +1 -0
  154. package/src/lms.module.ts +3 -0
  155. package/src/video-resolution-profile/dto/create-video-resolution-profile.dto.ts +16 -0
  156. package/src/video-resolution-profile/dto/update-video-resolution-profile.dto.ts +16 -0
  157. package/src/video-resolution-profile/video-resolution-profile.controller.ts +62 -0
  158. package/src/video-resolution-profile/video-resolution-profile.mcp-tools.ts +128 -0
  159. package/src/video-resolution-profile/video-resolution-profile.module.ts +13 -0
  160. 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 { CreateCourseStructureLessonDto } from './dto/create-course-structure-lesson.dto';
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(private readonly courseStructureService: CourseStructureService) {}
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: [forwardRef(() => PrismaModule), forwardRef(() => InstructorModule)],
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: [forwardRef(() => CourseService), forwardRef(() => CourseStructureService)],
29
+ exports: [
30
+ forwardRef(() => CourseService),
31
+ forwardRef(() => CourseStructureService),
32
+ forwardRef(() => CourseVideoConversionService),
33
+ ],
21
34
  })
22
35
  export class CourseModule {}