@hed-hog/lms 0.0.365 → 0.0.366

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 (113) hide show
  1. package/dist/class-group/class-group.controller.d.ts +1 -0
  2. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  3. package/dist/class-group/class-group.service.d.ts +1 -0
  4. package/dist/class-group/class-group.service.d.ts.map +1 -1
  5. package/dist/course/course-structure.controller.d.ts +4 -2
  6. package/dist/course/course-structure.controller.d.ts.map +1 -1
  7. package/dist/course/course-structure.controller.js +6 -3
  8. package/dist/course/course-structure.controller.js.map +1 -1
  9. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  10. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  11. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  12. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  13. package/dist/course/course-video-hls.service.d.ts +14 -0
  14. package/dist/course/course-video-hls.service.d.ts.map +1 -1
  15. package/dist/course/course-video-hls.service.js +25 -8
  16. package/dist/course/course-video-hls.service.js.map +1 -1
  17. package/dist/course/course.controller.d.ts +2 -0
  18. package/dist/course/course.controller.d.ts.map +1 -1
  19. package/dist/course/course.module.d.ts.map +1 -1
  20. package/dist/course/course.module.js +5 -0
  21. package/dist/course/course.module.js.map +1 -1
  22. package/dist/course/course.service.d.ts +2 -0
  23. package/dist/course/course.service.d.ts.map +1 -1
  24. package/dist/course/course.service.js +36 -2
  25. package/dist/course/course.service.js.map +1 -1
  26. package/dist/course/ffmpeg.util.d.ts +10 -0
  27. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  28. package/dist/course/ffmpeg.util.js +79 -0
  29. package/dist/course/ffmpeg.util.js.map +1 -0
  30. package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
  31. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  32. package/dist/course/lms-bulk-upload-automation.service.js +7 -3
  33. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  34. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  35. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  36. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  37. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  38. package/dist/lms.module.d.ts.map +1 -1
  39. package/dist/lms.module.js +10 -0
  40. package/dist/lms.module.js.map +1 -1
  41. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  42. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  43. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  44. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  45. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  46. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  47. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  48. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  49. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  50. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  51. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  52. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  53. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  54. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  55. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  56. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  57. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  58. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  59. package/dist/platforma/platforma-performance.service.js +500 -0
  60. package/dist/platforma/platforma-performance.service.js.map +1 -0
  61. package/dist/platforma/platforma-search.service.d.ts +21 -0
  62. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  63. package/dist/platforma/platforma-search.service.js +64 -0
  64. package/dist/platforma/platforma-search.service.js.map +1 -0
  65. package/dist/platforma/platforma.controller.d.ts +115 -1
  66. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  67. package/dist/platforma/platforma.controller.js +50 -2
  68. package/dist/platforma/platforma.controller.js.map +1 -1
  69. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  70. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  71. package/dist/realtime/lms-realtime.controller.js +31 -0
  72. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  73. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  74. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  75. package/dist/realtime/lms-realtime.service.js.map +1 -1
  76. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  77. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  78. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  83. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
  84. package/hedhog/frontend/app/courses/page.tsx.ejs +40 -9
  85. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  86. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  87. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  88. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  89. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  90. package/hedhog/frontend/app/paths/page.tsx.ejs +38 -4
  91. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  92. package/hedhog/frontend/messages/en.json +18 -0
  93. package/hedhog/frontend/messages/pt.json +21 -1
  94. package/hedhog/table/course_enrollment.yaml +3 -0
  95. package/hedhog/table/lesson_view_event.yaml +66 -0
  96. package/package.json +9 -8
  97. package/src/course/course-structure.controller.ts +3 -1
  98. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  99. package/src/course/course-video-hls.service.ts +30 -10
  100. package/src/course/course.module.ts +5 -0
  101. package/src/course/course.service.ts +46 -1
  102. package/src/course/ffmpeg.util.ts +65 -0
  103. package/src/course/lms-bulk-upload-automation.service.ts +4 -1
  104. package/src/lms.module.ts +10 -0
  105. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  106. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  107. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  108. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  109. package/src/platforma/platforma-performance.service.ts +606 -0
  110. package/src/platforma/platforma-search.service.ts +48 -0
  111. package/src/platforma/platforma.controller.ts +42 -0
  112. package/src/realtime/lms-realtime.controller.ts +27 -1
  113. package/src/realtime/lms-realtime.service.ts +2 -1
@@ -0,0 +1,66 @@
1
+ columns:
2
+ - type: pk
3
+ - name: user_id
4
+ type: int
5
+ isNullable: true
6
+ - name: course_lesson_id
7
+ type: fk
8
+ isNullable: true
9
+ references:
10
+ table: course_lesson
11
+ column: id
12
+ onDelete: SET NULL
13
+ - name: course_enrollment_id
14
+ type: fk
15
+ isNullable: true
16
+ references:
17
+ table: course_enrollment
18
+ column: id
19
+ onDelete: SET NULL
20
+ - name: session_id
21
+ type: varchar
22
+ length: 36
23
+ isNullable: true
24
+ - name: video_position_seconds
25
+ type: int
26
+ default: 0
27
+ - name: ip
28
+ type: varchar
29
+ length: 45
30
+ isNullable: true
31
+ - name: country
32
+ type: varchar
33
+ length: 2
34
+ isNullable: true
35
+ - name: city
36
+ type: varchar
37
+ length: 100
38
+ isNullable: true
39
+ - name: browser
40
+ type: varchar
41
+ length: 50
42
+ isNullable: true
43
+ - name: os
44
+ type: varchar
45
+ length: 50
46
+ isNullable: true
47
+ - name: device_type
48
+ type: varchar
49
+ length: 20
50
+ isNullable: true
51
+ - name: screen_width
52
+ type: int
53
+ isNullable: true
54
+ - name: screen_height
55
+ type: int
56
+ isNullable: true
57
+ - name: is_touch
58
+ type: boolean
59
+ default: false
60
+ - type: created_at
61
+
62
+ indices:
63
+ - columns: [user_id, created_at]
64
+ - columns: [course_lesson_id, created_at]
65
+ - columns: [created_at]
66
+ - columns: [course_enrollment_id]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/lms",
3
- "version": "0.0.365",
3
+ "version": "0.0.366",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -16,14 +16,15 @@
16
16
  "@hed-hog/api-prisma": "0.0.6",
17
17
  "@hed-hog/api-locale": "0.0.14",
18
18
  "@hed-hog/api-pagination": "0.0.7",
19
- "@hed-hog/api-types": "0.0.1",
20
- "@hed-hog/category": "0.0.365",
21
- "@hed-hog/crm": "0.0.365",
22
19
  "@hed-hog/api": "0.0.8",
23
- "@hed-hog/finance": "0.0.365",
24
- "@hed-hog/commerce": "0.0.365",
25
- "@hed-hog/queue": "0.0.365",
26
- "@hed-hog/core": "0.0.365"
20
+ "@hed-hog/category": "0.0.366",
21
+ "@hed-hog/api-types": "0.0.1",
22
+ "@hed-hog/crm": "0.0.366",
23
+ "@hed-hog/finance": "0.0.366",
24
+ "@hed-hog/core": "0.0.366",
25
+ "@hed-hog/queue": "0.0.366",
26
+ "@hed-hog/commerce": "0.0.366",
27
+ "@hed-hog/agent": "0.0.366"
27
28
  },
28
29
  "exports": {
29
30
  ".": {
@@ -10,6 +10,7 @@ import {
10
10
  Post,
11
11
  } from '@nestjs/common';
12
12
  import { CourseStructureService } from './course-structure.service';
13
+ import { CourseVideoAgentPipelineService } from './course-video-agent-pipeline.service';
13
14
  import { CourseVideoConversionService } from './course-video-conversion.service';
14
15
  import { CourseVideoHlsService } from './course-video-hls.service';
15
16
  import { CreateCourseBulkJobDto } from './dto/create-course-bulk-job.dto';
@@ -35,6 +36,7 @@ export class CourseStructureController {
35
36
  private readonly courseStructureService: CourseStructureService,
36
37
  private readonly courseVideoConversionService: CourseVideoConversionService,
37
38
  private readonly courseVideoHlsService: CourseVideoHlsService,
39
+ private readonly courseVideoAgentPipelineService: CourseVideoAgentPipelineService,
38
40
  ) {}
39
41
 
40
42
  @Get()
@@ -162,7 +164,7 @@ export class CourseStructureController {
162
164
  @Param('lessonId', ParseIntPipe) lessonId: number,
163
165
  @Body() dto: CreateLessonVideoConversionDto,
164
166
  ) {
165
- return this.courseVideoHlsService.enqueueHls({
167
+ return this.courseVideoAgentPipelineService.startProcessing({
166
168
  userId,
167
169
  courseId,
168
170
  sessionId,
@@ -0,0 +1,471 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { buildAiConfigFromIntegration, FileService, SettingService } from '@hed-hog/core';
3
+ import {
4
+ DatabaseQueueProvider,
5
+ IJobHandler,
6
+ NonRetryableError,
7
+ QueueHandlerRegistry,
8
+ } from '@hed-hog/queue';
9
+ import { AgentRuntimeService } from '@hed-hog/agent';
10
+ import { Inject, Injectable, Logger, OnModuleInit, forwardRef } from '@nestjs/common';
11
+ import axios from 'axios';
12
+ import { execFile } from 'child_process';
13
+ import { promises as fs } from 'fs';
14
+ import { tmpdir } from 'os';
15
+ import { basename, join } from 'path';
16
+ import { promisify } from 'util';
17
+ import { CourseVideoHlsService } from './course-video-hls.service';
18
+ import { getFfmpegCommand } from './ffmpeg.util';
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ export const VIDEO_PROCESSING_AGENT_SLUG = 'lms-video-processing';
23
+
24
+ export const LMS_AUDIO_EXTRACT_JOB = 'lms.audio.extract';
25
+ export const LMS_AUDIO_SPLIT_JOB = 'lms.audio.split';
26
+ export const LMS_AUDIO_TRANSCRIBE_CHUNKS_JOB = 'lms.audio.transcribe.chunks';
27
+ export const LMS_TRANSCRIPTION_SAVE_JOB = 'lms.transcription.save';
28
+
29
+ const CHUNK_SECONDS = 60;
30
+
31
+ type JobInput = {
32
+ id: number;
33
+ type: string;
34
+ queue_name: string;
35
+ payload: Record<string, any>;
36
+ attempts: number;
37
+ max_attempts: number;
38
+ };
39
+
40
+ /**
41
+ * Decomposed, queue-backed steps of the LMS video transcription pipeline, orchestrated by the
42
+ * seeded `lms-video-processing` agent flow via `tool.queue_dispatch` (dispatch-and-wait).
43
+ *
44
+ * Each step is its own job so it is visible both in the queue dashboard and the agent run
45
+ * timeline. Intermediates are persisted (audio + chunk + transcript files) and only small
46
+ * file-id references travel through the agent state between steps. The existing monolithic
47
+ * `lms.audio.transcribe` job and legacy paths are left untouched.
48
+ */
49
+ @Injectable()
50
+ export class CourseVideoAgentPipelineService implements OnModuleInit, IJobHandler {
51
+ private readonly logger = new Logger(CourseVideoAgentPipelineService.name);
52
+
53
+ constructor(
54
+ @Inject(forwardRef(() => PrismaService))
55
+ private readonly prisma: PrismaService,
56
+ @Inject(forwardRef(() => FileService))
57
+ private readonly fileService: FileService,
58
+ @Inject(forwardRef(() => SettingService))
59
+ private readonly settingService: SettingService,
60
+ @Inject(forwardRef(() => QueueHandlerRegistry))
61
+ private readonly registry: QueueHandlerRegistry,
62
+ @Inject(forwardRef(() => DatabaseQueueProvider))
63
+ private readonly dbQueue: DatabaseQueueProvider,
64
+ @Inject(forwardRef(() => AgentRuntimeService))
65
+ private readonly agentRuntime: AgentRuntimeService,
66
+ @Inject(forwardRef(() => CourseVideoHlsService))
67
+ private readonly hlsService: CourseVideoHlsService,
68
+ ) {}
69
+
70
+ private get db() {
71
+ return this.prisma as any;
72
+ }
73
+
74
+ onModuleInit() {
75
+ for (const type of [
76
+ LMS_AUDIO_EXTRACT_JOB,
77
+ LMS_AUDIO_SPLIT_JOB,
78
+ LMS_AUDIO_TRANSCRIBE_CHUNKS_JOB,
79
+ LMS_TRANSCRIPTION_SAVE_JOB,
80
+ ]) {
81
+ this.registry.register(type, this);
82
+ }
83
+ this.logger.log(
84
+ `Registered handlers for ${LMS_AUDIO_EXTRACT_JOB}, ${LMS_AUDIO_SPLIT_JOB}, ${LMS_AUDIO_TRANSCRIBE_CHUNKS_JOB}, ${LMS_TRANSCRIPTION_SAVE_JOB}`,
85
+ );
86
+ }
87
+
88
+ // ───────────────────────────── orchestration entry ─────────────────────────────
89
+
90
+ /**
91
+ * Validates the lesson/original, registers the `video_original` file, and starts the seeded
92
+ * `lms-video-processing` agent run (async). Falls back to the legacy monolithic HLS pipeline
93
+ * when the agent flow is not seeded yet, so the feature degrades gracefully during rollout.
94
+ */
95
+ async startProcessing(params: {
96
+ userId: number;
97
+ courseId: number;
98
+ sessionId: number;
99
+ lessonId: number;
100
+ originalFileId: number;
101
+ }): Promise<{ agentRunId: number | null; status: string }> {
102
+ const agent = await this.db.agent.findUnique({
103
+ where: { slug: VIDEO_PROCESSING_AGENT_SLUG },
104
+ select: { id: true, status: true },
105
+ });
106
+
107
+ if (!agent || agent.status === 'archived') {
108
+ this.logger.warn(
109
+ `Agent "${VIDEO_PROCESSING_AGENT_SLUG}" unavailable — falling back to legacy enqueueHls.`,
110
+ );
111
+ const legacy = await this.hlsService.enqueueHls(params);
112
+ return { agentRunId: null, status: legacy.status };
113
+ }
114
+
115
+ await this.hlsService.prepareLessonForProcessing({
116
+ courseId: params.courseId,
117
+ sessionId: params.sessionId,
118
+ lessonId: params.lessonId,
119
+ originalFileId: params.originalFileId,
120
+ });
121
+
122
+ const settings = await this.settingService.getSettingValues([
123
+ 'lms-audio-transcription-enabled',
124
+ ]);
125
+ const transcriptionEnabled = settings['lms-audio-transcription-enabled'] !== false;
126
+
127
+ const course = await this.db.course.findUnique({
128
+ where: { id: params.courseId },
129
+ include: { locale: true },
130
+ });
131
+ const localeCode = course?.locale?.code ?? 'pt';
132
+
133
+ const run = await this.agentRuntime.startManualRun(
134
+ agent.id,
135
+ {
136
+ course_id: params.courseId,
137
+ session_id: params.sessionId,
138
+ lesson_id: params.lessonId,
139
+ original_file_id: params.originalFileId,
140
+ user_id: params.userId,
141
+ transcription_enabled: transcriptionEnabled,
142
+ locale_code: localeCode,
143
+ },
144
+ params.userId,
145
+ );
146
+
147
+ this.logger.log(
148
+ `Started agent run ${run?.id} for lesson ${params.lessonId} (transcription=${transcriptionEnabled}).`,
149
+ );
150
+ return { agentRunId: run?.id ?? null, status: 'started' };
151
+ }
152
+
153
+ // ───────────────────────────── job dispatch ─────────────────────────────
154
+
155
+ async handle(job: JobInput): Promise<any> {
156
+ switch (job.type) {
157
+ case LMS_AUDIO_EXTRACT_JOB:
158
+ return this.handleExtract(job);
159
+ case LMS_AUDIO_SPLIT_JOB:
160
+ return this.handleSplit(job);
161
+ case LMS_AUDIO_TRANSCRIBE_CHUNKS_JOB:
162
+ return this.handleTranscribeChunks(job);
163
+ case LMS_TRANSCRIPTION_SAVE_JOB:
164
+ return this.handleSave(job);
165
+ default:
166
+ throw new NonRetryableError(`Unsupported job type "${job.type}"`);
167
+ }
168
+ }
169
+
170
+ /** Step: extract a 16 kHz mono mp3 from the original video and persist it as lesson_audio. */
171
+ private async handleExtract(job: JobInput): Promise<{ audioFileId: number }> {
172
+ const courseId = Number(job.payload?.courseId);
173
+ const lessonId = Number(job.payload?.lessonId);
174
+ const originalFileId = Number(job.payload?.originalFileId);
175
+ if (!lessonId || !originalFileId) {
176
+ throw new NonRetryableError('lms.audio.extract: lessonId and originalFileId are required.');
177
+ }
178
+
179
+ const workDir = await fs.mkdtemp(join(tmpdir(), `lms-extract-${job.id}-`));
180
+ const inputPath = join(workDir, `original-${originalFileId}`);
181
+ const mp3Path = join(workDir, `lesson_${lessonId}_audio.mp3`);
182
+ try {
183
+ await this.fileService.downloadToPath(originalFileId, inputPath);
184
+ await execFileAsync(
185
+ getFfmpegCommand(),
186
+ ['-y', '-i', inputPath, '-vn', '-ar', '16000', '-ac', '1', '-c:a', 'libmp3lame', '-b:a', '32k', mp3Path],
187
+ { maxBuffer: 1024 * 1024 * 20, windowsHide: true },
188
+ );
189
+
190
+ const course = await this.db.course.findUnique({
191
+ where: { id: courseId },
192
+ select: { locale_id: true },
193
+ });
194
+ const uploaded = await this.fileService.uploadFromPath('lms/lessons/audio', mp3Path, {
195
+ originalname: basename(mp3Path),
196
+ mimetype: 'audio/mp3',
197
+ });
198
+ const defaultLocale = await this.db.locale.findFirst({
199
+ where: { OR: [{ code: 'pt-BR' }, { code: 'pt' }] },
200
+ select: { id: true },
201
+ orderBy: { id: 'asc' },
202
+ });
203
+ const resolvedLocaleId = course?.locale_id ?? defaultLocale?.id ?? null;
204
+
205
+ const existing = await this.db.course_lesson_file.findFirst({
206
+ where: { course_lesson_id: lessonId, type: 'lesson_audio' },
207
+ select: { id: true },
208
+ });
209
+ if (existing) {
210
+ await this.db.course_lesson_file.update({
211
+ where: { id: existing.id },
212
+ data: { file_id: uploaded.id, locale_id: resolvedLocaleId, title: 'Audio Original.mp3', type: 'lesson_audio', is_public: false },
213
+ });
214
+ } else {
215
+ await this.db.course_lesson_file.create({
216
+ data: { course_lesson_id: lessonId, file_id: uploaded.id, title: 'Audio Original.mp3', type: 'lesson_audio', is_public: false, locale_id: resolvedLocaleId },
217
+ });
218
+ }
219
+
220
+ this.logger.log(`[extract job=${job.id}] lesson ${lessonId} audio fileId=${uploaded.id}`);
221
+ return { audioFileId: uploaded.id };
222
+ } finally {
223
+ await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
224
+ }
225
+ }
226
+
227
+ /** Step: split the lesson audio into 60s chunks, uploaded to a staging prefix. */
228
+ private async handleSplit(
229
+ job: JobInput,
230
+ ): Promise<{ chunkFileIds: number[]; chunkCount: number }> {
231
+ const lessonId = Number(job.payload?.lessonId);
232
+ const audioFileId = Number(job.payload?.audioFileId);
233
+ if (!lessonId || !audioFileId) {
234
+ throw new NonRetryableError('lms.audio.split: lessonId and audioFileId are required.');
235
+ }
236
+
237
+ const workDir = await fs.mkdtemp(join(tmpdir(), `lms-split-${job.id}-`));
238
+ const sourceAudioPath = join(workDir, `audio_${lessonId}.source`);
239
+ const normalizedAudioPath = join(workDir, `audio_${lessonId}.normalized.mp3`);
240
+ try {
241
+ await this.fileService.downloadToPath(audioFileId, sourceAudioPath);
242
+ await execFileAsync(
243
+ getFfmpegCommand(),
244
+ ['-y', '-i', sourceAudioPath, '-ar', '16000', '-ac', '1', '-c:a', 'libmp3lame', '-b:a', '32k', normalizedAudioPath],
245
+ { maxBuffer: 1024 * 1024 * 20, windowsHide: true },
246
+ );
247
+
248
+ const chunkPattern = join(workDir, 'chunk_%04d.mp3');
249
+ await execFileAsync(
250
+ getFfmpegCommand(),
251
+ ['-y', '-i', normalizedAudioPath, '-f', 'segment', '-segment_time', String(CHUNK_SECONDS), '-ar', '16000', '-ac', '1', '-c:a', 'libmp3lame', '-b:a', '32k', chunkPattern],
252
+ { maxBuffer: 1024 * 1024 * 20, windowsHide: true },
253
+ );
254
+
255
+ const chunkNames = (await fs.readdir(workDir))
256
+ .filter((n) => n.startsWith('chunk_') && n.endsWith('.mp3'))
257
+ .sort((a, b) => a.localeCompare(b));
258
+
259
+ const chunkFileIds: number[] = [];
260
+ for (const name of chunkNames) {
261
+ const uploaded = await this.fileService.uploadFromPath(
262
+ `lms/lessons/transcribe-chunks/${lessonId}`,
263
+ join(workDir, name),
264
+ { originalname: name, mimetype: 'audio/mpeg' },
265
+ );
266
+ chunkFileIds.push(uploaded.id);
267
+ }
268
+
269
+ this.logger.log(`[split job=${job.id}] lesson ${lessonId} → ${chunkFileIds.length} chunk(s)`);
270
+ return { chunkFileIds, chunkCount: chunkFileIds.length };
271
+ } finally {
272
+ await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
273
+ }
274
+ }
275
+
276
+ /** Step: transcribe each chunk via OpenAI Whisper and persist the merged transcript JSON. */
277
+ private async handleTranscribeChunks(
278
+ job: JobInput,
279
+ ): Promise<{ transcriptFileId: number; segmentCount: number }> {
280
+ const courseId = Number(job.payload?.courseId);
281
+ const lessonId = Number(job.payload?.lessonId);
282
+ const chunkFileIds = this.parseIdArray(job.payload?.chunkFileIds);
283
+ if (!lessonId || chunkFileIds.length === 0) {
284
+ throw new NonRetryableError('lms.audio.transcribe.chunks: lessonId and chunkFileIds are required.');
285
+ }
286
+
287
+ const apiKey = await this.resolveOpenAiKey();
288
+ const course = await this.db.course.findUnique({
289
+ where: { id: courseId },
290
+ include: { locale: true },
291
+ });
292
+ const language = course?.locale?.code ?? 'pt';
293
+
294
+ const workDir = await fs.mkdtemp(join(tmpdir(), `lms-transcribe-${job.id}-`));
295
+ try {
296
+ const segments: Array<{
297
+ course_lesson_id: number;
298
+ start_seconds: number;
299
+ end_seconds: number;
300
+ text: string;
301
+ }> = [];
302
+
303
+ for (let i = 0; i < chunkFileIds.length; i += 1) {
304
+ const chunkPath = join(workDir, `chunk_${i}.mp3`);
305
+ await this.fileService.downloadToPath(chunkFileIds[i], chunkPath);
306
+ const offsetSeconds = i * CHUNK_SECONDS;
307
+ const buffer = await fs.readFile(chunkPath);
308
+
309
+ const formData = new FormData();
310
+ formData.append('file', new Blob([buffer], { type: 'audio/mpeg' }), `chunk_${i}.mp3`);
311
+ formData.append('model', 'whisper-1');
312
+ formData.append('response_format', 'verbose_json');
313
+ formData.append('language', language);
314
+
315
+ const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
316
+ const maybeHeaders = (formData as any).getHeaders?.();
317
+ if (maybeHeaders && typeof maybeHeaders === 'object') Object.assign(headers, maybeHeaders);
318
+
319
+ const response = await axios.post(
320
+ 'https://api.openai.com/v1/audio/transcriptions',
321
+ formData,
322
+ { headers },
323
+ );
324
+
325
+ for (const segment of response.data?.segments ?? []) {
326
+ const text = String(segment?.text ?? '').trim();
327
+ if (!text) continue;
328
+ segments.push({
329
+ course_lesson_id: lessonId,
330
+ start_seconds: offsetSeconds + Number(segment?.start ?? 0),
331
+ end_seconds: offsetSeconds + Number(segment?.end ?? 0),
332
+ text,
333
+ });
334
+ }
335
+ }
336
+
337
+ const transcriptPath = join(workDir, `transcript_${lessonId}.json`);
338
+ await fs.writeFile(transcriptPath, JSON.stringify({ lessonId, segments }), 'utf8');
339
+ const uploaded = await this.fileService.uploadFromPath(
340
+ `lms/lessons/transcripts/${lessonId}`,
341
+ transcriptPath,
342
+ { originalname: `transcript_${lessonId}.json`, mimetype: 'application/json' },
343
+ );
344
+
345
+ this.logger.log(`[transcribe job=${job.id}] lesson ${lessonId} → ${segments.length} segment(s)`);
346
+ return { transcriptFileId: uploaded.id, segmentCount: segments.length };
347
+ } finally {
348
+ await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
349
+ }
350
+ }
351
+
352
+ /** Step: join + persist the transcript segments and trigger XP calculation. */
353
+ private async handleSave(job: JobInput): Promise<{ saved: number }> {
354
+ const lessonId = Number(job.payload?.lessonId);
355
+ const transcriptFileId = Number(job.payload?.transcriptFileId);
356
+ const userId = Number(job.payload?.userId) || 0;
357
+ if (!lessonId || !transcriptFileId) {
358
+ throw new NonRetryableError('lms.transcription.save: lessonId and transcriptFileId are required.');
359
+ }
360
+
361
+ const workDir = await fs.mkdtemp(join(tmpdir(), `lms-tsave-${job.id}-`));
362
+ try {
363
+ const transcriptPath = join(workDir, 'transcript.json');
364
+ await this.fileService.downloadToPath(transcriptFileId, transcriptPath);
365
+ const parsed = JSON.parse(await fs.readFile(transcriptPath, 'utf8'));
366
+ const segments: Array<Record<string, any>> = Array.isArray(parsed?.segments)
367
+ ? parsed.segments
368
+ : [];
369
+
370
+ await this.prisma.$transaction([
371
+ this.db.course_lesson_transcription_segment.deleteMany({
372
+ where: { course_lesson_id: lessonId },
373
+ }),
374
+ this.db.course_lesson_transcription_segment.createMany({
375
+ data: segments.map((s) => ({
376
+ course_lesson_id: lessonId,
377
+ start_seconds: Number(s?.start_seconds ?? 0),
378
+ end_seconds: Number(s?.end_seconds ?? 0),
379
+ text: String(s?.text ?? ''),
380
+ })),
381
+ }),
382
+ ]);
383
+
384
+ this.logger.log(`[save job=${job.id}] lesson ${lessonId} saved ${segments.length} segment(s)`);
385
+ await this.triggerXpCalculation(lessonId, userId);
386
+ return { saved: segments.length };
387
+ } finally {
388
+ await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
389
+ }
390
+ }
391
+
392
+ // ───────────────────────────── helpers ─────────────────────────────
393
+
394
+ private parseIdArray(raw: unknown): number[] {
395
+ let arr: unknown = raw;
396
+ if (typeof raw === 'string') {
397
+ try {
398
+ arr = JSON.parse(raw);
399
+ } catch {
400
+ arr = [];
401
+ }
402
+ }
403
+ if (!Array.isArray(arr)) return [];
404
+ return arr.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0);
405
+ }
406
+
407
+ private async resolveOpenAiKey(): Promise<string> {
408
+ const settings = await this.settingService.getSettingValues(['ai-openai-profile-id']);
409
+ const profileId = Number(settings['ai-openai-profile-id']);
410
+ let apiKey = '';
411
+ if (profileId) {
412
+ const profile = await this.db.integration_profile.findUnique({
413
+ where: { id: profileId },
414
+ include: { integration_provider: { select: { slug: true } } },
415
+ });
416
+ if (profile) {
417
+ apiKey = buildAiConfigFromIntegration(profile.integration_provider.slug, profile.config).apiKey;
418
+ }
419
+ }
420
+ if (!apiKey) {
421
+ throw new NonRetryableError(
422
+ 'Transcrição de áudio requer um perfil OpenAI configurado (Settings → LMS → ai-openai-profile-id).',
423
+ );
424
+ }
425
+ return apiKey;
426
+ }
427
+
428
+ private async triggerXpCalculation(lessonId: number, userId: number): Promise<void> {
429
+ try {
430
+ const existingMap = await this.db.lesson_xp_map.findUnique({
431
+ where: { course_lesson_id: lessonId },
432
+ select: { id: true },
433
+ });
434
+
435
+ let mapId: number;
436
+ if (existingMap) {
437
+ mapId = existingMap.id;
438
+ await this.prisma.$executeRawUnsafe(
439
+ `UPDATE lesson_xp_map
440
+ SET status = 'processing'::lesson_xp_map_status_d4e5f6a7b8_enum,
441
+ processing_error = NULL,
442
+ updated_at = NOW()
443
+ WHERE id = $1`,
444
+ mapId,
445
+ );
446
+ } else {
447
+ const rows = await this.prisma.$queryRawUnsafe<{ id: number }[]>(
448
+ `INSERT INTO lesson_xp_map (course_lesson_id, version, total_xp, status, created_at, updated_at)
449
+ VALUES ($1, 1, 0, 'processing'::lesson_xp_map_status_d4e5f6a7b8_enum, NOW(), NOW())
450
+ RETURNING id`,
451
+ lessonId,
452
+ );
453
+ mapId = rows[0].id;
454
+ }
455
+
456
+ await this.dbQueue.enqueue({
457
+ type: 'lms.lesson.xp.calculate',
458
+ queueName: 'lms.lesson.xp.calculate',
459
+ payload: { lessonId, mapId, userId },
460
+ maxAttempts: 3,
461
+ sourceModule: 'lms',
462
+ sourceEntity: 'course_lesson',
463
+ sourceEntityId: String(lessonId),
464
+ });
465
+ } catch (err) {
466
+ this.logger.warn(
467
+ `Failed to enqueue XP calculation for lesson ${lessonId}: ${err instanceof Error ? err.message : String(err)}`,
468
+ );
469
+ }
470
+ }
471
+ }
@@ -70,13 +70,17 @@ export class CourseVideoHlsService implements OnModuleInit, IJobHandler {
70
70
  this.logger.log(`Registered handler for "${LMS_VIDEO_HLS_JOB}"`);
71
71
  }
72
72
 
73
- async enqueueHls(params: {
74
- userId: number;
73
+ /**
74
+ * Validates a lesson is an eligible file-storage video and registers the uploaded original
75
+ * as its `video_original` file. Shared by the legacy `enqueueHls` path and the new
76
+ * agent-orchestrated pipeline (CourseVideoAgentPipelineService.startProcessing).
77
+ */
78
+ async prepareLessonForProcessing(params: {
75
79
  courseId: number;
76
80
  sessionId: number;
77
81
  lessonId: number;
78
82
  originalFileId: number;
79
- }) {
83
+ }): Promise<{ content: Record<string, unknown> | null; original: any }> {
80
84
  const lesson = await (this.prisma as any).course_lesson.findFirst({
81
85
  where: {
82
86
  id: params.lessonId,
@@ -118,6 +122,18 @@ export class CourseVideoHlsService implements OnModuleInit, IJobHandler {
118
122
  overwrite: true,
119
123
  });
120
124
 
125
+ return { content, original };
126
+ }
127
+
128
+ async enqueueHls(params: {
129
+ userId: number;
130
+ courseId: number;
131
+ sessionId: number;
132
+ lessonId: number;
133
+ originalFileId: number;
134
+ }) {
135
+ const { content } = await this.prepareLessonForProcessing(params);
136
+
121
137
  const asyncNotification = await this.notificationService.create({
122
138
  user_id: params.userId,
123
139
  title: 'Geração de HLS em andamento',
@@ -189,12 +205,16 @@ export class CourseVideoHlsService implements OnModuleInit, IJobHandler {
189
205
  source_entity?: string | null;
190
206
  source_entity_id?: string | null;
191
207
  }): Promise<any> {
192
- const { courseId, sessionId, lessonId, originalFileId } = job.payload as {
193
- courseId?: number;
194
- sessionId?: number;
195
- lessonId?: number;
196
- originalFileId?: number;
197
- };
208
+ // Ids are coerced because agent-dispatched payloads template values as strings.
209
+ const courseId = Number(job.payload?.courseId);
210
+ const sessionId = Number(job.payload?.sessionId);
211
+ const lessonId = Number(job.payload?.lessonId);
212
+ const originalFileId = Number(job.payload?.originalFileId);
213
+ // Set by the agent pipeline: HLS-only, since audio extraction + transcription are
214
+ // dispatched as their own visible steps. Legacy callers omit it (full behaviour).
215
+ const skipAudioTranscription =
216
+ job.payload?.skipAudioTranscription === true ||
217
+ job.payload?.skipAudioTranscription === 'true';
198
218
 
199
219
  const notificationContext: NotificationContext | undefined =
200
220
  Number.isInteger(Number(job.payload?.notificationId)) &&
@@ -359,7 +379,7 @@ export class CourseVideoHlsService implements OnModuleInit, IJobHandler {
359
379
  this.logger.debug(`[HLS job=${job.id}] frame extraction disabled — skipping`);
360
380
  }
361
381
 
362
- if (transcriptionEnabled) {
382
+ if (transcriptionEnabled && !skipAudioTranscription) {
363
383
  this.logger.debug(`[HLS job=${job.id}] starting audio extraction for transcription`);
364
384
  await emitProgress('Extraindo áudio do vídeo...', { phase: 'extract_audio', lessonId });
365
385
  const audioFileId = await this.extractAndUploadLessonAudio({