// video.js // Copyright (C) 2024 DTP Technologies, LLC // All Rights Reserved 'use strict'; import express from 'express'; import { pipeline } from 'node:stream'; import { SiteController, SiteError } from '../../lib/site-lib.js'; export default class VideoController extends SiteController { static get name ( ) { return 'VideoController'; } static get slug ( ) { return 'video'; } constructor (dtp) { super(dtp, VideoController); } async start ( ) { const { limiter: limiterService, session: sessionService, } = this.dtp.services; const limiterConfig = limiterService.config.video; this.templates = { passwordResetComplete: this.loadViewTemplate('auth/password-reset-complete.pug'), }; const authRequired = sessionService.authCheckMiddleware({ requireLogin: true }); const router = express.Router(); this.dtp.app.use('/video', authRequired, router); router.use(async (req, res, next) => { res.locals.currentView = 'video'; return next(); }); router.param('videoId', this.populateVideoId.bind(this)); router.get( '/:videoId/media', limiterService.create(limiterConfig.getVideoMedia), this.getVideoMedia.bind(this), ); return router; } async populateVideoId (req, res, next, videoId) { const { video: videoService } = this.dtp.services; try { res.locals.video = await videoService.getVideoById(videoId); if (!res.locals.video) { throw new SiteError(404, 'Video not found'); } return next(); } catch (error) { this.log.error('failed to populate video', { videoId, error }); return next(error); } } async getVideoMedia (req, res, next) { const { minio: minioService } = this.dtp.services; try { let artifact = res.locals.video.media; const artifactStat = await minioService.statObject(artifact.bucket, artifact.key); const fileInfo = Object.assign({ }, artifact); const range = req.get('range'); if (range) { let [start, end] = range.replace(/bytes=/, '').split('-').map((term) => parseInt(term.trim(), 10)); if (!isNaN(start) && isNaN(end)) { end = artifactStat.size - 1; } if (isNaN(start) && !isNaN(end)) { start = artifactStat.size - end; end = artifactStat.size - 1; } fileInfo.range = { start, end }; } this.log.debug('starting video media stream', { media: artifact, fileInfo, }); const stream = await minioService.openDownloadStream(fileInfo); if (fileInfo.range) { res.writeHead(206, { 'Content-Type': 'video/mp4', 'Content-Length': fileInfo.range.end - fileInfo.range.start + 1, 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${fileInfo.range.start}-${fileInfo.range.end}/${artifactStat.size}`, 'Cache-Control': 'public, maxage=86400, s-maxage=86400, immutable', }); } else { res.writeHead(200, { 'Accept-Ranges': 'bytes', 'Content-Type': 'video/mp4', 'Content-Length': artifactStat.size, 'Cache-Control': 'public, maxage=86400, s-maxage=86400, immutable', }); } pipeline(stream, res, (err) => { if (err) { this.log.debug('failed to stream media', { err }); return; } this.log.info('media stream sent'); }); } catch (error) { this.log.error('failed to open media stream', { videoId: res.locals.video._id, error }); return next(error); } } }