@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.
- package/dist/course/course-audio-transcription.service.d.ts +29 -0
- package/dist/course/course-audio-transcription.service.d.ts.map +1 -0
- package/dist/course/course-audio-transcription.service.js +291 -0
- package/dist/course/course-audio-transcription.service.js.map +1 -0
- package/dist/course/course-lesson.controller.d.ts +10 -0
- package/dist/course/course-lesson.controller.d.ts.map +1 -0
- package/dist/course/course-lesson.controller.js +62 -0
- package/dist/course/course-lesson.controller.js.map +1 -0
- package/dist/course/course-structure.controller.d.ts +41 -15
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +50 -6
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +50 -15
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +238 -73
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-video-conversion.service.d.ts +20 -2
- package/dist/course/course-video-conversion.service.d.ts.map +1 -1
- package/dist/course/course-video-conversion.service.js +730 -10
- package/dist/course/course-video-conversion.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +24 -8
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +5 -3
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +24 -8
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +112 -176
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-lesson-frame.dto.d.ts +5 -0
- package/dist/course/dto/create-course-lesson-frame.dto.d.ts.map +1 -0
- package/dist/course/dto/create-course-lesson-frame.dto.js +30 -0
- package/dist/course/dto/create-course-lesson-frame.dto.js.map +1 -0
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts +4 -2
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js +10 -3
- package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +1 -1
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js +6 -6
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/course/dto/update-course-lesson-frame.dto.d.ts +5 -0
- package/dist/course/dto/update-course-lesson-frame.dto.d.ts.map +1 -0
- package/dist/course/dto/update-course-lesson-frame.dto.js +32 -0
- package/dist/course/dto/update-course-lesson-frame.dto.js.map +1 -0
- package/dist/course/dto/update-course-resources.dto.d.ts +4 -2
- package/dist/course/dto/update-course-resources.dto.d.ts.map +1 -1
- package/dist/course/dto/update-course-resources.dto.js +10 -3
- package/dist/course/dto/update-course-resources.dto.js.map +1 -1
- package/dist/course/dto/update-transcription-segments.dto.d.ts +10 -0
- package/dist/course/dto/update-transcription-segments.dto.d.ts.map +1 -0
- package/dist/course/dto/update-transcription-segments.dto.js +38 -0
- package/dist/course/dto/update-transcription-segments.dto.js.map +1 -0
- package/dist/course/lms-setting.controller.d.ts +13 -0
- package/dist/course/lms-setting.controller.d.ts.map +1 -0
- package/dist/course/lms-setting.controller.js +53 -0
- package/dist/course/lms-setting.controller.js.map +1 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.service.js +74 -33
- package/dist/enterprise/training/training-admin.service.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +6 -0
- package/dist/lms.module.js.map +1 -1
- package/hedhog/data/route.yaml +63 -0
- package/hedhog/data/setting_group.yaml +76 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +10 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +7 -2
- package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +5 -2
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +7 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +95 -50
- package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +11 -36
- package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +19 -5
- package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +30 -10
- package/hedhog/frontend/app/courses/[id]/structure/_components/CourseInstructorsSummaryCard.tsx.ejs +95 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +29 -11
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +42 -31
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +10 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +51 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +25 -22
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +12 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +315 -229
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +3220 -534
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +20 -15
- package/hedhog/frontend/app/courses/[id]/structure/_components/icon-action-tooltip.tsx.ejs +35 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +76 -67
- package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +13 -10
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +18 -16
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +6 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +23 -11
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +48 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +11 -2
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +121 -15
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +69 -14
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +11 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +31 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +32 -6
- package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +57 -3
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +46 -6
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +14 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-audio-files.ts.ejs +23 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +34 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +76 -0
- package/hedhog/frontend/messages/en.json +39 -3
- package/hedhog/frontend/messages/pt.json +39 -3
- package/hedhog/table/course.yaml +8 -0
- package/hedhog/table/course_lesson_file.yaml +12 -4
- package/hedhog/table/course_lesson_transcription_segment.yaml +22 -0
- package/hedhog/table/course_lesson_video_frame.yaml +25 -0
- package/package.json +8 -8
- package/src/course/course-audio-transcription.service.ts +393 -0
- package/src/course/course-lesson.controller.ts +28 -0
- package/src/course/course-structure.controller.ts +49 -3
- package/src/course/course-structure.service.ts +294 -32
- package/src/course/course-video-conversion.service.ts +972 -6
- package/src/course/course.module.ts +5 -3
- package/src/course/course.service.ts +87 -139
- package/src/course/dto/create-course-lesson-frame.dto.ts +14 -0
- package/src/course/dto/create-course-structure-lesson.dto.ts +19 -3
- package/src/course/dto/create-course.dto.ts +5 -5
- package/src/course/dto/update-course-lesson-frame.dto.ts +16 -0
- package/src/course/dto/update-course-resources.dto.ts +18 -3
- package/src/course/dto/update-transcription-segments.dto.ts +20 -0
- package/src/course/lms-setting.controller.ts +30 -0
- package/src/enterprise/training/training-admin.service.ts +77 -24
- package/src/index.ts +2 -0
- package/src/lms.module.ts +6 -0
- package/hedhog/table/course_instructor.yaml +0 -27
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
2
|
+
import { FileService, NotificationService, SettingService } from '@hed-hog/core';
|
|
3
|
+
import { IJobHandler, QueueHandlerRegistry } from '@hed-hog/queue';
|
|
4
|
+
import {
|
|
5
|
+
Inject,
|
|
6
|
+
Injectable,
|
|
7
|
+
Logger,
|
|
8
|
+
OnModuleInit,
|
|
9
|
+
forwardRef,
|
|
10
|
+
} 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 { join } from 'path';
|
|
16
|
+
import { promisify } from 'util';
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
type NotificationContext = {
|
|
21
|
+
userId: number;
|
|
22
|
+
notificationId: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
@Injectable()
|
|
26
|
+
export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandler {
|
|
27
|
+
private readonly logger = new Logger(CourseAudioTranscriptionService.name);
|
|
28
|
+
|
|
29
|
+
private async createProgressEvent(
|
|
30
|
+
queueJobId: number,
|
|
31
|
+
message: string,
|
|
32
|
+
metadata?: Record<string, unknown>,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
await (this.prismaService as any).queue_job_event.create({
|
|
36
|
+
data: {
|
|
37
|
+
queue_job_id: queueJobId,
|
|
38
|
+
event_type: 'started',
|
|
39
|
+
message,
|
|
40
|
+
...(metadata ? { metadata } : {}),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.logger.warn(
|
|
45
|
+
`Queue job ${queueJobId}: failed to persist transcription progress event "${message}": ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private resolveNotificationProgress(metadata?: Record<string, unknown>): number {
|
|
51
|
+
const phase = String(metadata?.phase ?? '');
|
|
52
|
+
|
|
53
|
+
if (phase === 'transcription_download_audio') return 92;
|
|
54
|
+
if (phase === 'transcription_split_audio') return 94;
|
|
55
|
+
if (phase === 'transcription_split_done') return 96;
|
|
56
|
+
if (phase === 'transcription_ai_chunk') {
|
|
57
|
+
const chunkIndex = Number(metadata?.chunkIndex ?? 0);
|
|
58
|
+
const chunkCount = Number(metadata?.chunkCount ?? 0);
|
|
59
|
+
if (chunkIndex > 0 && chunkCount > 0) {
|
|
60
|
+
const ratio = Math.min(1, Math.max(0, chunkIndex / chunkCount));
|
|
61
|
+
return 96 + Math.round(ratio * 3);
|
|
62
|
+
}
|
|
63
|
+
return 98;
|
|
64
|
+
}
|
|
65
|
+
if (phase === 'transcription_save') return 99;
|
|
66
|
+
if (phase === 'transcription_done') return 100;
|
|
67
|
+
|
|
68
|
+
return 95;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async updateAsyncNotification(
|
|
72
|
+
context: NotificationContext,
|
|
73
|
+
progress: number,
|
|
74
|
+
body: string,
|
|
75
|
+
success?: boolean,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
await this.notificationService.updateProgress(
|
|
79
|
+
context.userId,
|
|
80
|
+
context.notificationId,
|
|
81
|
+
{
|
|
82
|
+
progress,
|
|
83
|
+
body,
|
|
84
|
+
...(success != null ? { success } : {}),
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.logger.warn(
|
|
89
|
+
`Failed to update async notification ${context.notificationId} for user ${context.userId}: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
@Inject(forwardRef(() => PrismaService))
|
|
96
|
+
private readonly prismaService: PrismaService,
|
|
97
|
+
@Inject(forwardRef(() => SettingService))
|
|
98
|
+
private readonly settingService: SettingService,
|
|
99
|
+
@Inject(forwardRef(() => FileService))
|
|
100
|
+
private readonly fileService: FileService,
|
|
101
|
+
@Inject(forwardRef(() => NotificationService))
|
|
102
|
+
private readonly notificationService: NotificationService,
|
|
103
|
+
@Inject(forwardRef(() => QueueHandlerRegistry))
|
|
104
|
+
private readonly registry: QueueHandlerRegistry,
|
|
105
|
+
) {}
|
|
106
|
+
|
|
107
|
+
onModuleInit() {
|
|
108
|
+
this.registry.register('lms.audio.transcribe', this);
|
|
109
|
+
this.logger.log('Registered handler for "lms.audio.transcribe"');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async handle(job: {
|
|
113
|
+
id: number;
|
|
114
|
+
type: string;
|
|
115
|
+
queue_name: string;
|
|
116
|
+
payload: Record<string, any>;
|
|
117
|
+
attempts: number;
|
|
118
|
+
max_attempts: number;
|
|
119
|
+
source_module?: string | null;
|
|
120
|
+
source_entity?: string | null;
|
|
121
|
+
source_entity_id?: string | null;
|
|
122
|
+
}): Promise<void> {
|
|
123
|
+
const { courseId, lessonId, audioFileId, parentJobId, notificationId, notificationUserId } = job.payload as {
|
|
124
|
+
courseId: number;
|
|
125
|
+
lessonId: number;
|
|
126
|
+
audioFileId: number;
|
|
127
|
+
parentJobId?: number;
|
|
128
|
+
notificationId?: number;
|
|
129
|
+
notificationUserId?: number;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const notificationContext: NotificationContext | undefined =
|
|
133
|
+
Number.isInteger(Number(notificationId)) &&
|
|
134
|
+
Number.isInteger(Number(notificationUserId))
|
|
135
|
+
? {
|
|
136
|
+
notificationId: Number(notificationId),
|
|
137
|
+
userId: Number(notificationUserId),
|
|
138
|
+
}
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
const emitProgress = async (message: string, metadata?: Record<string, unknown>) => {
|
|
142
|
+
await this.createProgressEvent(job.id, message, metadata);
|
|
143
|
+
if (parentJobId) {
|
|
144
|
+
await this.createProgressEvent(parentJobId, message, metadata);
|
|
145
|
+
}
|
|
146
|
+
if (notificationContext) {
|
|
147
|
+
await this.updateAsyncNotification(
|
|
148
|
+
notificationContext,
|
|
149
|
+
this.resolveNotificationProgress(metadata),
|
|
150
|
+
message,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const settings = await this.settingService.getSettingValues([
|
|
156
|
+
'ai-openai-api-key',
|
|
157
|
+
'ai-openai-api-key-enabled',
|
|
158
|
+
'lms-audio-transcription-enabled',
|
|
159
|
+
]);
|
|
160
|
+
const apiKey = settings['ai-openai-api-key'] as string;
|
|
161
|
+
const enabled = settings['ai-openai-api-key-enabled'];
|
|
162
|
+
const transcriptionEnabled = settings['lms-audio-transcription-enabled'] !== false;
|
|
163
|
+
|
|
164
|
+
if (!transcriptionEnabled) {
|
|
165
|
+
this.logger.warn(
|
|
166
|
+
`Transcription skipped for lesson ${lessonId}: disabled by lms-audio-transcription-enabled setting`,
|
|
167
|
+
);
|
|
168
|
+
if (notificationContext) {
|
|
169
|
+
await this.updateAsyncNotification(
|
|
170
|
+
notificationContext,
|
|
171
|
+
100,
|
|
172
|
+
'Transcrição desabilitada nas configurações do LMS.',
|
|
173
|
+
false,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!enabled || !apiKey) {
|
|
180
|
+
this.logger.warn(
|
|
181
|
+
`Transcription skipped for lesson ${lessonId}: OpenAI key not configured`,
|
|
182
|
+
);
|
|
183
|
+
if (notificationContext) {
|
|
184
|
+
await this.updateAsyncNotification(
|
|
185
|
+
notificationContext,
|
|
186
|
+
100,
|
|
187
|
+
'Transcrição não iniciada: chave da IA não configurada.',
|
|
188
|
+
false,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const course = await (this.prismaService as any).course.findUnique({
|
|
195
|
+
where: { id: courseId },
|
|
196
|
+
include: { locale: true },
|
|
197
|
+
});
|
|
198
|
+
const language = course?.locale?.code ?? 'pt';
|
|
199
|
+
|
|
200
|
+
const tempDir = join(tmpdir(), `lms-transcribe-${lessonId}-${Date.now()}`);
|
|
201
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const sourceAudioPath = join(tempDir, `audio_${lessonId}.source`);
|
|
205
|
+
const normalizedAudioPath = join(tempDir, `audio_${lessonId}.normalized.mp3`);
|
|
206
|
+
await emitProgress('Baixando áudio para transcrição...', {
|
|
207
|
+
phase: 'transcription_download_audio',
|
|
208
|
+
lessonId,
|
|
209
|
+
audioFileId,
|
|
210
|
+
});
|
|
211
|
+
await this.fileService.downloadToPath(audioFileId, sourceAudioPath);
|
|
212
|
+
|
|
213
|
+
await emitProgress('Normalizando áudio para transcrição...', {
|
|
214
|
+
phase: 'transcription_split_audio',
|
|
215
|
+
lessonId,
|
|
216
|
+
});
|
|
217
|
+
await execFileAsync(
|
|
218
|
+
'ffmpeg',
|
|
219
|
+
[
|
|
220
|
+
'-y',
|
|
221
|
+
'-i',
|
|
222
|
+
sourceAudioPath,
|
|
223
|
+
'-ar',
|
|
224
|
+
'16000',
|
|
225
|
+
'-ac',
|
|
226
|
+
'1',
|
|
227
|
+
'-c:a',
|
|
228
|
+
'libmp3lame',
|
|
229
|
+
'-b:a',
|
|
230
|
+
'32k',
|
|
231
|
+
normalizedAudioPath,
|
|
232
|
+
],
|
|
233
|
+
{
|
|
234
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
235
|
+
windowsHide: true,
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const chunkPattern = join(tempDir, 'chunk_%04d.mp3');
|
|
240
|
+
await emitProgress('Dividindo áudio em partes para transcrição...', {
|
|
241
|
+
phase: 'transcription_split_audio',
|
|
242
|
+
lessonId,
|
|
243
|
+
});
|
|
244
|
+
await execFileAsync(
|
|
245
|
+
'ffmpeg',
|
|
246
|
+
[
|
|
247
|
+
'-y',
|
|
248
|
+
'-i',
|
|
249
|
+
normalizedAudioPath,
|
|
250
|
+
'-f',
|
|
251
|
+
'segment',
|
|
252
|
+
'-segment_time',
|
|
253
|
+
'60',
|
|
254
|
+
'-ar',
|
|
255
|
+
'16000',
|
|
256
|
+
'-ac',
|
|
257
|
+
'1',
|
|
258
|
+
'-c:a',
|
|
259
|
+
'libmp3lame',
|
|
260
|
+
'-b:a',
|
|
261
|
+
'32k',
|
|
262
|
+
chunkPattern,
|
|
263
|
+
],
|
|
264
|
+
{
|
|
265
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
266
|
+
windowsHide: true,
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const allFiles = await fs.readdir(tempDir);
|
|
271
|
+
const chunks = allFiles
|
|
272
|
+
.filter((name) => name.startsWith('chunk_') && name.endsWith('.mp3'))
|
|
273
|
+
.sort((a, b) => a.localeCompare(b));
|
|
274
|
+
await emitProgress(`Áudio dividido em ${chunks.length} parte(s).`, {
|
|
275
|
+
phase: 'transcription_split_done',
|
|
276
|
+
lessonId,
|
|
277
|
+
chunks: chunks.length,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const allSegments: Array<{
|
|
281
|
+
course_lesson_id: number;
|
|
282
|
+
start_seconds: number;
|
|
283
|
+
end_seconds: number;
|
|
284
|
+
text: string;
|
|
285
|
+
}> = [];
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
288
|
+
const chunkName = chunks[i];
|
|
289
|
+
if (!chunkName) continue;
|
|
290
|
+
await emitProgress(
|
|
291
|
+
`Transcrevendo com IA a parte ${i + 1} de ${chunks.length}...`,
|
|
292
|
+
{
|
|
293
|
+
phase: 'transcription_ai_chunk',
|
|
294
|
+
lessonId,
|
|
295
|
+
chunkIndex: i + 1,
|
|
296
|
+
chunkCount: chunks.length,
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const chunkPath = join(tempDir, chunkName);
|
|
301
|
+
const offsetSeconds = i * 60;
|
|
302
|
+
const chunkBuffer = await fs.readFile(chunkPath);
|
|
303
|
+
|
|
304
|
+
const formData = new FormData();
|
|
305
|
+
formData.append(
|
|
306
|
+
'file',
|
|
307
|
+
new Blob([chunkBuffer], { type: 'audio/mpeg' }),
|
|
308
|
+
chunkName,
|
|
309
|
+
);
|
|
310
|
+
formData.append('model', 'whisper-1');
|
|
311
|
+
formData.append('response_format', 'verbose_json');
|
|
312
|
+
formData.append('language', language);
|
|
313
|
+
|
|
314
|
+
const headers: Record<string, string> = {
|
|
315
|
+
Authorization: `Bearer ${apiKey}`,
|
|
316
|
+
};
|
|
317
|
+
const maybeHeaders = (formData as any).getHeaders?.();
|
|
318
|
+
if (maybeHeaders && typeof maybeHeaders === 'object') {
|
|
319
|
+
Object.assign(headers, maybeHeaders);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const response = await axios.post(
|
|
323
|
+
'https://api.openai.com/v1/audio/transcriptions',
|
|
324
|
+
formData,
|
|
325
|
+
{ headers },
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const segments = response.data?.segments ?? [];
|
|
329
|
+
for (const segment of segments) {
|
|
330
|
+
const text = String(segment?.text ?? '').trim();
|
|
331
|
+
if (!text) continue;
|
|
332
|
+
|
|
333
|
+
allSegments.push({
|
|
334
|
+
course_lesson_id: lessonId,
|
|
335
|
+
start_seconds: offsetSeconds + Number(segment?.start ?? 0),
|
|
336
|
+
end_seconds: offsetSeconds + Number(segment?.end ?? 0),
|
|
337
|
+
text,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
await emitProgress('Salvando transcrição gerada pela IA...', {
|
|
343
|
+
phase: 'transcription_save',
|
|
344
|
+
lessonId,
|
|
345
|
+
segments: allSegments.length,
|
|
346
|
+
});
|
|
347
|
+
await this.prismaService.$transaction([
|
|
348
|
+
(this.prismaService as any).course_lesson_transcription_segment.deleteMany({
|
|
349
|
+
where: { course_lesson_id: lessonId },
|
|
350
|
+
}),
|
|
351
|
+
(this.prismaService as any).course_lesson_transcription_segment.createMany({
|
|
352
|
+
data: allSegments,
|
|
353
|
+
}),
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
this.logger.log(
|
|
357
|
+
`Transcription saved for lesson ${lessonId}: ${allSegments.length} segments`,
|
|
358
|
+
);
|
|
359
|
+
await emitProgress(
|
|
360
|
+
`Transcrição concluída com ${allSegments.length} segmento(s).`,
|
|
361
|
+
{
|
|
362
|
+
phase: 'transcription_done',
|
|
363
|
+
lessonId,
|
|
364
|
+
segments: allSegments.length,
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
if (notificationContext) {
|
|
368
|
+
await this.updateAsyncNotification(
|
|
369
|
+
notificationContext,
|
|
370
|
+
100,
|
|
371
|
+
`Transcrição concluída com ${allSegments.length} segmento(s).`,
|
|
372
|
+
true,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (notificationContext) {
|
|
377
|
+
const message =
|
|
378
|
+
error instanceof Error && error.message
|
|
379
|
+
? error.message
|
|
380
|
+
: 'Falha ao transcrever o áudio.';
|
|
381
|
+
await this.updateAsyncNotification(
|
|
382
|
+
notificationContext,
|
|
383
|
+
100,
|
|
384
|
+
`Falha na transcrição: ${message}`,
|
|
385
|
+
false,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
} finally {
|
|
390
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Role } from '@hed-hog/api';
|
|
2
|
+
import { Body, Controller, Get, Param, ParseIntPipe, Put } from '@nestjs/common';
|
|
3
|
+
import { CourseStructureService } from './course-structure.service';
|
|
4
|
+
import { UpdateTranscriptionSegmentsDTO } from './dto/update-transcription-segments.dto';
|
|
5
|
+
|
|
6
|
+
@Role()
|
|
7
|
+
@Controller('lms/lessons')
|
|
8
|
+
export class CourseLessonController {
|
|
9
|
+
constructor(private readonly courseStructureService: CourseStructureService) {}
|
|
10
|
+
|
|
11
|
+
@Get(':id/transcription-segments')
|
|
12
|
+
getTranscriptionSegments(@Param('id', ParseIntPipe) id: number) {
|
|
13
|
+
return this.courseStructureService.getTranscriptionSegments(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Put(':id/transcription-segments')
|
|
17
|
+
updateTranscriptionSegments(
|
|
18
|
+
@Param('id', ParseIntPipe) id: number,
|
|
19
|
+
@Body() dto: UpdateTranscriptionSegmentsDTO,
|
|
20
|
+
) {
|
|
21
|
+
return this.courseStructureService.updateTranscriptionSegments(id, dto);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Get(':id/audio-files')
|
|
25
|
+
getAudioFiles(@Param('id', ParseIntPipe) id: number) {
|
|
26
|
+
return this.courseStructureService.getAudioFiles(id);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Role } from '@hed-hog/api';
|
|
1
|
+
import { Role, User } from '@hed-hog/api';
|
|
2
2
|
import {
|
|
3
3
|
Body,
|
|
4
4
|
Controller,
|
|
@@ -11,15 +11,17 @@ import {
|
|
|
11
11
|
} from '@nestjs/common';
|
|
12
12
|
import { CourseStructureService } from './course-structure.service';
|
|
13
13
|
import { CourseVideoConversionService } from './course-video-conversion.service';
|
|
14
|
+
import { CreateCourseLessonFrameDto } from './dto/create-course-lesson-frame.dto';
|
|
14
15
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
CreateCourseStructureLessonDto,
|
|
17
|
+
CreateLessonVideoConversionDto,
|
|
17
18
|
} from './dto/create-course-structure-lesson.dto';
|
|
18
19
|
import { CreateCourseStructureSessionDto } from './dto/create-course-structure-session.dto';
|
|
19
20
|
import { MoveLessonDto } from './dto/move-lesson.dto';
|
|
20
21
|
import { PasteLessonsDto } from './dto/paste-lessons.dto';
|
|
21
22
|
import { ReorderLessonsDto } from './dto/reorder-lessons.dto';
|
|
22
23
|
import { ReorderSessionsDto } from './dto/reorder-sessions.dto';
|
|
24
|
+
import { UpdateCourseLessonFrameDto } from './dto/update-course-lesson-frame.dto';
|
|
23
25
|
import { UpdateCourseResourcesDto } from './dto/update-course-resources.dto';
|
|
24
26
|
import { UpdateCourseStructureLessonDto } from './dto/update-course-structure-lesson.dto';
|
|
25
27
|
import { UpdateCourseStructureSessionDto } from './dto/update-course-structure-session.dto';
|
|
@@ -151,12 +153,14 @@ export class CourseStructureController {
|
|
|
151
153
|
|
|
152
154
|
@Post('sessions/:sessionId/lessons/:lessonId/video-conversions')
|
|
153
155
|
createLessonVideoConversion(
|
|
156
|
+
@User() { id: userId },
|
|
154
157
|
@Param('id', ParseIntPipe) courseId: number,
|
|
155
158
|
@Param('sessionId', ParseIntPipe) sessionId: number,
|
|
156
159
|
@Param('lessonId', ParseIntPipe) lessonId: number,
|
|
157
160
|
@Body() dto: CreateLessonVideoConversionDto,
|
|
158
161
|
) {
|
|
159
162
|
return this.courseVideoConversionService.enqueueConversion({
|
|
163
|
+
userId,
|
|
160
164
|
courseId,
|
|
161
165
|
sessionId,
|
|
162
166
|
lessonId,
|
|
@@ -173,6 +177,48 @@ export class CourseStructureController {
|
|
|
173
177
|
return this.courseStructureService.deleteLesson(courseId, sessionId, lessonId);
|
|
174
178
|
}
|
|
175
179
|
|
|
180
|
+
@Delete('sessions/:sessionId/lessons/:lessonId/frames/:frameId')
|
|
181
|
+
deleteLessonFrame(
|
|
182
|
+
@Param('id', ParseIntPipe) courseId: number,
|
|
183
|
+
@Param('sessionId', ParseIntPipe) sessionId: number,
|
|
184
|
+
@Param('lessonId', ParseIntPipe) lessonId: number,
|
|
185
|
+
@Param('frameId', ParseIntPipe) frameId: number,
|
|
186
|
+
) {
|
|
187
|
+
return this.courseStructureService.deleteLessonFrame(courseId, sessionId, lessonId, frameId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@Post('sessions/:sessionId/lessons/:lessonId/frames')
|
|
191
|
+
createLessonFrame(
|
|
192
|
+
@Param('id', ParseIntPipe) courseId: number,
|
|
193
|
+
@Param('sessionId', ParseIntPipe) sessionId: number,
|
|
194
|
+
@Param('lessonId', ParseIntPipe) lessonId: number,
|
|
195
|
+
@Body() dto: CreateCourseLessonFrameDto,
|
|
196
|
+
) {
|
|
197
|
+
return this.courseStructureService.createLessonFrame(
|
|
198
|
+
courseId,
|
|
199
|
+
sessionId,
|
|
200
|
+
lessonId,
|
|
201
|
+
dto,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@Patch('sessions/:sessionId/lessons/:lessonId/frames/:frameId')
|
|
206
|
+
updateLessonFrame(
|
|
207
|
+
@Param('id', ParseIntPipe) courseId: number,
|
|
208
|
+
@Param('sessionId', ParseIntPipe) sessionId: number,
|
|
209
|
+
@Param('lessonId', ParseIntPipe) lessonId: number,
|
|
210
|
+
@Param('frameId', ParseIntPipe) frameId: number,
|
|
211
|
+
@Body() dto: UpdateCourseLessonFrameDto,
|
|
212
|
+
) {
|
|
213
|
+
return this.courseStructureService.updateLessonFrame(
|
|
214
|
+
courseId,
|
|
215
|
+
sessionId,
|
|
216
|
+
lessonId,
|
|
217
|
+
frameId,
|
|
218
|
+
dto,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
176
222
|
@Post('sessions/:sessionId/lessons/:lessonId/duplicate')
|
|
177
223
|
duplicateLesson(
|
|
178
224
|
@Param('id', ParseIntPipe) courseId: number,
|