// video.js // Copyright (C) 2024 DTP Technologies, LLC // All Rights Reserved 'use strict'; import fs from 'node:fs'; import path from 'node:path'; import mongoose from 'mongoose'; const Video = mongoose.model('Video'); import mime from 'mime'; import numeral from 'numeral'; import { SiteService, SiteError } from '../../lib/site-lib.js'; import user from '../models/user.js'; export default class VideoService extends SiteService { static get name ( ) { return 'VideoService'; } static get slug ( ) { return 'video'; } constructor (dtp) { super(dtp, VideoService); } async start ( ) { const { user: userService } = this.dtp.services; await super.start(); await fs.promises.mkdir(process.env.VIDEO_WORK_PATH, { recursive: true }); this.populateVideo = [ { path: 'owner', select: userService.USER_SELECT, }, { path: 'thumbnail', }, ]; } async createVideo (owner, attachmentDefinition, file) { const NOW = new Date(); const { media: mediaService, minio: minioService } = this.dtp.services; this.log.debug('running ffprobe on uploaded library media', { path: file.path }); const metadata = await mediaService.ffprobe(file.path); this.log.debug('video file probed', { path: file.path, metadata }); if (user.membership) { if (file.size > (100 * 1024 * 1024)) { throw new SiteError(403, 'Video attachments are limited to 100MB file size.'); } } else { if (file.size > (25 * 1024 * 1024)) { throw new SiteError(403, 'Video attachments are limited to 25MB file size.'); } } const video = new Video(); video.created = NOW; video.owner = owner._id; video.duration = metadata.duration; video.status = 'new'; const ownerId = owner._id.toString(); const videoId = video._id.toString(); const fileBucket = process.env.MINIO_VIDEO_BUCKET || 'videos'; const mediaExt = mime.getExtension(file.mimetype) || 'dat'; const fileKey = `/video/${ownerId.slice(0, 3)}/${ownerId}/${videoId.slice(0, 3)}/${videoId}/media.${mediaExt}`; this.log.debug('uploading video to storage', { _id: video._id, bucket: fileBucket, key: fileKey, path: file.path, }); await minioService.uploadFile({ bucket: fileBucket, key: fileKey, filePath: file.path, metadata: { 'Video-ID': videoId, 'Content-Type': file.mimetype, 'Content-Length': file.size, }, }); video.media = { bucket: fileBucket, key: fileKey, metadata: { type: file.mimetype, size: metadata.format.size, bitRate: metadata.format.bit_rate, duration: metadata.duration, video: { width: metadata.width, height: metadata.height, fps: metadata.fps, }, }, }; await video.save(); await this.extractVideoAttachmentThumbnail(video, file); return video.toObject(); } async extractVideoAttachmentThumbnail (video, file) { const { media: mediaService } = this.dtp.services; const thumbnailFile = path.join(process.env.VIDEO_WORK_PATH, `${video._id}.png`); const ffmpegThumbnailArgs = [ '-y', '-i', file.path, '-ss', numeral(video.media.metadata.duration * 0.05).format('hh:mm:ss'), '-frames:v', '1', thumbnailFile, ]; this.log.debug('extracting video thumbnail', { ffmpegThumbnailArgs }); await mediaService.ffmpeg(ffmpegThumbnailArgs); this.log.info('persisting thumbnail to video', { videoId: video._id }); await this.setVideoThumbnailImage(video, thumbnailFile); } async setVideoThumbnailImage (video, thumbnailFile) { const { image: imageService } = this.dtp.services; await this.removeVideoThumbnailImage(video); const imageFileDesc = { path: thumbnailFile }; const outputs = [ { width: 960, height: 540, format: 'jpeg', formatParameters: { quality: 95 }, } ]; await imageService.processImageFile( video.owner._id, imageFileDesc, outputs ); await Video.updateOne( { _id: video._id }, { $set: { thumbnail: outputs[0].image._id } }, ); return outputs[0].image._id; } async removeVideoThumbnailImage (video) { const { image: imageService } = this.dtp.services; if (!video.thumbnail) { return; } this.log.info('removing current video thumbnail', { videoId: video._id }); await imageService.deleteImage(video.thumbnail); await Video.updateOne({ _id: video._id }, { $unset: { thumbnail: 1 } }); delete video.thumbnail; } async setVideoStatus (video, status) { await Video.updateOne({ _id: video._id }, { $set: { status } }); } async getVideoById (videoId) { const video = await Video .findOne({ _id: videoId }) .populate(this.populateVideo) .lean(); return video; } }