@hed-hog/lms 0.0.361 → 0.0.365
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/bitcode-wallet/bitcode-wallet.service.d.ts +66 -0
- package/dist/bitcode-wallet/bitcode-wallet.service.d.ts.map +1 -1
- package/dist/bitcode-wallet/bitcode-wallet.service.js +91 -0
- package/dist/bitcode-wallet/bitcode-wallet.service.js.map +1 -1
- package/dist/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.d.ts +8 -0
- package/dist/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.d.ts.map +1 -0
- package/dist/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.js +40 -0
- package/dist/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.js.map +1 -0
- package/dist/class-group/class-group.controller.d.ts +16 -16
- package/dist/class-group/class-group.service.d.ts +12 -12
- package/dist/course/course-audio-transcription.service.d.ts +3 -2
- package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
- package/dist/course/course-audio-transcription.service.js +49 -8
- package/dist/course/course-audio-transcription.service.js.map +1 -1
- package/dist/course/course-export-scorm12-worker.service.d.ts +21 -0
- package/dist/course/course-export-scorm12-worker.service.d.ts.map +1 -0
- package/dist/course/course-export-scorm12-worker.service.js +109 -0
- package/dist/course/course-export-scorm12-worker.service.js.map +1 -0
- package/dist/course/course-export-scorm12.service.d.ts +42 -0
- package/dist/course/course-export-scorm12.service.d.ts.map +1 -0
- package/dist/course/course-export-scorm12.service.js +628 -0
- package/dist/course/course-export-scorm12.service.js.map +1 -0
- package/dist/course/course-export.service.d.ts +84 -0
- package/dist/course/course-export.service.d.ts.map +1 -0
- package/dist/course/course-export.service.js +237 -0
- package/dist/course/course-export.service.js.map +1 -0
- package/dist/course/course-lesson.controller.d.ts +4 -0
- package/dist/course/course-lesson.controller.d.ts.map +1 -1
- package/dist/course/course-lesson.controller.js +10 -0
- package/dist/course/course-lesson.controller.js.map +1 -1
- package/dist/course/course-structure.controller.d.ts +24 -9
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +30 -3
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +25 -3
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +234 -24
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-video-conversion.service.d.ts +8 -0
- package/dist/course/course-video-conversion.service.d.ts.map +1 -1
- package/dist/course/course-video-conversion.service.js +87 -51
- package/dist/course/course-video-conversion.service.js.map +1 -1
- package/dist/course/course-video-hls.service.d.ts +57 -0
- package/dist/course/course-video-hls.service.d.ts.map +1 -0
- package/dist/course/course-video-hls.service.js +767 -0
- package/dist/course/course-video-hls.service.js.map +1 -0
- package/dist/course/course.controller.d.ts +115 -11
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.controller.js +66 -28
- package/dist/course/course.controller.js.map +1 -1
- package/dist/course/course.mcp-tools.js +1 -1
- package/dist/course/course.mcp-tools.js.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +13 -0
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +112 -11
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +682 -72
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/cleanup-course-storage.dto.d.ts +6 -0
- package/dist/course/dto/cleanup-course-storage.dto.d.ts.map +1 -0
- package/dist/course/dto/cleanup-course-storage.dto.js +34 -0
- package/dist/course/dto/cleanup-course-storage.dto.js.map +1 -0
- package/dist/course/dto/cleanup-upload-history.dto.d.ts +9 -0
- package/dist/course/dto/cleanup-upload-history.dto.d.ts.map +1 -0
- package/dist/course/dto/cleanup-upload-history.dto.js +36 -0
- package/dist/course/dto/cleanup-upload-history.dto.js.map +1 -0
- package/dist/course/dto/create-course-bulk-job.dto.d.ts +5 -0
- package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -0
- package/dist/course/dto/create-course-bulk-job.dto.js +26 -0
- package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -0
- package/dist/course/dto/create-course-export.dto.d.ts +14 -0
- package/dist/course/dto/create-course-export.dto.d.ts.map +1 -0
- package/dist/course/dto/create-course-export.dto.js +71 -0
- package/dist/course/dto/create-course-export.dto.js.map +1 -0
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts +2 -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 +3 -2
- package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.d.ts +54 -0
- package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -0
- package/dist/course/lms-bulk-upload-automation.service.js +537 -0
- package/dist/course/lms-bulk-upload-automation.service.js.map +1 -0
- package/dist/course/lms-bulk-upload-infra.service.d.ts +32 -0
- package/dist/course/lms-bulk-upload-infra.service.d.ts.map +1 -0
- package/dist/course/lms-bulk-upload-infra.service.js +301 -0
- package/dist/course/lms-bulk-upload-infra.service.js.map +1 -0
- package/dist/course/lms-bulk-upload.constants.d.ts +4 -0
- package/dist/course/lms-bulk-upload.constants.d.ts.map +1 -0
- package/dist/course/lms-bulk-upload.constants.js +7 -0
- package/dist/course/lms-bulk-upload.constants.js.map +1 -0
- package/dist/course/lms-bulk-upload.controller.d.ts +144 -1
- package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.controller.js +114 -4
- package/dist/course/lms-bulk-upload.controller.js.map +1 -1
- package/dist/course/lms-bulk-upload.service.d.ts +153 -3
- package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.js +659 -21
- package/dist/course/lms-bulk-upload.service.js.map +1 -1
- package/dist/course/lms-setting.controller.d.ts +6 -2
- package/dist/course/lms-setting.controller.d.ts.map +1 -1
- package/dist/course/lms-setting.controller.js +25 -8
- package/dist/course/lms-setting.controller.js.map +1 -1
- package/dist/course/scorm12-schemas.d.ts +4 -0
- package/dist/course/scorm12-schemas.d.ts.map +1 -0
- package/dist/course/scorm12-schemas.js +9 -0
- package/dist/course/scorm12-schemas.js.map +1 -0
- package/dist/enterprise/enterprise.controller.d.ts +20 -20
- package/dist/enterprise/enterprise.service.d.ts +20 -20
- package/dist/enterprise/training/training-admin.controller.d.ts +11 -11
- package/dist/enterprise/training/training-admin.service.d.ts +11 -11
- package/dist/enterprise/training/training-instructor.controller.d.ts +2 -2
- package/dist/enterprise/training/training-instructor.service.d.ts +2 -2
- package/dist/enterprise/training/training-student.controller.d.ts +1 -1
- package/dist/enterprise/training/training-student.service.d.ts +52 -1
- package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-student.service.js +217 -4
- package/dist/enterprise/training/training-student.service.js.map +1 -1
- package/dist/enterprise/training/training-viewer.controller.d.ts +2 -2
- package/dist/evaluation/evaluation.controller.d.ts +8 -8
- package/dist/evaluation/evaluation.service.d.ts +26 -8
- package/dist/evaluation/evaluation.service.d.ts.map +1 -1
- package/dist/evaluation/evaluation.service.js +125 -0
- package/dist/evaluation/evaluation.service.js.map +1 -1
- package/dist/exam/dto/create-standalone-question.dto.d.ts +12 -0
- package/dist/exam/dto/create-standalone-question.dto.d.ts.map +1 -0
- package/dist/exam/dto/create-standalone-question.dto.js +70 -0
- package/dist/exam/dto/create-standalone-question.dto.js.map +1 -0
- package/dist/exam/exam.module.d.ts.map +1 -1
- package/dist/exam/exam.module.js +2 -1
- package/dist/exam/exam.module.js.map +1 -1
- package/dist/exam/exam.service.d.ts +21 -0
- package/dist/exam/exam.service.d.ts.map +1 -1
- package/dist/exam/exam.service.js +80 -0
- package/dist/exam/exam.service.js.map +1 -1
- package/dist/exam/question.controller.d.ts +27 -0
- package/dist/exam/question.controller.d.ts.map +1 -0
- package/dist/exam/question.controller.js +53 -0
- package/dist/exam/question.controller.js.map +1 -0
- package/dist/lesson-xp-map/dto/create-lesson-xp-map.dto.d.ts +6 -0
- package/dist/lesson-xp-map/dto/create-lesson-xp-map.dto.d.ts.map +1 -0
- package/dist/lesson-xp-map/dto/create-lesson-xp-map.dto.js +34 -0
- package/dist/lesson-xp-map/dto/create-lesson-xp-map.dto.js.map +1 -0
- package/dist/lesson-xp-map/dto/create-lesson-xp-segment.dto.d.ts +28 -0
- package/dist/lesson-xp-map/dto/create-lesson-xp-segment.dto.d.ts.map +1 -0
- package/dist/lesson-xp-map/dto/create-lesson-xp-segment.dto.js +123 -0
- package/dist/lesson-xp-map/dto/create-lesson-xp-segment.dto.js.map +1 -0
- package/dist/lesson-xp-map/dto/review-lesson-xp-map.dto.d.ts +4 -0
- package/dist/lesson-xp-map/dto/review-lesson-xp-map.dto.d.ts.map +1 -0
- package/dist/lesson-xp-map/dto/review-lesson-xp-map.dto.js +22 -0
- package/dist/lesson-xp-map/dto/review-lesson-xp-map.dto.js.map +1 -0
- package/dist/lesson-xp-map/dto/update-lesson-xp-map.dto.d.ts +10 -0
- package/dist/lesson-xp-map/dto/update-lesson-xp-map.dto.d.ts.map +1 -0
- package/dist/lesson-xp-map/dto/update-lesson-xp-map.dto.js +52 -0
- package/dist/lesson-xp-map/dto/update-lesson-xp-map.dto.js.map +1 -0
- package/dist/lesson-xp-map/dto/update-lesson-xp-segment.dto.d.ts +15 -0
- package/dist/lesson-xp-map/dto/update-lesson-xp-segment.dto.d.ts.map +1 -0
- package/dist/lesson-xp-map/dto/update-lesson-xp-segment.dto.js +86 -0
- package/dist/lesson-xp-map/dto/update-lesson-xp-segment.dto.js.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +30 -0
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +440 -0
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-map.controller.d.ts +87 -0
- package/dist/lesson-xp-map/lesson-xp-map.controller.d.ts.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-map.controller.js +185 -0
- package/dist/lesson-xp-map/lesson-xp-map.controller.js.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-map.module.d.ts +3 -0
- package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-map.module.js +34 -0
- package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-map.service.d.ts +84 -0
- package/dist/lesson-xp-map/lesson-xp-map.service.d.ts.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-map.service.js +353 -0
- package/dist/lesson-xp-map/lesson-xp-map.service.js.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-segment.controller.d.ts +10 -0
- package/dist/lesson-xp-map/lesson-xp-segment.controller.d.ts.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-segment.controller.js +63 -0
- package/dist/lesson-xp-map/lesson-xp-segment.controller.js.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-segment.service.d.ts +27 -0
- package/dist/lesson-xp-map/lesson-xp-segment.service.d.ts.map +1 -0
- package/dist/lesson-xp-map/lesson-xp-segment.service.js +194 -0
- package/dist/lesson-xp-map/lesson-xp-segment.service.js.map +1 -0
- package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -0
- package/dist/lms-commerce-access.subscriber.d.ts +11 -0
- package/dist/lms-commerce-access.subscriber.d.ts.map +1 -0
- package/dist/lms-commerce-access.subscriber.js +74 -0
- package/dist/lms-commerce-access.subscriber.js.map +1 -0
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +21 -5
- package/dist/lms.module.js.map +1 -1
- package/dist/platforma/dto/update-profile.dto.d.ts +17 -0
- package/dist/platforma/dto/update-profile.dto.d.ts.map +1 -0
- package/dist/platforma/dto/update-profile.dto.js +87 -0
- package/dist/platforma/dto/update-profile.dto.js.map +1 -0
- package/dist/platforma/platforma-video.service.d.ts +39 -0
- package/dist/platforma/platforma-video.service.d.ts.map +1 -0
- package/dist/platforma/platforma-video.service.js +301 -0
- package/dist/platforma/platforma-video.service.js.map +1 -0
- package/dist/platforma/platforma.controller.d.ts +182 -1
- package/dist/platforma/platforma.controller.d.ts.map +1 -1
- package/dist/platforma/platforma.controller.js +243 -2
- package/dist/platforma/platforma.controller.js.map +1 -1
- package/dist/platforma/platforma.service.d.ts +27 -0
- package/dist/platforma/platforma.service.d.ts.map +1 -0
- package/dist/platforma/platforma.service.js +274 -0
- package/dist/platforma/platforma.service.js.map +1 -0
- package/dist/student-xp/dto/grant-skill-card-xp.dto.d.ts +5 -0
- package/dist/student-xp/dto/grant-skill-card-xp.dto.d.ts.map +1 -0
- package/dist/student-xp/dto/grant-skill-card-xp.dto.js +26 -0
- package/dist/student-xp/dto/grant-skill-card-xp.dto.js.map +1 -0
- package/dist/student-xp/student-xp.controller.d.ts +56 -0
- package/dist/student-xp/student-xp.controller.d.ts.map +1 -0
- package/dist/student-xp/student-xp.controller.js +138 -0
- package/dist/student-xp/student-xp.controller.js.map +1 -0
- package/dist/student-xp/student-xp.module.d.ts +3 -0
- package/dist/student-xp/student-xp.module.d.ts.map +1 -0
- package/dist/student-xp/student-xp.module.js +25 -0
- package/dist/student-xp/student-xp.module.js.map +1 -0
- package/dist/student-xp/student-xp.service.d.ts +81 -0
- package/dist/student-xp/student-xp.service.d.ts.map +1 -0
- package/dist/student-xp/student-xp.service.js +247 -0
- package/dist/student-xp/student-xp.service.js.map +1 -0
- package/dist/xp-catalog/dto/create-xp-area.dto.d.ts +12 -0
- package/dist/xp-catalog/dto/create-xp-area.dto.d.ts.map +1 -0
- package/dist/xp-catalog/dto/create-xp-area.dto.js +63 -0
- package/dist/xp-catalog/dto/create-xp-area.dto.js.map +1 -0
- package/dist/xp-catalog/dto/create-xp-learning-type.dto.d.ts +11 -0
- package/dist/xp-catalog/dto/create-xp-learning-type.dto.d.ts.map +1 -0
- package/dist/xp-catalog/dto/create-xp-learning-type.dto.js +57 -0
- package/dist/xp-catalog/dto/create-xp-learning-type.dto.js.map +1 -0
- package/dist/xp-catalog/dto/create-xp-skill.dto.d.ts +11 -0
- package/dist/xp-catalog/dto/create-xp-skill.dto.d.ts.map +1 -0
- package/dist/xp-catalog/dto/create-xp-skill.dto.js +57 -0
- package/dist/xp-catalog/dto/create-xp-skill.dto.js.map +1 -0
- package/dist/xp-catalog/dto/update-xp-area.dto.d.ts +12 -0
- package/dist/xp-catalog/dto/update-xp-area.dto.d.ts.map +1 -0
- package/dist/xp-catalog/dto/update-xp-area.dto.js +66 -0
- package/dist/xp-catalog/dto/update-xp-area.dto.js.map +1 -0
- package/dist/xp-catalog/dto/update-xp-learning-type.dto.d.ts +11 -0
- package/dist/xp-catalog/dto/update-xp-learning-type.dto.d.ts.map +1 -0
- package/dist/xp-catalog/dto/update-xp-learning-type.dto.js +60 -0
- package/dist/xp-catalog/dto/update-xp-learning-type.dto.js.map +1 -0
- package/dist/xp-catalog/dto/update-xp-skill.dto.d.ts +11 -0
- package/dist/xp-catalog/dto/update-xp-skill.dto.d.ts.map +1 -0
- package/dist/xp-catalog/dto/update-xp-skill.dto.js +60 -0
- package/dist/xp-catalog/dto/update-xp-skill.dto.js.map +1 -0
- package/dist/xp-catalog/xp-area.controller.d.ts +25 -0
- package/dist/xp-catalog/xp-area.controller.d.ts.map +1 -0
- package/dist/xp-catalog/xp-area.controller.js +105 -0
- package/dist/xp-catalog/xp-area.controller.js.map +1 -0
- package/dist/xp-catalog/xp-area.service.d.ts +35 -0
- package/dist/xp-catalog/xp-area.service.d.ts.map +1 -0
- package/dist/xp-catalog/xp-area.service.js +168 -0
- package/dist/xp-catalog/xp-area.service.js.map +1 -0
- package/dist/xp-catalog/xp-catalog.module.d.ts +3 -0
- package/dist/xp-catalog/xp-catalog.module.d.ts.map +1 -0
- package/dist/xp-catalog/xp-catalog.module.js +29 -0
- package/dist/xp-catalog/xp-catalog.module.js.map +1 -0
- package/dist/xp-catalog/xp-learning-type.controller.d.ts +20 -0
- package/dist/xp-catalog/xp-learning-type.controller.d.ts.map +1 -0
- package/dist/xp-catalog/xp-learning-type.controller.js +96 -0
- package/dist/xp-catalog/xp-learning-type.controller.js.map +1 -0
- package/dist/xp-catalog/xp-learning-type.service.d.ts +30 -0
- package/dist/xp-catalog/xp-learning-type.service.d.ts.map +1 -0
- package/dist/xp-catalog/xp-learning-type.service.js +146 -0
- package/dist/xp-catalog/xp-learning-type.service.js.map +1 -0
- package/dist/xp-catalog/xp-skill.controller.d.ts +26 -0
- package/dist/xp-catalog/xp-skill.controller.d.ts.map +1 -0
- package/dist/xp-catalog/xp-skill.controller.js +113 -0
- package/dist/xp-catalog/xp-skill.controller.js.map +1 -0
- package/dist/xp-catalog/xp-skill.service.d.ts +37 -0
- package/dist/xp-catalog/xp-skill.service.d.ts.map +1 -0
- package/dist/xp-catalog/xp-skill.service.js +174 -0
- package/dist/xp-catalog/xp-skill.service.js.map +1 -0
- package/hedhog/data/evaluation_topic.yaml +17 -0
- package/hedhog/data/menu.yaml +91 -7
- package/hedhog/data/queue_definition.yaml +48 -0
- package/hedhog/data/route.yaml +511 -29
- package/hedhog/data/setting_group.yaml +20 -20
- package/hedhog/data/xp_area.yaml +164 -0
- package/hedhog/data/xp_learning_type.yaml +131 -0
- package/hedhog/data/xp_skill.yaml +1834 -0
- package/hedhog/frontend/app/achievements/page.tsx.ejs +108 -118
- package/hedhog/frontend/app/bitcodes/page.tsx.ejs +22 -34
- package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +1749 -0
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +21 -45
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +40 -74
- package/hedhog/frontend/app/classes/page.tsx.ejs +56 -85
- package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +3 -2
- package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +48 -5
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +73 -8
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +420 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +308 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-operations-tab.tsx.ejs +19 -2
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +1172 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +16 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +2 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +623 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +1458 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +87 -46
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +8 -3
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +31 -8
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +16 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +618 -480
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +672 -737
- package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +1 -2
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +101 -85
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +24 -10
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +7 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +44 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +6 -10
- package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +49 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -3
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +53 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +106 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +80 -1
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-xp-overview.ts.ejs +76 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-xp-map.ts.ejs +128 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +0 -2
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +30 -0
- package/hedhog/frontend/app/courses/[id]/structure/_utils/xp-color-config.ts.ejs +115 -0
- package/hedhog/frontend/app/courses/_components/CourseDeleteDialog.tsx.ejs +223 -0
- package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +89 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +445 -230
- package/hedhog/frontend/app/enterprise/page.tsx.ejs +39 -63
- package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +53 -77
- package/hedhog/frontend/app/exams/page.tsx.ejs +54 -90
- package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +23 -36
- package/hedhog/frontend/app/instructors/page.tsx.ejs +72 -81
- package/hedhog/frontend/app/paths/page.tsx.ejs +40 -68
- package/hedhog/frontend/app/training/page.tsx.ejs +40 -68
- package/hedhog/frontend/app/xp/areas/page.tsx.ejs +782 -0
- package/hedhog/frontend/app/xp/learning-types/page.tsx.ejs +690 -0
- package/hedhog/frontend/app/xp/skills/page.tsx.ejs +811 -0
- package/hedhog/frontend/messages/en.json +412 -31
- package/hedhog/frontend/messages/pt.json +412 -31
- package/hedhog/table/course_export.yaml +62 -0
- package/hedhog/table/lesson_xp_map.yaml +50 -0
- package/hedhog/table/lesson_xp_segment.yaml +40 -0
- package/hedhog/table/lesson_xp_segment_area.yaml +24 -0
- package/hedhog/table/lesson_xp_segment_learning_type.yaml +24 -0
- package/hedhog/table/lesson_xp_segment_skill.yaml +24 -0
- package/hedhog/table/lms_bulk_upload_item.yaml +44 -0
- package/hedhog/table/lms_bulk_upload_session.yaml +42 -0
- package/hedhog/table/student_area_xp.yaml +30 -0
- package/hedhog/table/student_learning_type_xp.yaml +30 -0
- package/hedhog/table/student_skill_xp.yaml +30 -0
- package/hedhog/table/student_xp_event.yaml +34 -0
- package/hedhog/table/xp_area.yaml +39 -0
- package/hedhog/table/xp_learning_type.yaml +34 -0
- package/hedhog/table/xp_skill.yaml +39 -0
- package/package.json +13 -8
- package/src/bitcode-wallet/bitcode-wallet.service.ts +152 -0
- package/src/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.ts +32 -0
- package/src/course/course-audio-transcription.service.ts +58 -21
- package/src/course/course-export-scorm12-worker.service.ts +124 -0
- package/src/course/course-export-scorm12.service.ts +668 -0
- package/src/course/course-export.service.ts +280 -0
- package/src/course/course-lesson.controller.ts +6 -1
- package/src/course/course-structure.controller.ts +23 -1
- package/src/course/course-structure.service.ts +273 -7
- package/src/course/course-video-conversion.service.ts +113 -75
- package/src/course/course-video-hls.service.ts +946 -0
- package/src/course/course.controller.ts +54 -21
- package/src/course/course.mcp-tools.ts +1 -1
- package/src/course/course.module.ts +13 -0
- package/src/course/course.service.ts +906 -76
- package/src/course/dto/cleanup-course-storage.dto.ts +23 -0
- package/src/course/dto/cleanup-upload-history.dto.ts +26 -0
- package/src/course/dto/create-course-bulk-job.dto.ts +10 -0
- package/src/course/dto/create-course-export.dto.ts +56 -0
- package/src/course/dto/create-course-structure-lesson.dto.ts +4 -3
- package/src/course/lms-bulk-upload-automation.service.ts +707 -0
- package/src/course/lms-bulk-upload-infra.service.ts +360 -0
- package/src/course/lms-bulk-upload.constants.ts +5 -0
- package/src/course/lms-bulk-upload.controller.ts +110 -4
- package/src/course/lms-bulk-upload.service.ts +1092 -204
- package/src/course/lms-setting.controller.ts +26 -8
- package/src/course/scorm12-schemas.ts +9 -0
- package/src/enterprise/training/training-student.service.ts +221 -2
- package/src/evaluation/evaluation.service.ts +123 -0
- package/src/exam/dto/create-standalone-question.dto.ts +66 -0
- package/src/exam/exam.module.ts +2 -1
- package/src/exam/exam.service.ts +86 -0
- package/src/exam/question.controller.ts +28 -0
- package/src/lesson-xp-map/dto/create-lesson-xp-map.dto.ts +17 -0
- package/src/lesson-xp-map/dto/create-lesson-xp-segment.dto.ts +102 -0
- package/src/lesson-xp-map/dto/review-lesson-xp-map.dto.ts +7 -0
- package/src/lesson-xp-map/dto/update-lesson-xp-map.dto.ts +36 -0
- package/src/lesson-xp-map/dto/update-lesson-xp-segment.dto.ts +78 -0
- package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +570 -0
- package/src/lesson-xp-map/lesson-xp-map.controller.ts +116 -0
- package/src/lesson-xp-map/lesson-xp-map.module.ts +21 -0
- package/src/lesson-xp-map/lesson-xp-map.service.ts +442 -0
- package/src/lesson-xp-map/lesson-xp-segment.controller.ts +36 -0
- package/src/lesson-xp-map/lesson-xp-segment.service.ts +229 -0
- package/src/lms-commerce-access.subscriber.ts +88 -0
- package/src/lms.module.ts +21 -5
- package/src/platforma/dto/update-profile.dto.ts +59 -0
- package/src/platforma/platforma-video.service.ts +346 -0
- package/src/platforma/platforma.controller.ts +152 -3
- package/src/platforma/platforma.service.ts +268 -0
- package/src/student-xp/dto/grant-skill-card-xp.dto.ts +10 -0
- package/src/student-xp/student-xp.controller.ts +92 -0
- package/src/student-xp/student-xp.module.ts +12 -0
- package/src/student-xp/student-xp.service.ts +318 -0
- package/src/xp-catalog/dto/create-xp-area.dto.ts +40 -0
- package/src/xp-catalog/dto/create-xp-learning-type.dto.ts +35 -0
- package/src/xp-catalog/dto/create-xp-skill.dto.ts +35 -0
- package/src/xp-catalog/dto/update-xp-area.dto.ts +43 -0
- package/src/xp-catalog/dto/update-xp-learning-type.dto.ts +38 -0
- package/src/xp-catalog/dto/update-xp-skill.dto.ts +38 -0
- package/src/xp-catalog/xp-area.controller.ts +64 -0
- package/src/xp-catalog/xp-area.service.ts +196 -0
- package/src/xp-catalog/xp-catalog.module.ts +16 -0
- package/src/xp-catalog/xp-learning-type.controller.ts +59 -0
- package/src/xp-catalog/xp-learning-type.service.ts +170 -0
- package/src/xp-catalog/xp-skill.controller.ts +71 -0
- package/src/xp-catalog/xp-skill.service.ts +205 -0
- package/hedhog/data/video_resolution_profile.yaml +0 -7
- package/hedhog/frontend/app/video-resolution-profiles/page.tsx.ejs +0 -607
- package/hedhog/table/course_video_resolution_profile.yaml +0 -22
- package/hedhog/table/video_resolution_profile.yaml +0 -18
- package/src/video-resolution-profile/dto/create-video-resolution-profile.dto.ts +0 -16
- package/src/video-resolution-profile/dto/update-video-resolution-profile.dto.ts +0 -16
- package/src/video-resolution-profile/video-resolution-profile.controller.ts +0 -62
- package/src/video-resolution-profile/video-resolution-profile.mcp-tools.ts +0 -128
- package/src/video-resolution-profile/video-resolution-profile.module.ts +0 -13
- package/src/video-resolution-profile/video-resolution-profile.service.ts +0 -117
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
2
|
+
import { FileService, NotificationService, SettingService } from '@hed-hog/core';
|
|
3
|
+
import { IJobHandler, QueueHandlerRegistry, QueueJobService } from '@hed-hog/queue';
|
|
4
|
+
import {
|
|
5
|
+
BadRequestException,
|
|
6
|
+
Inject,
|
|
7
|
+
Injectable,
|
|
8
|
+
Logger,
|
|
9
|
+
NotFoundException,
|
|
10
|
+
OnModuleInit,
|
|
11
|
+
forwardRef,
|
|
12
|
+
} from '@nestjs/common';
|
|
13
|
+
import { execFile } from 'child_process';
|
|
14
|
+
import { existsSync, promises as fs, readdirSync } from 'fs';
|
|
15
|
+
import { tmpdir } from 'os';
|
|
16
|
+
import { basename, join } from 'path';
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
|
|
19
|
+
export const LMS_VIDEO_HLS_JOB = 'lms.video.hls';
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
type HlsVariantConfig = {
|
|
24
|
+
variantIndex: number;
|
|
25
|
+
height: number;
|
|
26
|
+
width: number;
|
|
27
|
+
videoBitrate: string;
|
|
28
|
+
audioBitrate: string;
|
|
29
|
+
bandwidth: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type LessonFrameResult = {
|
|
33
|
+
fileId: number;
|
|
34
|
+
timeSeconds: number;
|
|
35
|
+
outputBytes: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type NotificationContext = {
|
|
39
|
+
userId: number;
|
|
40
|
+
notificationId: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const HLS_LADDER_PRESETS: Record<number, { width: number; height: number; videoBitrate: string; audioBitrate: string; bandwidth: number }> = {
|
|
44
|
+
480: { width: 854, height: 480, videoBitrate: '1500k', audioBitrate: '96k', bandwidth: 1500000 },
|
|
45
|
+
720: { width: 1280, height: 720, videoBitrate: '2800k', audioBitrate: '128k', bandwidth: 2800000 },
|
|
46
|
+
1080: { width: 1920, height: 1080, videoBitrate: '5000k', audioBitrate: '192k', bandwidth: 5000000 },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
@Injectable()
|
|
50
|
+
export class CourseVideoHlsService implements OnModuleInit, IJobHandler {
|
|
51
|
+
private readonly logger = new Logger(CourseVideoHlsService.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(() => NotificationService))
|
|
61
|
+
private readonly notificationService: NotificationService,
|
|
62
|
+
@Inject(forwardRef(() => QueueHandlerRegistry))
|
|
63
|
+
private readonly registry: QueueHandlerRegistry,
|
|
64
|
+
@Inject(forwardRef(() => QueueJobService))
|
|
65
|
+
private readonly queueJob: QueueJobService,
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
onModuleInit() {
|
|
69
|
+
this.registry.register(LMS_VIDEO_HLS_JOB, this);
|
|
70
|
+
this.logger.log(`Registered handler for "${LMS_VIDEO_HLS_JOB}"`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async enqueueHls(params: {
|
|
74
|
+
userId: number;
|
|
75
|
+
courseId: number;
|
|
76
|
+
sessionId: number;
|
|
77
|
+
lessonId: number;
|
|
78
|
+
originalFileId: number;
|
|
79
|
+
}) {
|
|
80
|
+
const lesson = await (this.prisma as any).course_lesson.findFirst({
|
|
81
|
+
where: {
|
|
82
|
+
id: params.lessonId,
|
|
83
|
+
course_module_id: params.sessionId,
|
|
84
|
+
course_module: { course_id: params.courseId },
|
|
85
|
+
},
|
|
86
|
+
include: { course_module: true },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!lesson) throw new NotFoundException('Lesson not found for this course session');
|
|
90
|
+
|
|
91
|
+
const content = this.parseContent(lesson.content);
|
|
92
|
+
if (lesson.type !== 'video' || content?.sourceType !== 'video') {
|
|
93
|
+
throw new BadRequestException('HLS processing is only available for video lessons');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (content?.videoProvedor !== 'file_storage') {
|
|
97
|
+
throw new BadRequestException('HLS processing requires File Storage provider');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const original = await (this.prisma as any).file.findUnique({
|
|
101
|
+
where: { id: params.originalFileId },
|
|
102
|
+
include: { file_mimetype: true },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!original) throw new NotFoundException('Original video file not found');
|
|
106
|
+
|
|
107
|
+
const mimetype = original.file_mimetype?.name ?? '';
|
|
108
|
+
if (!this.isVideoFile(mimetype, original.filename)) {
|
|
109
|
+
throw new BadRequestException('Original file must be a video');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await this.upsertLessonFile({
|
|
113
|
+
lessonId: params.lessonId,
|
|
114
|
+
type: 'video_original',
|
|
115
|
+
fileId: params.originalFileId,
|
|
116
|
+
title: original.filename ?? `Original video ${params.originalFileId}`,
|
|
117
|
+
public: false,
|
|
118
|
+
overwrite: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const asyncNotification = await this.notificationService.create({
|
|
122
|
+
user_id: params.userId,
|
|
123
|
+
title: 'Geração de HLS em andamento',
|
|
124
|
+
body: 'Preparando pipeline de processamento HLS do vídeo...',
|
|
125
|
+
type: 'progress' as any,
|
|
126
|
+
progress: 1,
|
|
127
|
+
started_at: new Date().toISOString(),
|
|
128
|
+
auto_remove: false,
|
|
129
|
+
action_type: 'url' as any,
|
|
130
|
+
action_url: '/queue/jobs',
|
|
131
|
+
action_data: {
|
|
132
|
+
source: 'lms-video-hls',
|
|
133
|
+
courseId: params.courseId,
|
|
134
|
+
lessonId: params.lessonId,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const job = await this.queueJob.enqueue({
|
|
139
|
+
type: LMS_VIDEO_HLS_JOB,
|
|
140
|
+
queueName: LMS_VIDEO_HLS_JOB,
|
|
141
|
+
payload: {
|
|
142
|
+
courseId: params.courseId,
|
|
143
|
+
sessionId: params.sessionId,
|
|
144
|
+
lessonId: params.lessonId,
|
|
145
|
+
originalFileId: params.originalFileId,
|
|
146
|
+
notificationId: asyncNotification.id,
|
|
147
|
+
notificationUserId: params.userId,
|
|
148
|
+
},
|
|
149
|
+
sourceModule: 'lms',
|
|
150
|
+
sourceEntity: 'course_lesson',
|
|
151
|
+
sourceEntityId: String(params.lessonId),
|
|
152
|
+
maxAttempts: 3,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await (this.prisma as any).notification.update({
|
|
156
|
+
where: { id: asyncNotification.id },
|
|
157
|
+
data: {
|
|
158
|
+
action_data: {
|
|
159
|
+
source: 'lms-video-hls',
|
|
160
|
+
courseId: params.courseId,
|
|
161
|
+
lessonId: params.lessonId,
|
|
162
|
+
queueJobId: job.id,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await (this.prisma as any).course_lesson.update({
|
|
168
|
+
where: { id: params.lessonId },
|
|
169
|
+
data: {
|
|
170
|
+
content: JSON.stringify({
|
|
171
|
+
...(content ?? {}),
|
|
172
|
+
sourceType: 'video',
|
|
173
|
+
videoConversionJobId: job.id,
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return { queueJobId: job.id, status: 'queued' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async handle(job: {
|
|
182
|
+
id: number;
|
|
183
|
+
type: string;
|
|
184
|
+
queue_name: string;
|
|
185
|
+
payload: Record<string, any>;
|
|
186
|
+
attempts: number;
|
|
187
|
+
max_attempts: number;
|
|
188
|
+
source_module?: string | null;
|
|
189
|
+
source_entity?: string | null;
|
|
190
|
+
source_entity_id?: string | null;
|
|
191
|
+
}): Promise<any> {
|
|
192
|
+
const { courseId, sessionId, lessonId, originalFileId } = job.payload as {
|
|
193
|
+
courseId?: number;
|
|
194
|
+
sessionId?: number;
|
|
195
|
+
lessonId?: number;
|
|
196
|
+
originalFileId?: number;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const notificationContext: NotificationContext | undefined =
|
|
200
|
+
Number.isInteger(Number(job.payload?.notificationId)) &&
|
|
201
|
+
Number.isInteger(Number(job.payload?.notificationUserId))
|
|
202
|
+
? { notificationId: Number(job.payload.notificationId), userId: Number(job.payload.notificationUserId) }
|
|
203
|
+
: undefined;
|
|
204
|
+
|
|
205
|
+
const emitProgress = async (message: string, metadata?: Record<string, unknown>) => {
|
|
206
|
+
await this.createProgressEvent(job.id, message, metadata, notificationContext);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (!courseId || !sessionId || !lessonId || !originalFileId) {
|
|
210
|
+
throw new Error('Invalid LMS HLS job payload');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.logger.debug(`[HLS job=${job.id}] payload OK — courseId=${courseId} lessonId=${lessonId} originalFileId=${originalFileId} attempt=${job.attempts}/${job.max_attempts}`);
|
|
214
|
+
|
|
215
|
+
const maxInputBytes = this.getPositiveIntegerEnv('LMS_VIDEO_MAX_INPUT_BYTES');
|
|
216
|
+
const ffmpegTimeoutMs = this.getPositiveIntegerEnv('LMS_VIDEO_FFMPEG_TIMEOUT_MS') ?? 1000 * 60 * 60 * 2;
|
|
217
|
+
|
|
218
|
+
this.logger.debug(`[HLS job=${job.id}] loading settings...`);
|
|
219
|
+
const settings = await this.settingService.getSettingValues([
|
|
220
|
+
'lms-hls-resolutions',
|
|
221
|
+
'lms-hls-segment-duration',
|
|
222
|
+
'lms-video-frame-capture-interval-seconds',
|
|
223
|
+
'lms-image-extraction-enabled',
|
|
224
|
+
'lms-audio-transcription-enabled',
|
|
225
|
+
]);
|
|
226
|
+
this.logger.debug(`[HLS job=${job.id}] settings loaded — resolutions=${settings['lms-hls-resolutions']} segmentDuration=${settings['lms-hls-segment-duration']}`);
|
|
227
|
+
|
|
228
|
+
const hlsResolutions = this.parseResolutions(settings['lms-hls-resolutions']);
|
|
229
|
+
const segmentDuration = Math.max(2, Number(settings['lms-hls-segment-duration'] || 6));
|
|
230
|
+
const imageExtractionEnabled = settings['lms-image-extraction-enabled'] !== false;
|
|
231
|
+
const transcriptionEnabled = settings['lms-audio-transcription-enabled'] !== false;
|
|
232
|
+
const frameIntervalSeconds = this.resolveFrameIntervalSeconds(settings['lms-video-frame-capture-interval-seconds']);
|
|
233
|
+
|
|
234
|
+
this.logger.debug(`[HLS job=${job.id}] resolutions=${hlsResolutions.join(',')} segmentDuration=${segmentDuration}s imageExtraction=${imageExtractionEnabled} transcription=${transcriptionEnabled}`);
|
|
235
|
+
|
|
236
|
+
const workDir = await fs.mkdtemp(join(tmpdir(), `lms-hls-${job.id}-`));
|
|
237
|
+
const inputPath = join(workDir, `original-${originalFileId}.mp4`);
|
|
238
|
+
const hlsOutDir = join(workDir, 'hls');
|
|
239
|
+
await fs.mkdir(hlsOutDir, { recursive: true });
|
|
240
|
+
|
|
241
|
+
this.logger.debug(`[HLS job=${job.id}] workDir=${workDir}`);
|
|
242
|
+
|
|
243
|
+
const jobStartedAt = Date.now();
|
|
244
|
+
|
|
245
|
+
this.logger.log(`Queue job ${job.id}: starting HLS generation (lesson=${lessonId})`);
|
|
246
|
+
await emitProgress('Baixando vídeo original...', { phase: 'download_original', lessonId });
|
|
247
|
+
|
|
248
|
+
this.logger.debug(`[HLS job=${job.id}] downloading original file ${originalFileId} to ${inputPath}...`);
|
|
249
|
+
const downloaded = await this.fileService.downloadToPath(
|
|
250
|
+
originalFileId,
|
|
251
|
+
inputPath,
|
|
252
|
+
maxInputBytes ? { maxBytes: maxInputBytes } : undefined,
|
|
253
|
+
);
|
|
254
|
+
const inputSizeMb = parseFloat((downloaded.size / (1024 * 1024)).toFixed(2));
|
|
255
|
+
this.logger.debug(`[HLS job=${job.id}] download complete — ${inputSizeMb} MB in ${Date.now() - jobStartedAt}ms`);
|
|
256
|
+
|
|
257
|
+
await emitProgress(`Vídeo baixado (${inputSizeMb} MB). Lendo dimensões e duração...`, {
|
|
258
|
+
phase: 'probe_duration',
|
|
259
|
+
lessonId,
|
|
260
|
+
inputSizeMb,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
let probedDuration: number | null = null;
|
|
264
|
+
try {
|
|
265
|
+
this.logger.debug(`[HLS job=${job.id}] probing duration...`);
|
|
266
|
+
probedDuration = await this.probeDurationSeconds(inputPath, ffmpegTimeoutMs);
|
|
267
|
+
this.logger.debug(`[HLS job=${job.id}] duration=${probedDuration}s`);
|
|
268
|
+
if (probedDuration !== null && probedDuration > 0) {
|
|
269
|
+
await (this.prisma as any).course_lesson.update({
|
|
270
|
+
where: { id: lessonId },
|
|
271
|
+
data: { duration_seconds: probedDuration },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
} catch (err: any) {
|
|
275
|
+
this.logger.warn(`Queue job ${job.id}: failed to probe duration: ${err?.message ?? err}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.logger.debug(`[HLS job=${job.id}] probing source height...`);
|
|
279
|
+
const sourceHeight = await this.probeVideoHeight(inputPath);
|
|
280
|
+
this.logger.debug(`[HLS job=${job.id}] sourceHeight=${sourceHeight ?? 'unknown'}`);
|
|
281
|
+
const variants = this.buildHlsVariants(hlsResolutions, sourceHeight);
|
|
282
|
+
|
|
283
|
+
if (variants.length === 0) {
|
|
284
|
+
throw new Error(`No suitable HLS variants for source height ${sourceHeight ?? 'unknown'}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.logger.log(`Queue job ${job.id}: building HLS with variants: ${variants.map((v) => v.height + 'p').join(', ')}`);
|
|
288
|
+
this.logger.debug(`[HLS job=${job.id}] ffmpeg encode starting — variants=${variants.map((v) => `${v.height}p@${v.videoBitrate}`).join(', ')} segmentDuration=${segmentDuration}s outDir=${hlsOutDir}`);
|
|
289
|
+
await emitProgress(`Gerando HLS (${variants.map((v) => v.height + 'p').join(', ')})...`, {
|
|
290
|
+
phase: 'hls_encode',
|
|
291
|
+
lessonId,
|
|
292
|
+
variants: variants.map((v) => v.height),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const encodeStart = Date.now();
|
|
296
|
+
await this.generateHls({ inputPath, outDir: hlsOutDir, variants, segmentDuration, timeoutMs: ffmpegTimeoutMs });
|
|
297
|
+
this.logger.debug(`[HLS job=${job.id}] ffmpeg encode complete in ${Date.now() - encodeStart}ms`);
|
|
298
|
+
|
|
299
|
+
// Count generated segments to log
|
|
300
|
+
let totalSegmentCount = 0;
|
|
301
|
+
for (const v of variants) {
|
|
302
|
+
const variantDir = join(hlsOutDir, `stream_${v.variantIndex}`);
|
|
303
|
+
const files = (await fs.readdir(variantDir)).filter((f) => f.endsWith('.ts'));
|
|
304
|
+
this.logger.debug(`[HLS job=${job.id}] variant stream_${v.variantIndex} (${v.height}p): ${files.length} segments`);
|
|
305
|
+
totalSegmentCount += files.length;
|
|
306
|
+
}
|
|
307
|
+
this.logger.debug(`[HLS job=${job.id}] total segments to upload: ${totalSegmentCount} across ${variants.length} variants`);
|
|
308
|
+
|
|
309
|
+
await emitProgress('HLS gerado. Enviando arquivos para storage...', {
|
|
310
|
+
phase: 'hls_upload',
|
|
311
|
+
lessonId,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
this.logger.debug(`[HLS job=${job.id}] starting upload of ${totalSegmentCount} segments + ${variants.length} playlists + 1 master`);
|
|
315
|
+
const uploadStart = Date.now();
|
|
316
|
+
const masterManifestFileId = await this.uploadHlsDirectory({
|
|
317
|
+
lessonId,
|
|
318
|
+
hlsOutDir,
|
|
319
|
+
variants,
|
|
320
|
+
emitProgress,
|
|
321
|
+
});
|
|
322
|
+
this.logger.debug(`[HLS job=${job.id}] upload complete in ${Date.now() - uploadStart}ms — masterFileId=${masterManifestFileId}`);
|
|
323
|
+
|
|
324
|
+
this.logger.debug(`[HLS job=${job.id}] upserting video_hls lesson file...`);
|
|
325
|
+
await this.upsertLessonFile({
|
|
326
|
+
lessonId,
|
|
327
|
+
type: 'video_hls',
|
|
328
|
+
fileId: masterManifestFileId,
|
|
329
|
+
title: 'HLS Stream',
|
|
330
|
+
public: false,
|
|
331
|
+
overwrite: true,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
this.logger.log(`Queue job ${job.id}: HLS uploaded for lesson ${lessonId} (masterFileId=${masterManifestFileId})`);
|
|
335
|
+
|
|
336
|
+
let frameResults: LessonFrameResult[] = [];
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
if (imageExtractionEnabled) {
|
|
340
|
+
this.logger.debug(`[HLS job=${job.id}] starting frame extraction — interval=${frameIntervalSeconds}s`);
|
|
341
|
+
await emitProgress('Extraindo imagens do vídeo...', { phase: 'extract_frames', lessonId });
|
|
342
|
+
const frameStart = Date.now();
|
|
343
|
+
frameResults = await this.extractAndUploadLessonFrames({
|
|
344
|
+
jobId: job.id,
|
|
345
|
+
lessonId,
|
|
346
|
+
inputPath,
|
|
347
|
+
workDir,
|
|
348
|
+
intervalSeconds: frameIntervalSeconds,
|
|
349
|
+
timeoutMs: ffmpegTimeoutMs,
|
|
350
|
+
});
|
|
351
|
+
this.logger.debug(`[HLS job=${job.id}] frame extraction done — ${frameResults.length} frames in ${Date.now() - frameStart}ms`);
|
|
352
|
+
await emitProgress(
|
|
353
|
+
frameResults.length > 0
|
|
354
|
+
? `Imagens extraídas com sucesso (${frameResults.length}).`
|
|
355
|
+
: 'Nenhuma imagem foi extraída do vídeo.',
|
|
356
|
+
{ phase: 'extract_frames_done', lessonId, frames: frameResults.length },
|
|
357
|
+
);
|
|
358
|
+
} else {
|
|
359
|
+
this.logger.debug(`[HLS job=${job.id}] frame extraction disabled — skipping`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (transcriptionEnabled) {
|
|
363
|
+
this.logger.debug(`[HLS job=${job.id}] starting audio extraction for transcription`);
|
|
364
|
+
await emitProgress('Extraindo áudio do vídeo...', { phase: 'extract_audio', lessonId });
|
|
365
|
+
const audioFileId = await this.extractAndUploadLessonAudio({
|
|
366
|
+
courseId,
|
|
367
|
+
lessonId,
|
|
368
|
+
localInputPath: inputPath,
|
|
369
|
+
tempDir: workDir,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (audioFileId) {
|
|
373
|
+
this.logger.debug(`[HLS job=${job.id}] audio extracted — fileId=${audioFileId}, enqueuing transcription`);
|
|
374
|
+
await emitProgress('Áudio extraído. Agendando transcrição...', {
|
|
375
|
+
phase: 'queue_transcription',
|
|
376
|
+
lessonId,
|
|
377
|
+
audioFileId,
|
|
378
|
+
});
|
|
379
|
+
const transcriptionJob = await this.queueJob.enqueue({
|
|
380
|
+
type: 'lms.audio.transcribe',
|
|
381
|
+
queueName: 'lms.audio.transcribe',
|
|
382
|
+
payload: {
|
|
383
|
+
courseId,
|
|
384
|
+
sessionId,
|
|
385
|
+
lessonId,
|
|
386
|
+
audioFileId,
|
|
387
|
+
parentJobId: job.id,
|
|
388
|
+
notificationId: notificationContext?.notificationId,
|
|
389
|
+
notificationUserId: notificationContext?.userId,
|
|
390
|
+
},
|
|
391
|
+
maxAttempts: 3,
|
|
392
|
+
});
|
|
393
|
+
await emitProgress(`Transcrição agendada no job #${transcriptionJob.id}.`, {
|
|
394
|
+
phase: 'queue_transcription_done',
|
|
395
|
+
lessonId,
|
|
396
|
+
transcriptionJobId: transcriptionJob.id,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
this.logger.debug(`[HLS job=${job.id}] transcription job enqueued — transcriptionJobId=${transcriptionJob.id}`);
|
|
400
|
+
const totalMs = Date.now() - jobStartedAt;
|
|
401
|
+
this.logger.log(`[HLS job=${job.id}] pipeline complete in ${totalMs}ms — lessonId=${lessonId} masterFileId=${masterManifestFileId} frames=${frameResults.length} transcriptionJob=${transcriptionJob.id}`);
|
|
402
|
+
return {
|
|
403
|
+
lessonId,
|
|
404
|
+
probedDurationSeconds: probedDuration,
|
|
405
|
+
masterManifestFileId,
|
|
406
|
+
variants: variants.length,
|
|
407
|
+
extractedFrames: frameResults.length,
|
|
408
|
+
transcriptionJobId: transcriptionJob.id,
|
|
409
|
+
totalMs,
|
|
410
|
+
};
|
|
411
|
+
} else {
|
|
412
|
+
this.logger.debug(`[HLS job=${job.id}] audio extraction returned no file — skipping transcription`);
|
|
413
|
+
await emitProgress('Áudio não disponível. Encerrando pipeline sem transcrição.', {
|
|
414
|
+
phase: 'queue_transcription_skipped',
|
|
415
|
+
lessonId,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (error) {
|
|
420
|
+
this.logger.error(`[HLS job=${job.id}] pipeline FAILED after ${Date.now() - jobStartedAt}ms — ${error instanceof Error ? error.stack : String(error)}`);
|
|
421
|
+
if (notificationContext) {
|
|
422
|
+
const message = error instanceof Error ? error.message : 'Falha no pipeline HLS.';
|
|
423
|
+
await this.updateAsyncNotification(notificationContext, 100, `Falha: ${message}`, false);
|
|
424
|
+
}
|
|
425
|
+
throw error;
|
|
426
|
+
} finally {
|
|
427
|
+
this.logger.debug(`[HLS job=${job.id}] cleaning workDir ${workDir}`);
|
|
428
|
+
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
429
|
+
this.logger.log(`Queue job ${job.id}: cleaned temporary folder ${workDir}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (notificationContext) {
|
|
433
|
+
await this.updateAsyncNotification(notificationContext, 100, 'HLS gerado com sucesso.', true);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const totalMs = Date.now() - jobStartedAt;
|
|
437
|
+
this.logger.log(`[HLS job=${job.id}] pipeline complete in ${totalMs}ms — lessonId=${lessonId} masterFileId=${masterManifestFileId} frames=${frameResults.length} transcription=skipped`);
|
|
438
|
+
return {
|
|
439
|
+
lessonId,
|
|
440
|
+
probedDurationSeconds: probedDuration,
|
|
441
|
+
masterManifestFileId,
|
|
442
|
+
variants: variants.length,
|
|
443
|
+
extractedFrames: frameResults.length,
|
|
444
|
+
transcriptionJobId: null,
|
|
445
|
+
totalMs,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// HLS Generation
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
private async generateHls(params: {
|
|
454
|
+
inputPath: string;
|
|
455
|
+
outDir: string;
|
|
456
|
+
variants: HlsVariantConfig[];
|
|
457
|
+
segmentDuration: number;
|
|
458
|
+
timeoutMs: number;
|
|
459
|
+
}) {
|
|
460
|
+
const { inputPath, outDir, variants, segmentDuration, timeoutMs } = params;
|
|
461
|
+
const ffmpegCmd = this.getFfmpegCommand();
|
|
462
|
+
|
|
463
|
+
// Create output directories for each variant
|
|
464
|
+
for (const v of variants) {
|
|
465
|
+
await fs.mkdir(join(outDir, `stream_${v.variantIndex}`), { recursive: true });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const args: string[] = ['-y', '-i', inputPath];
|
|
469
|
+
|
|
470
|
+
if (variants.length > 1) {
|
|
471
|
+
args.push('-filter_complex', `[0:v]split=${variants.length}${variants.map((v) => `[v${v.variantIndex}]`).join('')}`);
|
|
472
|
+
for (const v of variants) {
|
|
473
|
+
args.push(
|
|
474
|
+
`-map`, `[v${v.variantIndex}]`,
|
|
475
|
+
`-map`, `0:a:0`,
|
|
476
|
+
`-c:v:${v.variantIndex}`, `libx264`,
|
|
477
|
+
`-b:v:${v.variantIndex}`, v.videoBitrate,
|
|
478
|
+
`-s:v:${v.variantIndex}`, `${v.width}x${v.height}`,
|
|
479
|
+
`-c:a:${v.variantIndex}`, `aac`,
|
|
480
|
+
`-b:a:${v.variantIndex}`, v.audioBitrate,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
args.push('-var_stream_map', variants.map((v) => `v:${v.variantIndex},a:${v.variantIndex}`).join(' '));
|
|
484
|
+
} else {
|
|
485
|
+
const v = variants[0]!;
|
|
486
|
+
args.push(
|
|
487
|
+
`-c:v`, `libx264`,
|
|
488
|
+
`-b:v`, v.videoBitrate,
|
|
489
|
+
`-s`, `${v.width}x${v.height}`,
|
|
490
|
+
`-c:a`, `aac`,
|
|
491
|
+
`-b:a`, v.audioBitrate,
|
|
492
|
+
`-var_stream_map`, `v:0,a:0`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
args.push(
|
|
497
|
+
`-master_pl_name`, `master.m3u8`,
|
|
498
|
+
`-f`, `hls`,
|
|
499
|
+
`-hls_time`, String(segmentDuration),
|
|
500
|
+
`-hls_list_size`, `0`,
|
|
501
|
+
`-hls_segment_filename`, join(outDir, 'stream_%v', 'seg%03d.ts'),
|
|
502
|
+
join(outDir, 'stream_%v', 'playlist.m3u8'),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
await execFileAsync(ffmpegCmd, args, {
|
|
507
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
508
|
+
timeout: timeoutMs,
|
|
509
|
+
windowsHide: true,
|
|
510
|
+
});
|
|
511
|
+
} catch (error: any) {
|
|
512
|
+
if (error?.code === 'ENOENT') {
|
|
513
|
+
throw new Error(`FFmpeg binary not found at "${ffmpegCmd}"`);
|
|
514
|
+
}
|
|
515
|
+
if (error?.killed && error?.signal === 'SIGTERM') {
|
|
516
|
+
throw new Error(`FFmpeg HLS generation timed out after ${timeoutMs}ms`);
|
|
517
|
+
}
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async uploadHlsDirectory(params: {
|
|
523
|
+
lessonId: number;
|
|
524
|
+
hlsOutDir: string;
|
|
525
|
+
variants: HlsVariantConfig[];
|
|
526
|
+
emitProgress: (message: string, metadata?: Record<string, unknown>) => Promise<void>;
|
|
527
|
+
}): Promise<number> {
|
|
528
|
+
const { lessonId, hlsOutDir, variants, emitProgress } = params;
|
|
529
|
+
|
|
530
|
+
// Upload all segment files in batches
|
|
531
|
+
const BATCH_SIZE = 20;
|
|
532
|
+
const segmentUploads: Array<{ localPath: string; location: string; filename: string }> = [];
|
|
533
|
+
|
|
534
|
+
for (const v of variants) {
|
|
535
|
+
const variantDir = join(hlsOutDir, `stream_${v.variantIndex}`);
|
|
536
|
+
const files = await fs.readdir(variantDir);
|
|
537
|
+
|
|
538
|
+
for (const filename of files) {
|
|
539
|
+
if (filename === 'playlist.m3u8') continue; // upload manifests separately
|
|
540
|
+
segmentUploads.push({
|
|
541
|
+
localPath: join(variantDir, filename),
|
|
542
|
+
location: `lms/lessons/hls/${lessonId}/stream_${v.variantIndex}`,
|
|
543
|
+
filename,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const totalBatches = Math.ceil(segmentUploads.length / BATCH_SIZE);
|
|
549
|
+
this.logger.debug(`[HLS upload lessonId=${lessonId}] ${segmentUploads.length} segments → ${totalBatches} batches of ${BATCH_SIZE}`);
|
|
550
|
+
|
|
551
|
+
for (let i = 0; i < segmentUploads.length; i += BATCH_SIZE) {
|
|
552
|
+
const batch = segmentUploads.slice(i, i + BATCH_SIZE);
|
|
553
|
+
const batchIndex = Math.floor(i / BATCH_SIZE) + 1;
|
|
554
|
+
const batchStart = Date.now();
|
|
555
|
+
this.logger.debug(`[HLS upload lessonId=${lessonId}] batch ${batchIndex}/${totalBatches} — uploading ${batch.map((s) => s.filename).join(', ')}`);
|
|
556
|
+
await Promise.all(
|
|
557
|
+
batch.map((seg) =>
|
|
558
|
+
this.fileService.uploadFromPath(seg.location, seg.localPath, {
|
|
559
|
+
originalname: seg.filename,
|
|
560
|
+
mimetype: 'video/MP2T',
|
|
561
|
+
}),
|
|
562
|
+
),
|
|
563
|
+
);
|
|
564
|
+
this.logger.debug(`[HLS upload lessonId=${lessonId}] batch ${batchIndex}/${totalBatches} done in ${Date.now() - batchStart}ms`);
|
|
565
|
+
await emitProgress(
|
|
566
|
+
`Enviando segmentos HLS... (${batchIndex}/${totalBatches})`,
|
|
567
|
+
{ phase: 'hls_upload', lessonId, batchIndex, totalBatches },
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Upload variant playlists
|
|
572
|
+
for (const v of variants) {
|
|
573
|
+
const playlistPath = join(hlsOutDir, `stream_${v.variantIndex}`, 'playlist.m3u8');
|
|
574
|
+
this.logger.debug(`[HLS upload lessonId=${lessonId}] uploading playlist stream_${v.variantIndex}/playlist.m3u8`);
|
|
575
|
+
await this.fileService.uploadFromPath(
|
|
576
|
+
`lms/lessons/hls/${lessonId}/stream_${v.variantIndex}`,
|
|
577
|
+
playlistPath,
|
|
578
|
+
{ originalname: 'playlist.m3u8', mimetype: 'application/x-mpegURL' },
|
|
579
|
+
);
|
|
580
|
+
this.logger.debug(`[HLS upload lessonId=${lessonId}] playlist stream_${v.variantIndex} uploaded`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Upload master manifest last, keep its file ID
|
|
584
|
+
const masterPath = join(hlsOutDir, 'master.m3u8');
|
|
585
|
+
this.logger.debug(`[HLS upload lessonId=${lessonId}] uploading master.m3u8`);
|
|
586
|
+
const masterFile = await this.fileService.uploadFromPath(
|
|
587
|
+
`lms/lessons/hls/${lessonId}`,
|
|
588
|
+
masterPath,
|
|
589
|
+
{ originalname: 'master.m3u8', mimetype: 'application/x-mpegURL' },
|
|
590
|
+
);
|
|
591
|
+
this.logger.debug(`[HLS upload lessonId=${lessonId}] master.m3u8 uploaded — fileId=${masterFile.id}`);
|
|
592
|
+
|
|
593
|
+
return masterFile.id;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
// Ladder helpers
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
private parseResolutions(raw: unknown): number[] {
|
|
601
|
+
const str = typeof raw === 'string' ? raw : '480,720,1080';
|
|
602
|
+
const parsed = str
|
|
603
|
+
.split(',')
|
|
604
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
605
|
+
.filter((n) => !isNaN(n) && HLS_LADDER_PRESETS[n] !== undefined);
|
|
606
|
+
return parsed.length > 0 ? [...new Set(parsed)].sort((a, b) => a - b) : [480, 720, 1080];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private buildHlsVariants(resolutions: number[], sourceHeight: number | null): HlsVariantConfig[] {
|
|
610
|
+
const effective = resolutions.filter((h) => sourceHeight === null || h <= sourceHeight);
|
|
611
|
+
// Always include at least the lowest quality or the source if it's below all configured resolutions
|
|
612
|
+
if (effective.length === 0 && sourceHeight !== null) {
|
|
613
|
+
const closest = resolutions.reduce((prev, curr) =>
|
|
614
|
+
Math.abs(curr - sourceHeight) < Math.abs(prev - sourceHeight) ? curr : prev,
|
|
615
|
+
);
|
|
616
|
+
effective.push(closest);
|
|
617
|
+
}
|
|
618
|
+
return effective.map((height, idx) => {
|
|
619
|
+
const preset = HLS_LADDER_PRESETS[height]!;
|
|
620
|
+
return { variantIndex: idx, height, ...preset };
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// FFprobe helpers
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
private async probeVideoHeight(inputPath: string): Promise<number | null> {
|
|
629
|
+
const ffprobeCmd = this.getFfprobeCommand();
|
|
630
|
+
try {
|
|
631
|
+
const { stdout } = await execFileAsync(
|
|
632
|
+
ffprobeCmd,
|
|
633
|
+
['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=height', '-of', 'default=noprint_wrappers=1:nokey=1', inputPath],
|
|
634
|
+
{ maxBuffer: 1024 * 1024, timeout: 30_000, windowsHide: true },
|
|
635
|
+
);
|
|
636
|
+
const h = parseInt(stdout.trim(), 10);
|
|
637
|
+
return Number.isFinite(h) && h > 0 ? h : null;
|
|
638
|
+
} catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private async probeDurationSeconds(inputPath: string, timeoutMs: number): Promise<number | null> {
|
|
644
|
+
const ffprobeCmd = this.getFfprobeCommand();
|
|
645
|
+
try {
|
|
646
|
+
const { stdout } = await execFileAsync(
|
|
647
|
+
ffprobeCmd,
|
|
648
|
+
['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', inputPath],
|
|
649
|
+
{ maxBuffer: 1024 * 1024, timeout: Math.min(timeoutMs, 30_000), windowsHide: true },
|
|
650
|
+
);
|
|
651
|
+
const parsed = parseFloat(stdout.trim());
|
|
652
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : null;
|
|
653
|
+
} catch {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// Frame extraction (same logic as CourseVideoConversionService)
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
private async extractAndUploadLessonFrames(params: {
|
|
663
|
+
jobId: number;
|
|
664
|
+
lessonId: number;
|
|
665
|
+
inputPath: string;
|
|
666
|
+
workDir: string;
|
|
667
|
+
intervalSeconds: number;
|
|
668
|
+
timeoutMs: number;
|
|
669
|
+
}): Promise<LessonFrameResult[]> {
|
|
670
|
+
const intervalSeconds = Math.max(1, Math.floor(params.intervalSeconds));
|
|
671
|
+
const framesDir = join(params.workDir, 'frames');
|
|
672
|
+
await fs.mkdir(framesDir, { recursive: true });
|
|
673
|
+
|
|
674
|
+
const pattern = join(framesDir, 'frame-%08d.jpg');
|
|
675
|
+
const ffmpegCmd = this.getFfmpegCommand();
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
await execFileAsync(
|
|
679
|
+
ffmpegCmd,
|
|
680
|
+
['-y', '-i', params.inputPath, '-vf', `fps=1/${intervalSeconds}`, '-q:v', '3', '-qmin', '2', '-qmax', '4', pattern],
|
|
681
|
+
{ maxBuffer: 1024 * 1024 * 20, timeout: params.timeoutMs, windowsHide: true },
|
|
682
|
+
);
|
|
683
|
+
} catch (error: any) {
|
|
684
|
+
if (error?.code === 'ENOENT') throw new Error(`FFmpeg binary not found`);
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const frameFiles = (await fs.readdir(framesDir))
|
|
689
|
+
.filter((n) => n.toLowerCase().endsWith('.jpg'))
|
|
690
|
+
.sort((a, b) => a.localeCompare(b));
|
|
691
|
+
|
|
692
|
+
if (frameFiles.length === 0) {
|
|
693
|
+
await (this.prisma as any).course_lesson_video_frame.deleteMany({ where: { course_lesson_id: params.lessonId } });
|
|
694
|
+
return [];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const uploads: LessonFrameResult[] = [];
|
|
698
|
+
for (let i = 0; i < frameFiles.length; i++) {
|
|
699
|
+
const fileName = frameFiles[i];
|
|
700
|
+
if (!fileName) continue;
|
|
701
|
+
const framePath = join(framesDir, fileName);
|
|
702
|
+
const stats = await fs.stat(framePath);
|
|
703
|
+
const uploaded = await this.fileService.uploadFromPath('lms/lessons/frames', framePath, {
|
|
704
|
+
originalname: fileName,
|
|
705
|
+
mimetype: 'image/jpeg',
|
|
706
|
+
});
|
|
707
|
+
uploads.push({ fileId: uploaded.id, timeSeconds: i * intervalSeconds, outputBytes: stats.size });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
await (this.prisma as any).course_lesson_video_frame.deleteMany({ where: { course_lesson_id: params.lessonId } });
|
|
711
|
+
await (this.prisma as any).course_lesson_video_frame.createMany({
|
|
712
|
+
data: uploads.map((item) => ({
|
|
713
|
+
course_lesson_id: params.lessonId,
|
|
714
|
+
file_id: item.fileId,
|
|
715
|
+
time_seconds: item.timeSeconds,
|
|
716
|
+
})),
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
return uploads;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
// Audio extraction (same logic as CourseVideoConversionService)
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
private async extractAndUploadLessonAudio(params: {
|
|
727
|
+
courseId: number;
|
|
728
|
+
lessonId: number;
|
|
729
|
+
localInputPath: string;
|
|
730
|
+
tempDir: string;
|
|
731
|
+
}): Promise<number | null> {
|
|
732
|
+
const ffmpegCmd = this.getFfmpegCommand();
|
|
733
|
+
const mp3Path = join(params.tempDir, `lesson_${params.lessonId}_audio.mp3`);
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
await execFileAsync(
|
|
737
|
+
ffmpegCmd,
|
|
738
|
+
['-y', '-i', params.localInputPath, '-vn', '-ar', '16000', '-ac', '1', '-c:a', 'libmp3lame', '-b:a', '32k', mp3Path],
|
|
739
|
+
{ maxBuffer: 1024 * 1024 * 20, windowsHide: true },
|
|
740
|
+
);
|
|
741
|
+
} catch {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
const course = await (this.prisma as any).course.findUnique({
|
|
747
|
+
where: { id: params.courseId },
|
|
748
|
+
select: { locale_id: true },
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const uploaded = await this.fileService.uploadFromPath('lms/lessons/audio', mp3Path, {
|
|
752
|
+
originalname: basename(mp3Path),
|
|
753
|
+
mimetype: 'audio/mp3',
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const defaultLocale = await (this.prisma as any).locale.findFirst({
|
|
757
|
+
where: { OR: [{ code: 'pt-BR' }, { code: 'pt' }] },
|
|
758
|
+
select: { id: true },
|
|
759
|
+
orderBy: { id: 'asc' },
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const resolvedLocaleId = course?.locale_id ?? defaultLocale?.id ?? null;
|
|
763
|
+
|
|
764
|
+
const existing = await (this.prisma as any).course_lesson_file.findFirst({
|
|
765
|
+
where: { course_lesson_id: params.lessonId, type: 'lesson_audio' },
|
|
766
|
+
select: { id: true },
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
if (existing) {
|
|
770
|
+
await (this.prisma as any).course_lesson_file.update({
|
|
771
|
+
where: { id: existing.id },
|
|
772
|
+
data: { file_id: uploaded.id, locale_id: resolvedLocaleId, title: 'Audio Original.mp3', type: 'lesson_audio', is_public: false },
|
|
773
|
+
});
|
|
774
|
+
} else {
|
|
775
|
+
await (this.prisma as any).course_lesson_file.create({
|
|
776
|
+
data: {
|
|
777
|
+
course_lesson_id: params.lessonId,
|
|
778
|
+
file_id: uploaded.id,
|
|
779
|
+
title: 'Audio Original.mp3',
|
|
780
|
+
type: 'lesson_audio',
|
|
781
|
+
is_public: false,
|
|
782
|
+
locale_id: resolvedLocaleId,
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return uploaded.id;
|
|
788
|
+
} catch {
|
|
789
|
+
return null;
|
|
790
|
+
} finally {
|
|
791
|
+
await fs.rm(mp3Path, { force: true }).catch(() => undefined);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ---------------------------------------------------------------------------
|
|
796
|
+
// Shared helpers
|
|
797
|
+
// ---------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
private async upsertLessonFile(params: {
|
|
800
|
+
lessonId: number;
|
|
801
|
+
type: string;
|
|
802
|
+
fileId: number;
|
|
803
|
+
title: string;
|
|
804
|
+
public: boolean;
|
|
805
|
+
overwrite: boolean;
|
|
806
|
+
}) {
|
|
807
|
+
if (params.overwrite) {
|
|
808
|
+
await (this.prisma as any).course_lesson_file.deleteMany({
|
|
809
|
+
where: { course_lesson_id: params.lessonId, type: params.type },
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return (this.prisma as any).course_lesson_file.create({
|
|
814
|
+
data: {
|
|
815
|
+
course_lesson_id: params.lessonId,
|
|
816
|
+
file_id: params.fileId,
|
|
817
|
+
title: params.title,
|
|
818
|
+
type: params.type,
|
|
819
|
+
is_public: params.public,
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private async createProgressEvent(
|
|
825
|
+
queueJobId: number,
|
|
826
|
+
message: string,
|
|
827
|
+
metadata?: Record<string, unknown>,
|
|
828
|
+
notificationContext?: NotificationContext,
|
|
829
|
+
) {
|
|
830
|
+
try {
|
|
831
|
+
await (this.prisma as any).queue_job_event.create({
|
|
832
|
+
data: { queue_job_id: queueJobId, event_type: 'started', message, ...(metadata ? { metadata } : {}) },
|
|
833
|
+
});
|
|
834
|
+
} catch (error) {
|
|
835
|
+
this.logger.warn(`Failed to persist progress event: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (notificationContext) {
|
|
839
|
+
const progress = this.resolveNotificationProgress(metadata);
|
|
840
|
+
await this.updateAsyncNotification(notificationContext, progress, message);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private resolveNotificationProgress(metadata?: Record<string, unknown>): number {
|
|
845
|
+
const phase = String(metadata?.phase ?? '');
|
|
846
|
+
if (phase === 'download_original') return 5;
|
|
847
|
+
if (phase === 'probe_duration') return 12;
|
|
848
|
+
if (phase === 'hls_encode') return 20;
|
|
849
|
+
if (phase === 'hls_upload') return 65;
|
|
850
|
+
if (phase === 'extract_frames') return 72;
|
|
851
|
+
if (phase === 'extract_frames_done') return 78;
|
|
852
|
+
if (phase === 'extract_audio') return 83;
|
|
853
|
+
if (phase === 'queue_transcription') return 90;
|
|
854
|
+
if (phase === 'queue_transcription_done') return 92;
|
|
855
|
+
if (phase === 'queue_transcription_skipped') return 100;
|
|
856
|
+
return 15;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private async updateAsyncNotification(context: NotificationContext, progress: number, body: string, success?: boolean) {
|
|
860
|
+
try {
|
|
861
|
+
await this.notificationService.updateProgress(context.userId, context.notificationId, {
|
|
862
|
+
progress,
|
|
863
|
+
body,
|
|
864
|
+
...(success != null ? { success } : {}),
|
|
865
|
+
});
|
|
866
|
+
} catch (error) {
|
|
867
|
+
this.logger.warn(`Failed to update notification ${context.notificationId}: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private parseContent(content: string | null | undefined): Record<string, unknown> | null {
|
|
872
|
+
if (!content) return null;
|
|
873
|
+
try { return JSON.parse(content); } catch { return null; }
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
private isVideoFile(mimetype: string, filename?: string): boolean {
|
|
877
|
+
if (mimetype.startsWith('video/')) return true;
|
|
878
|
+
const ext = (filename ?? '').split('.').pop()?.toLowerCase() ?? '';
|
|
879
|
+
return ['mp4', 'm4v', 'mov', 'mkv', 'webm', 'avi', 'wmv', 'flv', 'ogv', 'm2ts', 'ts'].includes(ext);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private resolveFrameIntervalSeconds(raw: unknown): number {
|
|
883
|
+
const n = Number(raw);
|
|
884
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 10;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private getPositiveIntegerEnv(name: string): number | undefined {
|
|
888
|
+
const raw = process.env[name];
|
|
889
|
+
if (!raw) return undefined;
|
|
890
|
+
const n = parseInt(raw, 10);
|
|
891
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private getFfmpegCommand(): string {
|
|
895
|
+
const fromEnv = process.env.FFMPEG_PATH?.trim();
|
|
896
|
+
if (fromEnv) return fromEnv;
|
|
897
|
+
if (process.platform === 'win32') {
|
|
898
|
+
const found = this.findWindowsBinary('ffmpeg');
|
|
899
|
+
if (found) return found;
|
|
900
|
+
}
|
|
901
|
+
return 'ffmpeg';
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private getFfprobeCommand(): string {
|
|
905
|
+
const fromEnv = process.env.FFPROBE_PATH?.trim();
|
|
906
|
+
if (fromEnv) return fromEnv;
|
|
907
|
+
const ffmpegEnv = process.env.FFMPEG_PATH?.trim();
|
|
908
|
+
if (ffmpegEnv) {
|
|
909
|
+
const candidate = ffmpegEnv.replace(/ffmpeg(\.exe)?$/i, (_, ext) => `ffprobe${ext ?? ''}`);
|
|
910
|
+
if (existsSync(candidate)) return candidate;
|
|
911
|
+
}
|
|
912
|
+
if (process.platform === 'win32') {
|
|
913
|
+
const found = this.findWindowsBinary('ffprobe');
|
|
914
|
+
if (found) return found;
|
|
915
|
+
}
|
|
916
|
+
return 'ffprobe';
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
private findWindowsBinary(name: 'ffmpeg' | 'ffprobe'): string | null {
|
|
920
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
921
|
+
if (!localAppData) return null;
|
|
922
|
+
const packagesRoot = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
|
|
923
|
+
try {
|
|
924
|
+
const packageDirs = readdirSync(packagesRoot, { withFileTypes: true })
|
|
925
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('Gyan.FFmpeg'))
|
|
926
|
+
.map((e) => join(packagesRoot, e.name))
|
|
927
|
+
.sort((a, b) => b.localeCompare(a));
|
|
928
|
+
|
|
929
|
+
for (const dir of packageDirs) {
|
|
930
|
+
const direct = join(dir, 'bin', `${name}.exe`);
|
|
931
|
+
if (existsSync(direct)) return direct;
|
|
932
|
+
try {
|
|
933
|
+
const versionDirs = readdirSync(dir, { withFileTypes: true })
|
|
934
|
+
.filter((e) => e.isDirectory() && e.name.toLowerCase().startsWith('ffmpeg-'))
|
|
935
|
+
.map((e) => join(dir, e.name))
|
|
936
|
+
.sort((a, b) => b.localeCompare(a));
|
|
937
|
+
for (const vd of versionDirs) {
|
|
938
|
+
const candidate = join(vd, 'bin', `${name}.exe`);
|
|
939
|
+
if (existsSync(candidate)) return candidate;
|
|
940
|
+
}
|
|
941
|
+
} catch { /* skip */ }
|
|
942
|
+
}
|
|
943
|
+
} catch { /* skip */ }
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
}
|