18 changed files with 394 additions and 68 deletions
@ -0,0 +1,123 @@ |
|||
// 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); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,4 @@ |
|||
.uk-lightbox-toolbar { |
|||
background-color: rgba(0,0,0, 0.85); |
|||
border-top: solid 1px #6a6a6a; |
|||
} |
Loading…
Reference in new issue