From 7951694d034b8115e7b6b5ed0b69dc7ff3d3defc Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 8 May 2018 15:41:52 +0100 Subject: [PATCH 1/2] feat(previews) Generate low res webm previews for use as scrubbable proxies in clients --- .gitignore | 1 + src/app.js | 7 ++++ src/config.js | 6 ++++ src/index.js | 5 +++ src/previews.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ src/scanner.js | 13 ++----- src/util.js | 16 +++++++++ 7 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 src/previews.js diff --git a/.gitignore b/.gitignore index 334078f..b35f7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ bin/ media/ dist/ pouch__all_dbs__/ +_previews/ _media \ No newline at end of file diff --git a/src/app.js b/src/app.js index df478e6..ec2ac65 100644 --- a/src/app.js +++ b/src/app.js @@ -2,6 +2,7 @@ const express = require('express') const pinoHttp = require('pino-http') const PouchDB = require('pouchdb-node') const util = require('util') +const path = require('path') const recursiveReadDir = require('recursive-readdir') const { getId } = require('./util') @@ -18,6 +19,12 @@ module.exports = function ({ db, config, logger }) { mode: 'minimumForPouchDB' })) + app.get('/media/preview/:id', wrap(async (req, res) => { + const { previewPath } = await db.get(req.params.id.toUpperCase()) + + res.sendFile(path.join(process.cwd(), previewPath)) + })) + app.get('/cls', wrap(async (req, res) => { const { rows } = await db.allDocs({ include_docs: true }) diff --git a/src/config.js b/src/config.js index 90781bc..7a8d380 100644 --- a/src/config.js +++ b/src/config.js @@ -23,6 +23,12 @@ const defaults = { width: 256, height: -1 }, + previews: { + enable: false, + width: 160, + height: -1, + bitrate: '40k' + }, isProduction: process.env.NODE_ENV === 'production', logger: { level: process.env.NODE_ENV === 'production' ? 'info' : 'trace', diff --git a/src/index.js b/src/index.js index c43f000..f883fa8 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ const pino = require('pino') const config = require('./config') const PouchDB = require('pouchdb-node') const scanner = require('./scanner') +const previews = require('./previews') const app = require('./app') const logger = pino(Object.assign({}, config.logger, { @@ -16,3 +17,7 @@ logger.info(config) scanner({ logger, db, config }) app({ logger, db, PouchDB, config }).listen(config.http.port) + +if (config.previews.enable) { + previews({ logger, db, config }) +} diff --git a/src/previews.js b/src/previews.js new file mode 100644 index 0000000..0667a2f --- /dev/null +++ b/src/previews.js @@ -0,0 +1,91 @@ +const cp = require('child_process') +const { Observable } = require('@reactivex/rxjs') +const util = require('util') +const mkdirp = require('mkdirp-promise') +const os = require('os') +const fs = require('fs') +const path = require('path') +const { fileExists } = require('./util') + +const statAsync = util.promisify(fs.stat) +const unlinkAsync = util.promisify(fs.unlink) +const renameAsync = util.promisify(fs.rename) + +module.exports = function ({ config, db, logger }) { + Observable + .create(async o => { + db.changes({ + since: 'now', + live: true + }).on('change', function (change) { + o.next([change.id, change.deleted]) + }).on('error', function (err) { + logger.error({ err }) + }) + + // Queue all for attempting to regenerate previews, if they are needed + const { rows } = await db.allDocs() + rows.forEach(d => o.next([d.id, false])) + logger.info('Queued all for preview validity check') + }) + .concatMap(async ([id, deleted]) => { + await generatePreview(id, deleted) + }) + .subscribe() + + async function generatePreview (mediaId, deleted) { + try { + const destPath = path.join('_previews', mediaId) + '.webm' + if (deleted) { + await unlinkAsync(destPath) + return + } + + const doc = await db.get(mediaId) + if (doc.previewTime === doc.mediaTime && await fileExists(destPath)) { + return + } + + const mediaLogger = logger.child({ + id: mediaId, + path: doc.mediaPath + }) + + const tmpPath = destPath + '.new' + + const args = [ + // TODO (perf) Low priority process? + config.paths.ffmpeg, + '-hide_banner', + '-i', `"${doc.mediaPath}"`, + '-f', 'webm', + '-an', + '-c:v', 'libvpx', + '-b:v', config.previews.bitrate, + '-auto-alt-ref', '0', + `-vf scale=${config.previews.width}:${config.previews.height}`, + '-threads 1', + `"${tmpPath}"` + ] + + await mkdirp(path.dirname(tmpPath)) + mediaLogger.info('Starting preview generation') + await new Promise((resolve, reject) => { + cp.exec(args.join(' '), (err, stdout, stderr) => err ? reject(err) : resolve()) + }) + + const previewStat = await statAsync(tmpPath) + doc.previewSize = previewStat.size + doc.previewTime = doc.mediaTime + doc.previewPath = destPath + + await renameAsync(tmpPath, destPath) + + await db.put(doc) + + mediaLogger.info('Finished preview generation') + } catch (err) { + logger.error({ err }) + } + } +} diff --git a/src/scanner.js b/src/scanner.js index 8bd6b70..40a166e 100644 --- a/src/scanner.js +++ b/src/scanner.js @@ -6,7 +6,7 @@ const mkdirp = require('mkdirp-promise') const os = require('os') const fs = require('fs') const path = require('path') -const { getId } = require('./util') +const { getId, fileExists } = require('./util') const moment = require('moment') const statAsync = util.promisify(fs.stat) @@ -60,15 +60,8 @@ module.exports = function ({ config, db, logger }) { }) await Promise.all(rows.map(async ({ doc }) => { try { - if (doc.mediaPath.indexOf(config.scanner.paths) === 0) { - try { - const stat = await statAsync(doc.mediaPath) - if (stat.isFile()) { - return - } - } catch (e) { - // File not found - } + if (doc.mediaPath.indexOf(config.scanner.paths) === 0 && await fileExists(doc.mediaPath)) { + return } deleted.push({ diff --git a/src/util.js b/src/util.js index fb075d3..0f867a2 100644 --- a/src/util.js +++ b/src/util.js @@ -1,4 +1,8 @@ const path = require('path') +const fs = require('fs') +const util = require('util') + +const statAsync = util.promisify(fs.stat) module.exports = { getId (fileDir, filePath) { @@ -7,5 +11,17 @@ module.exports = { .replace(/\.[^/.]+$/, '') .replace(/\\+/g, '/') .toUpperCase() + }, + + async fileExists (destPath) { + try { + const stat = await statAsync(destPath) + if (stat.isFile()) { + return true + } + } catch (e) { + // File not found + } + return false } } From 0a53ef316b986eae8ad865dac5d03820fcd1745f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 8 May 2018 16:53:19 +0100 Subject: [PATCH 2/2] chore(preview) Add endpoint to readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cd47a06..ae2f3ec 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ db.changes({ }); ``` +### Preview Videos +This tool is able to generate low resolution webm preview videos of all media, intended to be used in web based clients. +They are generated in the background after the media is found or detected to have changed, so may not be available immediately. +They can be accessed via the following url format `/media/preview/` Development -----------