@hed-hog/lms 0.0.353 → 0.0.355

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 (135) hide show
  1. package/dist/course/course-audio-transcription.service.d.ts +29 -0
  2. package/dist/course/course-audio-transcription.service.d.ts.map +1 -0
  3. package/dist/course/course-audio-transcription.service.js +291 -0
  4. package/dist/course/course-audio-transcription.service.js.map +1 -0
  5. package/dist/course/course-lesson.controller.d.ts +10 -0
  6. package/dist/course/course-lesson.controller.d.ts.map +1 -0
  7. package/dist/course/course-lesson.controller.js +62 -0
  8. package/dist/course/course-lesson.controller.js.map +1 -0
  9. package/dist/course/course-structure.controller.d.ts +41 -15
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.controller.js +50 -6
  12. package/dist/course/course-structure.controller.js.map +1 -1
  13. package/dist/course/course-structure.service.d.ts +50 -15
  14. package/dist/course/course-structure.service.d.ts.map +1 -1
  15. package/dist/course/course-structure.service.js +238 -73
  16. package/dist/course/course-structure.service.js.map +1 -1
  17. package/dist/course/course-video-conversion.service.d.ts +20 -2
  18. package/dist/course/course-video-conversion.service.d.ts.map +1 -1
  19. package/dist/course/course-video-conversion.service.js +730 -10
  20. package/dist/course/course-video-conversion.service.js.map +1 -1
  21. package/dist/course/course.controller.d.ts +24 -8
  22. package/dist/course/course.controller.d.ts.map +1 -1
  23. package/dist/course/course.module.d.ts.map +1 -1
  24. package/dist/course/course.module.js +5 -3
  25. package/dist/course/course.module.js.map +1 -1
  26. package/dist/course/course.service.d.ts +24 -8
  27. package/dist/course/course.service.d.ts.map +1 -1
  28. package/dist/course/course.service.js +112 -176
  29. package/dist/course/course.service.js.map +1 -1
  30. package/dist/course/dto/create-course-lesson-frame.dto.d.ts +5 -0
  31. package/dist/course/dto/create-course-lesson-frame.dto.d.ts.map +1 -0
  32. package/dist/course/dto/create-course-lesson-frame.dto.js +30 -0
  33. package/dist/course/dto/create-course-lesson-frame.dto.js.map +1 -0
  34. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +4 -2
  35. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  36. package/dist/course/dto/create-course-structure-lesson.dto.js +10 -3
  37. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  38. package/dist/course/dto/create-course.dto.d.ts +1 -1
  39. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  40. package/dist/course/dto/create-course.dto.js +6 -6
  41. package/dist/course/dto/create-course.dto.js.map +1 -1
  42. package/dist/course/dto/update-course-lesson-frame.dto.d.ts +5 -0
  43. package/dist/course/dto/update-course-lesson-frame.dto.d.ts.map +1 -0
  44. package/dist/course/dto/update-course-lesson-frame.dto.js +32 -0
  45. package/dist/course/dto/update-course-lesson-frame.dto.js.map +1 -0
  46. package/dist/course/dto/update-course-resources.dto.d.ts +4 -2
  47. package/dist/course/dto/update-course-resources.dto.d.ts.map +1 -1
  48. package/dist/course/dto/update-course-resources.dto.js +10 -3
  49. package/dist/course/dto/update-course-resources.dto.js.map +1 -1
  50. package/dist/course/dto/update-transcription-segments.dto.d.ts +10 -0
  51. package/dist/course/dto/update-transcription-segments.dto.d.ts.map +1 -0
  52. package/dist/course/dto/update-transcription-segments.dto.js +38 -0
  53. package/dist/course/dto/update-transcription-segments.dto.js.map +1 -0
  54. package/dist/course/lms-setting.controller.d.ts +13 -0
  55. package/dist/course/lms-setting.controller.d.ts.map +1 -0
  56. package/dist/course/lms-setting.controller.js +53 -0
  57. package/dist/course/lms-setting.controller.js.map +1 -0
  58. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  59. package/dist/enterprise/training/training-admin.service.js +74 -33
  60. package/dist/enterprise/training/training-admin.service.js.map +1 -1
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +2 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/lms.module.d.ts.map +1 -1
  66. package/dist/lms.module.js +6 -0
  67. package/dist/lms.module.js.map +1 -1
  68. package/hedhog/data/route.yaml +63 -0
  69. package/hedhog/data/setting_group.yaml +76 -0
  70. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +3 -0
  71. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +10 -1
  72. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +7 -2
  73. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +5 -2
  74. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +7 -1
  75. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +3 -0
  76. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +95 -50
  77. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +11 -36
  78. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +19 -5
  79. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  80. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +30 -10
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/CourseInstructorsSummaryCard.tsx.ejs +95 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +29 -11
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +42 -31
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +10 -1
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +51 -9
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +25 -22
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +12 -9
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +315 -229
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +3220 -534
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +20 -15
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/icon-action-tooltip.tsx.ejs +35 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +76 -67
  93. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +13 -10
  94. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +18 -16
  95. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +6 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +1 -1
  97. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +23 -11
  98. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +48 -9
  99. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +11 -2
  100. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +121 -15
  101. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +69 -14
  102. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +11 -0
  103. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +31 -0
  104. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +32 -6
  105. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +57 -3
  106. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +46 -6
  107. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +14 -0
  108. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-audio-files.ts.ejs +23 -0
  109. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +34 -0
  110. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +76 -0
  111. package/hedhog/frontend/messages/en.json +39 -3
  112. package/hedhog/frontend/messages/pt.json +39 -3
  113. package/hedhog/table/course.yaml +8 -0
  114. package/hedhog/table/course_lesson_file.yaml +12 -4
  115. package/hedhog/table/course_lesson_transcription_segment.yaml +22 -0
  116. package/hedhog/table/course_lesson_video_frame.yaml +25 -0
  117. package/package.json +8 -8
  118. package/src/course/course-audio-transcription.service.ts +393 -0
  119. package/src/course/course-lesson.controller.ts +28 -0
  120. package/src/course/course-structure.controller.ts +49 -3
  121. package/src/course/course-structure.service.ts +294 -32
  122. package/src/course/course-video-conversion.service.ts +972 -6
  123. package/src/course/course.module.ts +5 -3
  124. package/src/course/course.service.ts +87 -139
  125. package/src/course/dto/create-course-lesson-frame.dto.ts +14 -0
  126. package/src/course/dto/create-course-structure-lesson.dto.ts +19 -3
  127. package/src/course/dto/create-course.dto.ts +5 -5
  128. package/src/course/dto/update-course-lesson-frame.dto.ts +16 -0
  129. package/src/course/dto/update-course-resources.dto.ts +18 -3
  130. package/src/course/dto/update-transcription-segments.dto.ts +20 -0
  131. package/src/course/lms-setting.controller.ts +30 -0
  132. package/src/enterprise/training/training-admin.service.ts +77 -24
  133. package/src/index.ts +2 -0
  134. package/src/lms.module.ts +6 -0
  135. package/hedhog/table/course_instructor.yaml +0 -27
@@ -26,9 +26,64 @@ const util_1 = require("util");
26
26
  exports.LMS_VIDEO_CONVERSION_JOB = 'lms.video.convert';
27
27
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
28
28
  let CourseVideoConversionService = CourseVideoConversionService_1 = class CourseVideoConversionService {
29
- constructor(prisma, fileService, registry, queueJob) {
29
+ async createProgressEvent(queueJobId, message, metadata, notificationContext) {
30
+ try {
31
+ await this.prisma.queue_job_event.create({
32
+ data: Object.assign({ queue_job_id: queueJobId, event_type: 'started', message }, (metadata ? { metadata } : {})),
33
+ });
34
+ }
35
+ catch (error) {
36
+ this.logger.warn(`Queue job ${queueJobId}: failed to persist progress event "${message}": ${error instanceof Error ? error.message : 'unknown error'}`);
37
+ }
38
+ if (notificationContext) {
39
+ const progress = this.resolveNotificationProgress(metadata);
40
+ await this.updateAsyncNotification(notificationContext, progress, message);
41
+ }
42
+ }
43
+ resolveNotificationProgress(metadata) {
44
+ var _a, _b, _c;
45
+ const phase = String((_a = metadata === null || metadata === void 0 ? void 0 : metadata.phase) !== null && _a !== void 0 ? _a : '');
46
+ if (phase === 'download_original')
47
+ return 5;
48
+ if (phase === 'probe_duration')
49
+ return 10;
50
+ if (phase === 'convert_profile') {
51
+ const profileIndex = Number((_b = metadata === null || metadata === void 0 ? void 0 : metadata.profileIndex) !== null && _b !== void 0 ? _b : 0);
52
+ const profileCount = Number((_c = metadata === null || metadata === void 0 ? void 0 : metadata.profileCount) !== null && _c !== void 0 ? _c : 0);
53
+ if (profileIndex > 0 && profileCount > 0) {
54
+ const ratio = Math.min(1, Math.max(0, profileIndex / profileCount));
55
+ return 10 + Math.round(ratio * 45);
56
+ }
57
+ return 40;
58
+ }
59
+ if (phase === 'extract_frames')
60
+ return 65;
61
+ if (phase === 'extract_frames_done')
62
+ return 72;
63
+ if (phase === 'extract_audio')
64
+ return 80;
65
+ if (phase === 'queue_transcription')
66
+ return 88;
67
+ if (phase === 'queue_transcription_done')
68
+ return 90;
69
+ if (phase === 'queue_transcription_skipped')
70
+ return 100;
71
+ return 15;
72
+ }
73
+ async updateAsyncNotification(context, progress, body, success) {
74
+ try {
75
+ await this.notificationService.updateProgress(context.userId, context.notificationId, Object.assign({ progress,
76
+ body }, (success != null ? { success } : {})));
77
+ }
78
+ catch (error) {
79
+ this.logger.warn(`Failed to update async notification ${context.notificationId} for user ${context.userId}: ${error instanceof Error ? error.message : 'unknown error'}`);
80
+ }
81
+ }
82
+ constructor(prisma, fileService, settingService, notificationService, registry, queueJob) {
30
83
  this.prisma = prisma;
31
84
  this.fileService = fileService;
85
+ this.settingService = settingService;
86
+ this.notificationService = notificationService;
32
87
  this.registry = registry;
33
88
  this.queueJob = queueJob;
34
89
  this.logger = new common_1.Logger(CourseVideoConversionService_1.name);
@@ -39,6 +94,11 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
39
94
  }
40
95
  async enqueueConversion(params) {
41
96
  var _a, _b, _c;
97
+ const settings = await this.settingService.getSettingValues('lms-video-conversion-enabled');
98
+ const isVideoConversionEnabled = settings['lms-video-conversion-enabled'] !== false;
99
+ if (!isVideoConversionEnabled) {
100
+ throw new common_1.BadRequestException('Video conversion queue is disabled by settings');
101
+ }
42
102
  const lesson = await this.prisma.course_lesson.findFirst({
43
103
  where: {
44
104
  id: params.lessonId,
@@ -80,6 +140,22 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
80
140
  public: false,
81
141
  overwrite: true,
82
142
  });
143
+ const asyncNotification = await this.notificationService.create({
144
+ user_id: params.userId,
145
+ title: 'Conversão e transcrição de vídeo em andamento',
146
+ body: 'Preparando pipeline de processamento do vídeo...',
147
+ type: 'progress',
148
+ progress: 1,
149
+ started_at: new Date().toISOString(),
150
+ auto_remove: false,
151
+ action_type: 'url',
152
+ action_url: '/queue/jobs',
153
+ action_data: {
154
+ source: 'lms-video-conversion',
155
+ courseId: params.courseId,
156
+ lessonId: params.lessonId,
157
+ },
158
+ });
83
159
  const job = await this.queueJob.enqueue({
84
160
  type: exports.LMS_VIDEO_CONVERSION_JOB,
85
161
  queueName: exports.LMS_VIDEO_CONVERSION_JOB,
@@ -90,6 +166,8 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
90
166
  originalFileId: params.originalFileId,
91
167
  profiles,
92
168
  overwrite: true,
169
+ notificationId: asyncNotification.id,
170
+ notificationUserId: params.userId,
93
171
  },
94
172
  sourceModule: 'lms',
95
173
  sourceEntity: 'course_lesson',
@@ -105,8 +183,18 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
105
183
  return { queueJobId: job.id, status: 'queued' };
106
184
  }
107
185
  async handle(job) {
108
- var _a, _b;
186
+ var _a, _b, _c, _d, _e, _f;
109
187
  const { courseId, sessionId, lessonId, originalFileId, profiles, overwrite } = job.payload;
188
+ const notificationContext = Number.isInteger(Number((_a = job.payload) === null || _a === void 0 ? void 0 : _a.notificationId)) &&
189
+ Number.isInteger(Number((_b = job.payload) === null || _b === void 0 ? void 0 : _b.notificationUserId))
190
+ ? {
191
+ notificationId: Number(job.payload.notificationId),
192
+ userId: Number(job.payload.notificationUserId),
193
+ }
194
+ : undefined;
195
+ const emitProgress = async (message, metadata) => {
196
+ await this.createProgressEvent(job.id, message, metadata, notificationContext);
197
+ };
110
198
  if (!courseId || !sessionId || !lessonId || !originalFileId || !(profiles === null || profiles === void 0 ? void 0 : profiles.length)) {
111
199
  throw new Error('Invalid LMS video conversion payload');
112
200
  }
@@ -117,19 +205,60 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
117
205
  throw new Error('Course has no active profiles to process');
118
206
  }
119
207
  const maxInputBytes = this.getPositiveIntegerEnv('LMS_VIDEO_MAX_INPUT_BYTES');
120
- const ffmpegTimeoutMs = (_a = this.getPositiveIntegerEnv('LMS_VIDEO_FFMPEG_TIMEOUT_MS')) !== null && _a !== void 0 ? _a : 1000 * 60 * 60;
208
+ const ffmpegTimeoutMs = (_c = this.getPositiveIntegerEnv('LMS_VIDEO_FFMPEG_TIMEOUT_MS')) !== null && _c !== void 0 ? _c : 1000 * 60 * 60;
209
+ const intervalSettings = await this.settingService.getSettingValues([
210
+ 'lms-video-frame-capture-interval-seconds',
211
+ 'lms-image-extraction-enabled',
212
+ 'lms-audio-transcription-enabled',
213
+ ]);
214
+ const frameIntervalSeconds = this.resolveFrameIntervalSeconds(intervalSettings['lms-video-frame-capture-interval-seconds']);
215
+ const imageExtractionEnabled = intervalSettings['lms-image-extraction-enabled'] !== false;
216
+ const transcriptionEnabled = intervalSettings['lms-audio-transcription-enabled'] !== false;
121
217
  const workDir = await fs_1.promises.mkdtemp((0, path_1.join)((0, os_1.tmpdir)(), `lms-video-${job.id}-`));
122
218
  const inputPath = (0, path_1.join)(workDir, `original-${originalFileId}.mp4`);
123
219
  this.logger.log(`Queue job ${job.id}: starting LMS video conversion (lesson=${lessonId}, profiles=${profilesToProcess.length})`);
220
+ await emitProgress('Baixando vídeo original...', {
221
+ phase: 'download_original',
222
+ lessonId,
223
+ });
224
+ this.logger.debug(`Queue job ${job.id}: payload resolved (course=${courseId}, session=${sessionId}, lesson=${lessonId}, originalFileId=${originalFileId}, overwrite=${overwrite !== false}, ffmpegTimeoutMs=${ffmpegTimeoutMs}, frameIntervalSeconds=${frameIntervalSeconds}, profiles=[${profilesToProcess.map((profile) => profile.id).join(', ')}])`);
124
225
  const downloadedOriginal = await this.fileService.downloadToPath(originalFileId, inputPath, maxInputBytes ? { maxBytes: maxInputBytes } : undefined);
125
226
  this.logger.log(`Queue job ${job.id}: original file downloaded (lesson=${lessonId}, size=${downloadedOriginal.size})`);
227
+ await emitProgress('Vídeo original baixado. Lendo duração...', {
228
+ phase: 'probe_duration',
229
+ lessonId,
230
+ });
231
+ let probedDuration = null;
232
+ try {
233
+ probedDuration = await this.probeDurationSeconds(inputPath, ffmpegTimeoutMs);
234
+ if (probedDuration !== null && probedDuration > 0) {
235
+ await this.prisma.course_lesson.update({
236
+ where: { id: lessonId },
237
+ data: { duration_seconds: probedDuration },
238
+ });
239
+ this.logger.log(`Queue job ${job.id}: probed video duration=${probedDuration}s, updated lesson ${lessonId}`);
240
+ }
241
+ }
242
+ catch (err) {
243
+ this.logger.warn(`Queue job ${job.id}: failed to probe duration for lesson ${lessonId}: ${(_d = err === null || err === void 0 ? void 0 : err.message) !== null && _d !== void 0 ? _d : err}`);
244
+ }
126
245
  const jobStartedAt = Date.now();
127
246
  const results = [];
247
+ let frameResults = [];
128
248
  try {
129
249
  for (const profile of profilesToProcess) {
130
250
  const profileStartedAt = Date.now();
131
251
  const outputPath = (0, path_1.join)(workDir, `${this.slugify(profile.name || String(profile.id))}-${profile.id}.mp4`);
132
252
  this.logger.log(`Queue job ${job.id}: converting profile ${profile.id} for lesson ${lessonId}`);
253
+ await emitProgress(`Convertendo vídeo para o perfil ${profile.name || profile.id}...`, {
254
+ phase: 'convert_profile',
255
+ lessonId,
256
+ profileId: profile.id,
257
+ profileName: profile.name,
258
+ profileIndex: results.length + 1,
259
+ profileCount: profilesToProcess.length,
260
+ });
261
+ this.logger.debug(`Queue job ${job.id}: profile ${profile.id} ffmpeg params="${profile.ffmpeg_params}" outputPath=${outputPath}`);
133
262
  const conversionStartedAt = Date.now();
134
263
  await this.convert(inputPath, outputPath, profile.ffmpeg_params, ffmpegTimeoutMs);
135
264
  const conversionMs = Date.now() - conversionStartedAt;
@@ -147,7 +276,7 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
147
276
  lessonId,
148
277
  type: this.profileResourceType(profile.id),
149
278
  fileId: uploaded.id,
150
- title: (_b = uploaded.filename) !== null && _b !== void 0 ? _b : (0, path_1.basename)(outputPath),
279
+ title: (_e = uploaded.filename) !== null && _e !== void 0 ? _e : (0, path_1.basename)(outputPath),
151
280
  public: false,
152
281
  overwrite: overwrite !== false,
153
282
  });
@@ -163,6 +292,190 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
163
292
  });
164
293
  this.logger.log(`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})`);
165
294
  }
295
+ if (imageExtractionEnabled) {
296
+ await emitProgress('Extraindo imagens do vídeo...', {
297
+ phase: 'extract_frames',
298
+ lessonId,
299
+ });
300
+ this.logger.debug(`Queue job ${job.id}: starting frame extraction for lesson ${lessonId}`);
301
+ frameResults = await this.extractAndUploadLessonFrames({
302
+ jobId: job.id,
303
+ lessonId,
304
+ inputPath,
305
+ workDir,
306
+ intervalSeconds: frameIntervalSeconds,
307
+ timeoutMs: ffmpegTimeoutMs,
308
+ });
309
+ this.logger.debug(`Queue job ${job.id}: frame extraction finished for lesson ${lessonId} (frames=${frameResults.length})`);
310
+ await emitProgress(frameResults.length > 0
311
+ ? `Imagens extraídas com sucesso (${frameResults.length}).`
312
+ : 'Nenhuma imagem foi extraída do vídeo.', {
313
+ phase: 'extract_frames_done',
314
+ lessonId,
315
+ frames: frameResults.length,
316
+ });
317
+ }
318
+ else {
319
+ this.logger.debug(`Queue job ${job.id}: skipping frame extraction for lesson ${lessonId} (lms-image-extraction-enabled=false)`);
320
+ }
321
+ if (transcriptionEnabled) {
322
+ await emitProgress('Extraindo áudio do vídeo...', {
323
+ phase: 'extract_audio',
324
+ lessonId,
325
+ });
326
+ this.logger.debug(`Queue job ${job.id}: starting audio extraction for lesson ${lessonId}`);
327
+ const audioFileId = await this.extractAndUploadLessonAudio({
328
+ courseId,
329
+ lessonId,
330
+ localInputPath: inputPath,
331
+ tempDir: workDir,
332
+ });
333
+ this.logger.debug(`Queue job ${job.id}: audio extraction finished for lesson ${lessonId} (audioFileId=${audioFileId !== null && audioFileId !== void 0 ? audioFileId : 'null'})`);
334
+ if (audioFileId) {
335
+ await emitProgress('Áudio extraído. Agendando transcrição...', {
336
+ phase: 'queue_transcription',
337
+ lessonId,
338
+ audioFileId,
339
+ });
340
+ this.logger.debug(`Queue job ${job.id}: enqueueing transcription job for lesson ${lessonId} with audioFileId=${audioFileId}`);
341
+ const transcriptionJob = await this.queueJob.enqueue({
342
+ type: 'lms.audio.transcribe',
343
+ queueName: 'lms.audio.transcribe',
344
+ payload: {
345
+ courseId,
346
+ sessionId,
347
+ lessonId,
348
+ audioFileId,
349
+ parentJobId: job.id,
350
+ notificationId: notificationContext === null || notificationContext === void 0 ? void 0 : notificationContext.notificationId,
351
+ notificationUserId: notificationContext === null || notificationContext === void 0 ? void 0 : notificationContext.userId,
352
+ },
353
+ maxAttempts: 3,
354
+ });
355
+ await emitProgress(`Transcrição em IA agendada no job #${transcriptionJob.id}.`, {
356
+ phase: 'queue_transcription_done',
357
+ lessonId,
358
+ audioFileId,
359
+ transcriptionJobId: transcriptionJob.id,
360
+ });
361
+ return {
362
+ lessonId,
363
+ probedDurationSeconds: probedDuration !== null && probedDuration !== void 0 ? probedDuration : null,
364
+ converted: results.length,
365
+ results,
366
+ extractedFrames: frameResults.length,
367
+ frames: frameResults,
368
+ transcriptionJobId: transcriptionJob.id,
369
+ metrics: {
370
+ totalMs: Date.now() - jobStartedAt,
371
+ totalOutputBytes: results.reduce((acc, item) => acc + item.outputBytes, 0),
372
+ averageProfileMs: results.length > 0
373
+ ? Math.round(results.reduce((acc, item) => acc + item.totalMs, 0) / results.length)
374
+ : 0,
375
+ averageConversionMBps: results.length > 0
376
+ ? Number((results.reduce((acc, item) => acc + item.conversionMBps, 0) / results.length).toFixed(2))
377
+ : 0,
378
+ averageUploadMBps: results.length > 0
379
+ ? Number((results.reduce((acc, item) => acc + item.uploadMBps, 0) / results.length).toFixed(2))
380
+ : 0,
381
+ },
382
+ };
383
+ }
384
+ else {
385
+ let transcriptionSkipReason = 'audio extraction returned no file id';
386
+ try {
387
+ const existingAudio = await this.prisma.course_lesson_file.findFirst({
388
+ where: {
389
+ course_lesson_id: lessonId,
390
+ type: 'lesson_audio',
391
+ file_id: { not: null },
392
+ },
393
+ orderBy: { id: 'desc' },
394
+ select: { file_id: true },
395
+ });
396
+ const fallbackAudioFileId = Number((_f = existingAudio === null || existingAudio === void 0 ? void 0 : existingAudio.file_id) !== null && _f !== void 0 ? _f : 0);
397
+ if (fallbackAudioFileId > 0) {
398
+ transcriptionSkipReason =
399
+ 'audio extraction returned no file id, using existing lesson_audio fallback';
400
+ this.logger.warn(`Queue job ${job.id}: audio extraction returned null for lesson ${lessonId}; enqueueing transcription with existing lesson_audio fileId=${fallbackAudioFileId}`);
401
+ await emitProgress('Áudio reutilizado de execução anterior. Agendando transcrição...', {
402
+ phase: 'queue_transcription',
403
+ lessonId,
404
+ audioFileId: fallbackAudioFileId,
405
+ fallback: true,
406
+ });
407
+ const transcriptionJob = await this.queueJob.enqueue({
408
+ type: 'lms.audio.transcribe',
409
+ queueName: 'lms.audio.transcribe',
410
+ payload: {
411
+ courseId,
412
+ sessionId,
413
+ lessonId,
414
+ audioFileId: fallbackAudioFileId,
415
+ parentJobId: job.id,
416
+ notificationId: notificationContext === null || notificationContext === void 0 ? void 0 : notificationContext.notificationId,
417
+ notificationUserId: notificationContext === null || notificationContext === void 0 ? void 0 : notificationContext.userId,
418
+ },
419
+ maxAttempts: 3,
420
+ });
421
+ await emitProgress(`Transcrição em IA agendada no job #${transcriptionJob.id}.`, {
422
+ phase: 'queue_transcription_done',
423
+ lessonId,
424
+ audioFileId: fallbackAudioFileId,
425
+ transcriptionJobId: transcriptionJob.id,
426
+ fallback: true,
427
+ });
428
+ return {
429
+ lessonId,
430
+ probedDurationSeconds: probedDuration !== null && probedDuration !== void 0 ? probedDuration : null,
431
+ converted: results.length,
432
+ results,
433
+ extractedFrames: frameResults.length,
434
+ frames: frameResults,
435
+ transcriptionJobId: transcriptionJob.id,
436
+ metrics: {
437
+ totalMs: Date.now() - jobStartedAt,
438
+ totalOutputBytes: results.reduce((acc, item) => acc + item.outputBytes, 0),
439
+ averageProfileMs: results.length > 0
440
+ ? Math.round(results.reduce((acc, item) => acc + item.totalMs, 0) / results.length)
441
+ : 0,
442
+ averageConversionMBps: results.length > 0
443
+ ? Number((results.reduce((acc, item) => acc + item.conversionMBps, 0) / results.length).toFixed(2))
444
+ : 0,
445
+ averageUploadMBps: results.length > 0
446
+ ? Number((results.reduce((acc, item) => acc + item.uploadMBps, 0) / results.length).toFixed(2))
447
+ : 0,
448
+ },
449
+ };
450
+ }
451
+ }
452
+ catch (fallbackError) {
453
+ transcriptionSkipReason = `fallback lookup failed: ${fallbackError instanceof Error ? fallbackError.message : 'unknown error'}`;
454
+ this.logger.warn(`Queue job ${job.id}: fallback lookup for lesson_audio failed on lesson ${lessonId}: ${fallbackError instanceof Error ? fallbackError.message : 'unknown error'}`);
455
+ }
456
+ this.logger.debug(`Queue job ${job.id}: skipping transcription enqueue for lesson ${lessonId} because no audio file was produced (${transcriptionSkipReason})`);
457
+ await emitProgress('Áudio não disponível. Encerrando pipeline sem transcrição.', {
458
+ phase: 'queue_transcription_skipped',
459
+ lessonId,
460
+ reason: transcriptionSkipReason,
461
+ });
462
+ if (notificationContext) {
463
+ await this.updateAsyncNotification(notificationContext, 100, 'Pipeline concluído sem transcrição: áudio indisponível para processamento.', true);
464
+ }
465
+ }
466
+ }
467
+ else {
468
+ this.logger.debug(`Queue job ${job.id}: skipping audio extraction and transcription for lesson ${lessonId} (lms-audio-transcription-enabled=false)`);
469
+ }
470
+ }
471
+ catch (error) {
472
+ if (notificationContext) {
473
+ const message = error instanceof Error && error.message
474
+ ? error.message
475
+ : 'Falha ao processar conversão de vídeo.';
476
+ await this.updateAsyncNotification(notificationContext, 100, `Falha no pipeline de vídeo: ${message}`, false);
477
+ }
478
+ throw error;
166
479
  }
167
480
  finally {
168
481
  await fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
@@ -171,10 +484,17 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
171
484
  const totalMs = Date.now() - jobStartedAt;
172
485
  const totalOutputBytes = results.reduce((acc, item) => acc + item.outputBytes, 0);
173
486
  this.logger.log(`Queue job ${job.id}: LMS conversion finished (lesson=${lessonId}, profiles=${results.length}, totalMs=${totalMs}, totalOutputBytes=${totalOutputBytes})`);
487
+ if (notificationContext) {
488
+ await this.updateAsyncNotification(notificationContext, 100, 'Conversão e transcrição concluídas com sucesso.', true);
489
+ }
174
490
  return {
175
491
  lessonId,
492
+ probedDurationSeconds: probedDuration !== null && probedDuration !== void 0 ? probedDuration : null,
176
493
  converted: results.length,
177
494
  results,
495
+ extractedFrames: frameResults.length,
496
+ frames: frameResults,
497
+ transcriptionJobId: null,
178
498
  metrics: {
179
499
  totalMs,
180
500
  totalOutputBytes,
@@ -190,6 +510,219 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
190
510
  },
191
511
  };
192
512
  }
513
+ async extractAndUploadLessonFrames(params) {
514
+ const intervalSeconds = Math.max(1, Math.floor(params.intervalSeconds));
515
+ const framesDir = (0, path_1.join)(params.workDir, 'frames');
516
+ await fs_1.promises.mkdir(framesDir, { recursive: true });
517
+ const pattern = (0, path_1.join)(framesDir, 'frame-%08d.jpg');
518
+ this.logger.log(`Queue job ${params.jobId}: extracting lesson frames every ${intervalSeconds}s (lesson=${params.lessonId})`);
519
+ this.logger.debug(`Queue job ${params.jobId}: frame extraction input=${params.inputPath} outputPattern=${pattern} timeoutMs=${params.timeoutMs}`);
520
+ await this.extractFramesFromVideo(params.inputPath, pattern, intervalSeconds, params.timeoutMs);
521
+ const frameFiles = (await fs_1.promises.readdir(framesDir))
522
+ .filter((name) => name.toLowerCase().endsWith('.jpg'))
523
+ .sort((a, b) => a.localeCompare(b));
524
+ this.logger.debug(`Queue job ${params.jobId}: frame extraction produced ${frameFiles.length} file(s) in ${framesDir}`);
525
+ if (frameFiles.length === 0) {
526
+ this.logger.warn(`Queue job ${params.jobId}: no frames extracted for lesson ${params.lessonId}`);
527
+ await this.prisma.course_lesson_video_frame.deleteMany({
528
+ where: { course_lesson_id: params.lessonId },
529
+ });
530
+ return [];
531
+ }
532
+ const uploads = [];
533
+ for (let index = 0; index < frameFiles.length; index += 1) {
534
+ const fileName = frameFiles[index];
535
+ if (!fileName)
536
+ continue;
537
+ const framePath = (0, path_1.join)(framesDir, fileName);
538
+ const stats = await fs_1.promises.stat(framePath);
539
+ const uploaded = await this.fileService.uploadFromPath('lms/lessons/frames', framePath, {
540
+ originalname: fileName,
541
+ mimetype: 'image/jpeg',
542
+ });
543
+ uploads.push({
544
+ fileId: uploaded.id,
545
+ timeSeconds: index * intervalSeconds,
546
+ outputBytes: stats.size,
547
+ });
548
+ this.logger.debug(`Queue job ${params.jobId}: uploaded frame ${fileName} -> fileId=${uploaded.id}, timeSeconds=${index * intervalSeconds}, outputBytes=${stats.size}`);
549
+ }
550
+ await this.prisma.course_lesson_video_frame.deleteMany({
551
+ where: { course_lesson_id: params.lessonId },
552
+ });
553
+ await this.prisma.course_lesson_video_frame.createMany({
554
+ data: uploads.map((item) => ({
555
+ course_lesson_id: params.lessonId,
556
+ file_id: item.fileId,
557
+ time_seconds: item.timeSeconds,
558
+ })),
559
+ });
560
+ this.logger.log(`Queue job ${params.jobId}: extracted and uploaded ${uploads.length} lesson frames (lesson=${params.lessonId})`);
561
+ return uploads;
562
+ }
563
+ async extractAndUploadLessonAudio(params) {
564
+ var _a, _b, _c;
565
+ const ffmpegCommand = this.getFfmpegCommand();
566
+ const mp3Path = (0, path_1.join)(params.tempDir, `lesson_${params.lessonId}_audio.mp3`);
567
+ const mp3LowBitratePath = (0, path_1.join)(params.tempDir, `lesson_${params.lessonId}_audio.low.mp3`);
568
+ let outputPath = mp3Path;
569
+ const mimetype = 'audio/mp3';
570
+ this.logger.debug(`Audio extraction: lesson=${params.lessonId}, input=${params.localInputPath}, preferredOutput=${mp3Path}`);
571
+ try {
572
+ await execFileAsync(ffmpegCommand, [
573
+ '-y',
574
+ '-i',
575
+ params.localInputPath,
576
+ '-vn',
577
+ '-ar',
578
+ '16000',
579
+ '-ac',
580
+ '1',
581
+ '-c:a',
582
+ 'libmp3lame',
583
+ '-b:a',
584
+ '32k',
585
+ mp3Path,
586
+ ], {
587
+ maxBuffer: 1024 * 1024 * 20,
588
+ windowsHide: true,
589
+ });
590
+ }
591
+ catch (error) {
592
+ this.logger.warn(`Audio extraction failed for lesson ${params.lessonId}: ${error instanceof Error ? error.message : 'unknown error'}`);
593
+ return null;
594
+ }
595
+ let uploadedAudioId = null;
596
+ try {
597
+ const course = await this.prisma.course.findUnique({
598
+ where: { id: params.courseId },
599
+ select: { locale_id: true },
600
+ });
601
+ let uploaded;
602
+ try {
603
+ uploaded = await this.fileService.uploadFromPath('lms/lessons/audio', outputPath, {
604
+ originalname: (0, path_1.basename)(outputPath),
605
+ mimetype,
606
+ });
607
+ }
608
+ catch (uploadError) {
609
+ // Retry with an even smaller bitrate when storage max size is too strict.
610
+ this.logger.warn(`Audio upload first attempt failed for lesson ${params.lessonId}, retrying with lower bitrate: ${uploadError instanceof Error ? uploadError.message : 'unknown error'}`);
611
+ outputPath = mp3LowBitratePath;
612
+ await execFileAsync(ffmpegCommand, [
613
+ '-y',
614
+ '-i',
615
+ params.localInputPath,
616
+ '-vn',
617
+ '-ar',
618
+ '16000',
619
+ '-ac',
620
+ '1',
621
+ '-c:a',
622
+ 'libmp3lame',
623
+ '-b:a',
624
+ '16k',
625
+ mp3LowBitratePath,
626
+ ], {
627
+ maxBuffer: 1024 * 1024 * 20,
628
+ windowsHide: true,
629
+ });
630
+ uploaded = await this.fileService.uploadFromPath('lms/lessons/audio', outputPath, {
631
+ originalname: (0, path_1.basename)(outputPath),
632
+ mimetype,
633
+ });
634
+ }
635
+ uploadedAudioId = uploaded.id;
636
+ this.logger.debug(`Audio extraction: lesson=${params.lessonId} uploaded audio fileId=${uploaded.id}, mimetype=${mimetype}, outputPath=${outputPath}`);
637
+ const existingAudio = await this.prisma.course_lesson_file.findFirst({
638
+ where: {
639
+ course_lesson_id: params.lessonId,
640
+ type: 'lesson_audio',
641
+ },
642
+ select: { id: true, locale_id: true },
643
+ });
644
+ const defaultLocale = await this.prisma.locale.findFirst({
645
+ where: {
646
+ OR: [{ code: 'pt-BR' }, { code: 'pt' }],
647
+ },
648
+ select: { id: true },
649
+ orderBy: { id: 'asc' },
650
+ });
651
+ const resolvedLocaleId = (_c = (_b = (_a = existingAudio === null || existingAudio === void 0 ? void 0 : existingAudio.locale_id) !== null && _a !== void 0 ? _a : course === null || course === void 0 ? void 0 : course.locale_id) !== null && _b !== void 0 ? _b : defaultLocale === null || defaultLocale === void 0 ? void 0 : defaultLocale.id) !== null && _c !== void 0 ? _c : null;
652
+ const audioTitle = 'Audio Original.mp3';
653
+ if (existingAudio) {
654
+ await this.prisma.course_lesson_file.update({
655
+ where: { id: existingAudio.id },
656
+ data: {
657
+ file_id: uploaded.id,
658
+ locale_id: resolvedLocaleId,
659
+ title: audioTitle,
660
+ type: 'lesson_audio',
661
+ is_public: false,
662
+ },
663
+ });
664
+ }
665
+ else {
666
+ await this.prisma.course_lesson_file.create({
667
+ data: {
668
+ course_lesson_id: params.lessonId,
669
+ file_id: uploaded.id,
670
+ title: audioTitle,
671
+ type: 'lesson_audio',
672
+ is_public: false,
673
+ locale_id: resolvedLocaleId,
674
+ },
675
+ });
676
+ }
677
+ return uploaded.id;
678
+ }
679
+ catch (error) {
680
+ if (uploadedAudioId) {
681
+ this.logger.warn(`Audio metadata upsert failed for lesson ${params.lessonId}, but audio upload succeeded (fileId=${uploadedAudioId}): ${error instanceof Error ? error.message : 'unknown error'}`);
682
+ return uploadedAudioId;
683
+ }
684
+ this.logger.warn(`Audio upload failed for lesson ${params.lessonId}: ${error instanceof Error ? error.message : 'unknown error'}`);
685
+ return null;
686
+ }
687
+ finally {
688
+ this.logger.debug(`Audio extraction: lesson=${params.lessonId} cleaning temporary audio files (${mp3Path}, ${mp3LowBitratePath})`);
689
+ await fs_1.promises.rm(mp3Path, { force: true }).catch(() => undefined);
690
+ await fs_1.promises.rm(mp3LowBitratePath, { force: true }).catch(() => undefined);
691
+ }
692
+ }
693
+ async extractFramesFromVideo(inputPath, outputPattern, intervalSeconds, timeoutMs) {
694
+ const ffmpegCommand = this.getFfmpegCommand();
695
+ const args = [
696
+ '-y',
697
+ '-i',
698
+ inputPath,
699
+ '-vf',
700
+ `fps=1/${intervalSeconds}`,
701
+ '-q:v',
702
+ '3',
703
+ '-qmin',
704
+ '2',
705
+ '-qmax',
706
+ '4',
707
+ outputPattern,
708
+ ];
709
+ try {
710
+ await execFileAsync(ffmpegCommand, args, {
711
+ maxBuffer: 1024 * 1024 * 20,
712
+ timeout: timeoutMs,
713
+ windowsHide: true,
714
+ });
715
+ }
716
+ catch (error) {
717
+ if ((error === null || error === void 0 ? void 0 : error.code) === 'ENOENT') {
718
+ throw new Error(`FFmpeg binary not found. Configure FFMPEG_PATH or ensure "${ffmpegCommand}" is available in PATH`);
719
+ }
720
+ if ((error === null || error === void 0 ? void 0 : error.killed) && (error === null || error === void 0 ? void 0 : error.signal) === 'SIGTERM') {
721
+ throw new Error(`FFmpeg frame extraction timed out after ${timeoutMs}ms`);
722
+ }
723
+ throw error;
724
+ }
725
+ }
193
726
  async getCourseProfiles(courseId) {
194
727
  const rows = await this.prisma.course_video_resolution_profile.findMany({
195
728
  where: { course_id: courseId },
@@ -206,11 +739,12 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
206
739
  }));
207
740
  }
208
741
  async upsertLessonFile(params) {
742
+ const normalizedType = this.normalizeLessonFileType(params.type);
209
743
  if (params.overwrite) {
210
744
  await this.prisma.course_lesson_file.deleteMany({
211
745
  where: {
212
746
  course_lesson_id: params.lessonId,
213
- tipo: params.type,
747
+ type: normalizedType,
214
748
  },
215
749
  });
216
750
  }
@@ -219,27 +753,197 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
219
753
  course_lesson_id: params.lessonId,
220
754
  file_id: params.fileId,
221
755
  title: params.title,
222
- tipo: params.type,
223
- publico: params.public,
756
+ type: normalizedType,
757
+ is_public: params.public,
224
758
  },
225
759
  });
226
760
  }
761
+ normalizeLessonFileType(value) {
762
+ if (value === 'video_original') {
763
+ return 'video_original';
764
+ }
765
+ if (value === null || value === void 0 ? void 0 : value.startsWith('video_profile:')) {
766
+ return value;
767
+ }
768
+ if (value === 'lesson_audio') {
769
+ return 'lesson_audio';
770
+ }
771
+ if (value === 'student_download' || value === 'file' || value === 'download') {
772
+ return 'student_download';
773
+ }
774
+ return 'supplementary_material';
775
+ }
227
776
  async convert(inputPath, outputPath, params, timeoutMs) {
777
+ const ffmpegCommand = this.getFfmpegCommand();
228
778
  const args = ['-y', '-i', inputPath, ...this.splitFfmpegParams(params), outputPath];
229
779
  try {
230
- await execFileAsync('ffmpeg', args, {
780
+ await execFileAsync(ffmpegCommand, args, {
231
781
  maxBuffer: 1024 * 1024 * 20,
232
782
  timeout: timeoutMs,
233
783
  windowsHide: true,
234
784
  });
235
785
  }
236
786
  catch (error) {
787
+ if ((error === null || error === void 0 ? void 0 : error.code) === 'ENOENT') {
788
+ throw new Error(`FFmpeg binary not found. Configure FFMPEG_PATH or ensure "${ffmpegCommand}" is available in PATH`);
789
+ }
237
790
  if ((error === null || error === void 0 ? void 0 : error.killed) && (error === null || error === void 0 ? void 0 : error.signal) === 'SIGTERM') {
238
791
  throw new Error(`FFmpeg timed out after ${timeoutMs}ms`);
239
792
  }
240
793
  throw error;
241
794
  }
242
795
  }
796
+ getFfmpegCommand() {
797
+ var _a;
798
+ const fromEnv = (_a = process.env.FFMPEG_PATH) === null || _a === void 0 ? void 0 : _a.trim();
799
+ if (fromEnv) {
800
+ return fromEnv;
801
+ }
802
+ if (process.platform === 'win32') {
803
+ const fromWinget = this.findWindowsWingetFfmpeg();
804
+ if (fromWinget) {
805
+ return fromWinget;
806
+ }
807
+ }
808
+ return 'ffmpeg';
809
+ }
810
+ getFfprobeCommand() {
811
+ var _a, _b;
812
+ const fromEnv = (_a = process.env.FFPROBE_PATH) === null || _a === void 0 ? void 0 : _a.trim();
813
+ if (fromEnv) {
814
+ return fromEnv;
815
+ }
816
+ const ffmpegEnv = (_b = process.env.FFMPEG_PATH) === null || _b === void 0 ? void 0 : _b.trim();
817
+ if (ffmpegEnv) {
818
+ const candidate = ffmpegEnv.replace(/ffmpeg(\.exe)?$/i, (_, ext) => `ffprobe${ext !== null && ext !== void 0 ? ext : ''}`);
819
+ if ((0, fs_1.existsSync)(candidate)) {
820
+ return candidate;
821
+ }
822
+ }
823
+ if (process.platform === 'win32') {
824
+ const fromWinget = this.findWindowsWingetFfprobe();
825
+ if (fromWinget) {
826
+ return fromWinget;
827
+ }
828
+ }
829
+ return 'ffprobe';
830
+ }
831
+ findWindowsWingetFfprobe() {
832
+ const localAppData = process.env.LOCALAPPDATA;
833
+ if (!localAppData) {
834
+ return null;
835
+ }
836
+ const wingetPackagesRoot = (0, path_1.join)(localAppData, 'Microsoft', 'WinGet', 'Packages');
837
+ try {
838
+ const packageDirs = (0, fs_1.readdirSync)(wingetPackagesRoot, { withFileTypes: true })
839
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('Gyan.FFmpeg'))
840
+ .map((entry) => (0, path_1.join)(wingetPackagesRoot, entry.name))
841
+ .sort((a, b) => b.localeCompare(a));
842
+ for (const packageDir of packageDirs) {
843
+ const executable = this.findFfprobeExecutableInside(packageDir);
844
+ if (executable) {
845
+ return executable;
846
+ }
847
+ }
848
+ }
849
+ catch (_a) {
850
+ return null;
851
+ }
852
+ return null;
853
+ }
854
+ findFfprobeExecutableInside(baseDir) {
855
+ const directCandidate = (0, path_1.join)(baseDir, 'bin', 'ffprobe.exe');
856
+ if ((0, fs_1.existsSync)(directCandidate)) {
857
+ return directCandidate;
858
+ }
859
+ try {
860
+ const versionDirs = (0, fs_1.readdirSync)(baseDir, { withFileTypes: true })
861
+ .filter((entry) => entry.isDirectory() && entry.name.toLowerCase().startsWith('ffmpeg-'))
862
+ .map((entry) => (0, path_1.join)(baseDir, entry.name))
863
+ .sort((a, b) => b.localeCompare(a));
864
+ for (const versionDir of versionDirs) {
865
+ const candidate = (0, path_1.join)(versionDir, 'bin', 'ffprobe.exe');
866
+ if ((0, fs_1.existsSync)(candidate)) {
867
+ return candidate;
868
+ }
869
+ }
870
+ }
871
+ catch (_a) {
872
+ return null;
873
+ }
874
+ return null;
875
+ }
876
+ async probeDurationSeconds(inputPath, timeoutMs) {
877
+ var _a;
878
+ const ffprobeCommand = this.getFfprobeCommand();
879
+ const args = [
880
+ '-v', 'error',
881
+ '-show_entries', 'format=duration',
882
+ '-of', 'default=noprint_wrappers=1:nokey=1',
883
+ inputPath,
884
+ ];
885
+ try {
886
+ const { stdout } = await execFileAsync(ffprobeCommand, args, {
887
+ maxBuffer: 1024 * 1024,
888
+ timeout: Math.min(timeoutMs, 30000),
889
+ windowsHide: true,
890
+ });
891
+ const parsed = parseFloat(stdout.trim());
892
+ if (!Number.isFinite(parsed) || parsed <= 0) {
893
+ return null;
894
+ }
895
+ return Math.round(parsed);
896
+ }
897
+ catch (error) {
898
+ this.logger.warn(`FFprobe duration extraction failed: ${(_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : error}`);
899
+ return null;
900
+ }
901
+ }
902
+ findWindowsWingetFfmpeg() {
903
+ const localAppData = process.env.LOCALAPPDATA;
904
+ if (!localAppData) {
905
+ return null;
906
+ }
907
+ const wingetPackagesRoot = (0, path_1.join)(localAppData, 'Microsoft', 'WinGet', 'Packages');
908
+ try {
909
+ const packageDirs = (0, fs_1.readdirSync)(wingetPackagesRoot, { withFileTypes: true })
910
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('Gyan.FFmpeg'))
911
+ .map((entry) => (0, path_1.join)(wingetPackagesRoot, entry.name))
912
+ .sort((a, b) => b.localeCompare(a));
913
+ for (const packageDir of packageDirs) {
914
+ const executable = this.findFfmpegExecutableInside(packageDir);
915
+ if (executable) {
916
+ return executable;
917
+ }
918
+ }
919
+ }
920
+ catch (_a) {
921
+ return null;
922
+ }
923
+ return null;
924
+ }
925
+ findFfmpegExecutableInside(baseDir) {
926
+ const directCandidate = (0, path_1.join)(baseDir, 'bin', 'ffmpeg.exe');
927
+ if ((0, fs_1.existsSync)(directCandidate)) {
928
+ return directCandidate;
929
+ }
930
+ try {
931
+ const versionDirs = (0, fs_1.readdirSync)(baseDir, { withFileTypes: true })
932
+ .filter((entry) => entry.isDirectory() && entry.name.toLowerCase().startsWith('ffmpeg-'))
933
+ .map((entry) => (0, path_1.join)(baseDir, entry.name))
934
+ .sort((a, b) => b.localeCompare(a));
935
+ for (const versionDir of versionDirs) {
936
+ const candidate = (0, path_1.join)(versionDir, 'bin', 'ffmpeg.exe');
937
+ if ((0, fs_1.existsSync)(candidate)) {
938
+ return candidate;
939
+ }
940
+ }
941
+ }
942
+ catch (_a) {
943
+ return null;
944
+ }
945
+ return null;
946
+ }
243
947
  getPositiveIntegerEnv(name) {
244
948
  const raw = process.env[name];
245
949
  if (!raw) {
@@ -251,6 +955,18 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
251
955
  }
252
956
  return Math.floor(value);
253
957
  }
958
+ resolveFrameIntervalSeconds(value) {
959
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
960
+ return Math.max(1, Math.floor(value));
961
+ }
962
+ if (typeof value === 'string') {
963
+ const parsed = Number(value);
964
+ if (Number.isFinite(parsed) && parsed > 0) {
965
+ return Math.max(1, Math.floor(parsed));
966
+ }
967
+ }
968
+ return 10;
969
+ }
254
970
  calculateMBps(bytes, durationMs) {
255
971
  if (!Number.isFinite(bytes) || bytes <= 0) {
256
972
  return 0;
@@ -298,10 +1014,14 @@ exports.CourseVideoConversionService = CourseVideoConversionService = CourseVide
298
1014
  (0, common_1.Injectable)(),
299
1015
  __param(0, (0, common_1.Inject)((0, common_1.forwardRef)(() => api_prisma_1.PrismaService))),
300
1016
  __param(1, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.FileService))),
301
- __param(2, (0, common_1.Inject)((0, common_1.forwardRef)(() => queue_1.QueueHandlerRegistry))),
302
- __param(3, (0, common_1.Inject)((0, common_1.forwardRef)(() => queue_1.QueueJobService))),
1017
+ __param(2, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.SettingService))),
1018
+ __param(3, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.NotificationService))),
1019
+ __param(4, (0, common_1.Inject)((0, common_1.forwardRef)(() => queue_1.QueueHandlerRegistry))),
1020
+ __param(5, (0, common_1.Inject)((0, common_1.forwardRef)(() => queue_1.QueueJobService))),
303
1021
  __metadata("design:paramtypes", [api_prisma_1.PrismaService,
304
1022
  core_1.FileService,
1023
+ core_1.SettingService,
1024
+ core_1.NotificationService,
305
1025
  queue_1.QueueHandlerRegistry,
306
1026
  queue_1.QueueJobService])
307
1027
  ], CourseVideoConversionService);