Merge pull request #68 from vexorian/development

Update plex status to stopped in more situations. Code for resolution/codec normalization and alignment, but currently disabled by constants in ffmpeg.js
This commit is contained in:
Austin Tinius 2020-06-23 10:41:36 -07:00 committed by GitHub
commit 70da29a243
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 172 additions and 51 deletions

View File

@ -2,18 +2,42 @@ const spawn = require('child_process').spawn
const events = require('events')
const fs = require('fs')
// For now these options can be enabled with constants, must also enable overlay in settings:
// Normalize resoltion to WxH:
const FIX_RESOLUTION = false;
const W = 1920;
const H = 1080;
// Normalize codecs, video codec is in ffmpeg settings:
const FIX_CODECS = false;
// Align audio and video channels
const ALIGN_AUDIO = false;
// What audio encoder to use:
const AUDIO_ENCODER = 'aac';
const ERROR_PICTURE_PATH = 'http://localhost:8000/images/generic-error-screen.png'
class FFMPEG extends events.EventEmitter {
constructor(opts, channel) {
super()
this.opts = opts
this.channel = channel
this.ffmpegPath = opts.ffmpegPath
this.alignAudio = ALIGN_AUDIO;
}
async spawnConcat(streamUrl) {
this.spawn(streamUrl, undefined, undefined, undefined, false, false, undefined, true)
}
async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) {
this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false)
this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false);
}
async spawnError(title, subtitle, streamStats, enableIcon, type) {
// currently the error stream feature is not implemented
console.log("error: " + title + " ; " + subtitle);
this.emit('error', { code: -1, cmd: `error stream disabled` })
return;
}
async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) {
let ffmpegArgs = [`-threads`, this.opts.threads,
@ -22,8 +46,6 @@ class FFMPEG extends events.EventEmitter {
if (limitRead === true)
ffmpegArgs.push(`-re`)
if (typeof duration !== 'undefined')
ffmpegArgs.push(`-t`, duration)
if (typeof startTime !== 'undefined')
ffmpegArgs.push(`-ss`, startTime)
@ -33,67 +55,139 @@ class FFMPEG extends events.EventEmitter {
`-safe`, `0`,
`-protocol_whitelist`, `file,http,tcp,https,tcp,tls`)
ffmpegArgs.push(`-i`, streamUrl)
// Map correct audio index. '?' so doesn't fail if no stream available.
let audioIndex = (typeof streamStats === 'undefined') ? '0:a?' : `0:${streamStats.audioIndex}?`;
let audioIndex = (typeof streamStats === 'undefined') ? 'a' : `${streamStats.audioIndex}`;
// Overlay icon
if (enableIcon && type === 'program') {
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
//TODO: Do something about missing audio stream
if (!isConcatPlaylist) {
// When we have an individual stream, there is a pipeline of possible
// filters to apply.
//
var doOverlay = (enableIcon && type === 'program');
var iW = streamStats.videoWidth;
var iH = streamStats.videoHeight;
let posAry = [ '20:20', 'W-w-20:20', '20:H-h-20', 'W-w-20:H-h-20'] // top-left, top-right, bottom-left, bottom-right (with 20px padding)
let icnDur = ''
// (explanation is the same for the video and audio streams)
// The initial stream is called '[video]'
var currentVideo = "[video]";
var currentAudio = "[audio]";
// Initially, videoComplex does nothing besides assigning the label
// to the input stream
var audioComplex = `;[0:${audioIndex}]anull[audio]`;
var videoComplex = `;[0:v]null[video]`;
// Depending on the options we will apply multiple filters
// each filter modifies the current video stream. Adds a filter to
// the videoComplex variable. The result of the filter becomes the
// new currentVideo value.
//
// When adding filters, make sure that
// videoComplex always begins wiht ; and doesn't end with ;
if (this.channel.iconDuration > 0)
icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
let iconOverlay = `[1:v]scale=${this.channel.iconWidth}:-1[icn];[0:v][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[outv]`
// Only scale video if specified, don't upscale video
if (this.opts.videoResolutionHeight != "unchanged" && streamStats.videoHeight != `undefined` && parseInt(this.opts.videoResolutionHeight, 10) < parseInt(streamStats.videoHeight, 10)) {
iconOverlay = `[0:v]scale=-2:${this.opts.videoResolutionHeight}[scaled];[1:v]scale=${this.channel.iconWidth}:-1[icn];[scaled][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[outv]`
// prepare input files, overlay adds another input file
ffmpegArgs.push(`-i`, streamUrl);
if (doOverlay) {
ffmpegArgs.push(`-i`, `${this.channel.icon}` );
}
ffmpegArgs.push(`-i`, `${this.channel.icon}`,
`-filter_complex`, iconOverlay,
`-map`, `[outv]`,
`-c:v`, this.opts.videoEncoder,
// Resolution fix: Add scale filter, current stream becomes [siz]
if (FIX_RESOLUTION && (iW != W || iH != H) ) {
//Maybe the scaling algorithm could be configurable. bicubic seems good though
videoComplex += `;${currentVideo}scale=${W}:${H}:flags=bicubic:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2[siz]`
currentVideo = "[siz]";
}
// Channel overlay:
if (doOverlay) {
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
let posAry = [ '20:20', 'W-w-20:20', '20:H-h-20', 'W-w-20:H-h-20'] // top-left, top-right, bottom-left, bottom-right (with 20px padding)
let icnDur = ''
if (this.channel.iconDuration > 0)
icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
videoComplex += `;[1:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]`
currentVideo = '[comb]';
}
// Align audio is just the apad filter applied to audio stream
if (this.alignAudio) {
audioComplex += `;${currentAudio}apad=whole_dur=${streamStats.duration}ms[padded]`;
currentAudio = '[padded]';
}
// If no filters have been applied, then the stream will still be
// [video] , in that case, we do not actually add the video stuff to
// filter_complex and this allows us to avoid transcoding.
var changeVideoCodec = FIX_CODECS;
var changeAudioCodec = FIX_CODECS;
var filterComplex = '';
if (currentVideo != '[video]') {
changeVideoCodec = true;
filterComplex += videoComplex;
} else {
currentVideo = '0:v';
}
// same with audi:
if (currentAudio != '[audio]') {
changeAudioCodec = true; //this is useful for some more flags later
filterComplex += audioComplex;
} else {
currentAudio = `0:${audioIndex}`;
}
//If there is a filter complex, add it.
if (filterComplex != '') {
ffmpegArgs.push(`-filter_complex` , filterComplex.slice(1) );
if (this.alignAudio) {
ffmpegArgs.push('-shortest');
}
}
ffmpegArgs.push(
'-map', currentVideo,
'-map', currentAudio,
`-c:v`, (changeVideoCodec ? this.opts.videoEncoder : 'copy'),
`-flags`, `cgop+ilme`,
`-sc_threshold`, `1000000000`,
`-sc_threshold`, `1000000000`
);
if ( changeVideoCodec ) {
// add the video encoder flags
ffmpegArgs.push(
`-b:v`, `${this.opts.videoBitrate}k`,
`-minrate:v`, `${this.opts.videoBitrate}k`,
`-maxrate:v`, `${this.opts.videoBitrate}k`,
`-bufsize:v`, `${this.opts.videoBufSize}k`,
`-map`, `${audioIndex}`,
`-c:a`, `copy`,
`-bufsize:v`, `${this.opts.videoBufSize}k`
);
}
ffmpegArgs.push(
`-c:a`, (changeAudioCodec ? AUDIO_ENCODER : 'copy'),
`-muxdelay`, `0`,
`-muxpreload`, `0`);
} else if (enableIcon && streamStats.videoCodec != this.opts.videoEncoder) { // Encode commercial if video codec does not match
ffmpegArgs.push(`-map`, `0:v`,
`-map`, `${audioIndex}`,
`-c:v`, this.opts.videoEncoder,
`-flags`, `cgop+ilme`,
`-sc_threshold`, `1000000000`,
`-b:v`, `${this.opts.videoBitrate}k`,
`-minrate:v`, `${this.opts.videoBitrate}k`,
`-maxrate:v`, `${this.opts.videoBitrate}k`,
`-bufsize:v`, `${this.opts.videoBufSize}k`,
`-c:a`, `copy`,
`-muxdelay`, `0`,
`-muxpreload`, `0`);
} else
ffmpegArgs.push(`-map`, `0:v`,
`-map`, `${audioIndex}`,
`-muxpreload`, `0`
);
} else {
//Concat stream is simpler and should always copy the codec
ffmpegArgs.push(
`-i`, streamUrl,
`-map`, `0:v`,
`-map`, `0:${audioIndex}`,
`-c`, `copy`,
`-muxdelay`, this.opts.concatMuxDelay,
`-muxpreload`, this.opts.concatMuxDelay);
}
ffmpegArgs.push(`-metadata`,
`service_provider="PseudoTV"`,
`-metadata`,
`service_name="${this.channel.name}`,
`-f`, `mpegts`,
`pipe:1`)
`-f`, `mpegts`);
//t should be before output
if (typeof duration !== 'undefined') {
ffmpegArgs.push(`-t`, duration)
}
ffmpegArgs.push(`pipe:1`)
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs)
this.ffmpeg.stdout.on('data', (chunk) => {

View File

@ -168,6 +168,7 @@ lang=en`
try {
let streams = this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].Stream
ret.duration = parseFloat( this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].duration );
streams.forEach(function (stream) {
// Video
if (stream["streamType"] == "1") {

View File

@ -138,18 +138,29 @@ function video(db) {
let deinterlace = enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon)
let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem);
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
ffmpeg.on('data', (data) => { res.write(data) })
ffmpeg.on('error', (err) => {
console.error("FFMPEG ERROR", err);
res.status(500).send("FFMPEG ERROR");
return;
plexTranscoder.stopUpdatingPlex();
if (typeof(this.backup) !== 'undefined') {
let ffmpeg2 = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
ffmpeg2.spawnError('Source error', `ffmpeg returned code ${err.code}`, this.backup.stream.streamStats, this.backup.enableChannelIcon, this.backup.type); // Spawn the ffmpeg process, fire this bitch up
ffmpeg2.on('data', (data) => { res.write(data) } );
ffmpeg2.on('error', (err) => { res.end() } );
ffmpeg2.on('close', () => { res.send() } );
ffmpeg2.on('end', () => { res.end() } );
res.on('close', () => {
ffmpeg2.kill();
});
} else {
res.end()
}
})
ffmpeg.on('close', () => {
plexTranscoder.stopUpdatingPlex();
res.send();
})
@ -159,14 +170,29 @@ function video(db) {
})
res.on('close', () => { // on HTTP close, kill ffmpeg
plexTranscoder.stopUpdatingPlex();
ffmpeg.kill();
})
plexTranscoder.getStream(deinterlace).then(stream => {
let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined;
let streamStats = stream.streamStats
console.log("timeElapsed=" + prog.timeElapsed );
streamStats.duration = streamStats.duration - prog.timeElapsed;
this.backup = {
stream: stream,
streamStart: streamStart,
streamDuration: streamDuration,
enableChannelIcon: enableChannelIcon,
type: lineupItem.type
};
ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, enableChannelIcon, lineupItem.type); // Spawn the ffmpeg process, fire this bitch up
plexTranscoder.startUpdatingPlex();
});
plexTranscoder.startUpdatingPlex();
});
})
router.get('/playlist', (req, res) => {
res.type('text')