commit
3661aa9aba
@ -1,4 +1,4 @@
|
||||
# dizqueTV 1.1.4
|
||||
# dizqueTV 1.2.3
|
||||
  
|
||||
|
||||
Create live TV channel streams from media on your Plex servers.
|
||||
|
||||
29
src/api.js
29
src/api.js
@ -8,6 +8,7 @@ const FFMPEGInfo = require('./ffmpeg-info');
|
||||
const PlexServerDB = require('./dao/plex-server-db');
|
||||
const Plex = require("./plex.js");
|
||||
const FillerDB = require('./dao/filler-db');
|
||||
const timeSlotsService = require('./services/time-slots-service');
|
||||
|
||||
module.exports = { router: api }
|
||||
function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
|
||||
@ -303,6 +304,10 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
|
||||
try {
|
||||
db['ffmpeg-settings'].update({ _id: req.body._id }, req.body)
|
||||
let ffmpeg = db['ffmpeg-settings'].find()[0]
|
||||
let err = fixupFFMPEGSettings(ffmpeg);
|
||||
if (typeof(err) !== 'undefined') {
|
||||
return res.status(400).send(err);
|
||||
}
|
||||
res.send(ffmpeg)
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
@ -323,6 +328,14 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
|
||||
|
||||
})
|
||||
|
||||
function fixupFFMPEGSettings(ffmpeg) {
|
||||
if (typeof(ffmpeg.maxFPS) === 'undefined') {
|
||||
ffmpeg.maxFPS = 60;
|
||||
} else if ( isNaN(ffmpeg.maxFPS) ) {
|
||||
return "maxFPS should be a number";
|
||||
}
|
||||
}
|
||||
|
||||
// PLEX SETTINGS
|
||||
router.get('/api/plex-settings', (req, res) => {
|
||||
try {
|
||||
@ -528,6 +541,20 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
|
||||
|
||||
})
|
||||
|
||||
//tool services
|
||||
router.post('/api/channel-tools/time-slots', async (req, res) => {
|
||||
try {
|
||||
let toolRes = await timeSlotsService(req.body.programs, req.body.schedule);
|
||||
if ( typeof(toolRes.userError) !=='undefined') {
|
||||
return res.status(400).send(toolRes.userError);
|
||||
}
|
||||
res.status(200).send(toolRes);
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
res.status(500).send("Internal error");
|
||||
}
|
||||
});
|
||||
|
||||
// CHANNELS.M3U Download
|
||||
router.get('/api/channels.m3u', async (req, res) => {
|
||||
try {
|
||||
@ -538,7 +565,7 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
|
||||
var data = `#EXTM3U url-tvg="${tvg}" x-tvg-url="${tvg}"\n`;
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
if (channels[i].stealth!==true) {
|
||||
data += `#EXTINF:0 tvg-id="${channels[i].number}" tvg-chno="${channels[i].number}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}" group-title="dizqueTV",${channels[i].name}\n`
|
||||
data += `#EXTINF:0 tvg-id="${channels[i].number}" CUID="${channels[i].number}" tvg-chno="${channels[i].number}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}" group-title="dizqueTV",${channels[i].name}\n`
|
||||
data += `${req.protocol}://${req.get('host')}/video?channel=${channels[i].number}\n`
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
module.exports = {
|
||||
SLACK: 9999,
|
||||
TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30*60*1000,
|
||||
STEALTH_DURATION: 5 * 60* 1000,
|
||||
DEFAULT_GUIDE_STEALTH_DURATION: 5 * 60* 1000,
|
||||
TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000,
|
||||
TOO_FREQUENT: 100,
|
||||
|
||||
VERSION_NAME: "1.1.4"
|
||||
VERSION_NAME: "1.2.3"
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
const path = require('path');
|
||||
var fs = require('fs');
|
||||
|
||||
const TARGET_VERSION = 600;
|
||||
const TARGET_VERSION = 701;
|
||||
|
||||
const STEPS = [
|
||||
// [v, v2, x] : if the current version is v, call x(db), and version becomes v2
|
||||
@ -31,6 +31,9 @@ const STEPS = [
|
||||
[ 400, 500, (db,channels) => splitServersSingleChannels(db, channels) ],
|
||||
[ 500, 501, (db) => fixCorruptedServer(db) ],
|
||||
[ 501, 600, () => extractFillersFromChannels() ],
|
||||
[ 600, 601, (db) => addFPS(db) ],
|
||||
[ 601, 700, (db) => migrateWatermark(db) ],
|
||||
[ 700, 701, (db) => addScalingAlgorithm(db) ],
|
||||
]
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
@ -392,6 +395,8 @@ function ffmpeg() {
|
||||
normalizeAudioCodec: true,
|
||||
normalizeResolution: true,
|
||||
normalizeAudio: true,
|
||||
maxFPS: 60,
|
||||
scalingAlgorithm: "bicubic",
|
||||
}
|
||||
}
|
||||
|
||||
@ -662,6 +667,95 @@ function extractFillersFromChannels() {
|
||||
|
||||
}
|
||||
|
||||
function addFPS(db) {
|
||||
let ffmpegSettings = db['ffmpeg-settings'].find()[0];
|
||||
let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json');
|
||||
ffmpegSettings.maxFPS = 60;
|
||||
fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) );
|
||||
}
|
||||
|
||||
function migrateWatermark(db, channelDB) {
|
||||
let ffmpegSettings = db['ffmpeg-settings'].find()[0];
|
||||
let w = 1920;
|
||||
let h = 1080;
|
||||
|
||||
function parseResolutionString(s) {
|
||||
var i = s.indexOf('x');
|
||||
if (i == -1) {
|
||||
i = s.indexOf("×");
|
||||
if (i == -1) {
|
||||
return {w:1920, h:1080}
|
||||
}
|
||||
}
|
||||
return {
|
||||
w: parseInt( s.substring(0,i) , 10 ),
|
||||
h: parseInt( s.substring(i+1) , 10 ),
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(ffmpegSettings.targetResolution != null)
|
||||
&& (typeof(ffmpegSettings.targetResolution) !== 'undefined')
|
||||
&& (typeof(ffmpegSettings.targetResolution) !== '')
|
||||
) {
|
||||
let p = parseResolutionString( ffmpegSettings.targetResolution );
|
||||
w = p.w;
|
||||
h = p.h;
|
||||
}
|
||||
console.log(`Using ${w}x${h} as resolution to migrate new watermark settings.`);
|
||||
function migrateChannel(channel) {
|
||||
if (channel.overlayIcon === true) {
|
||||
channel.watermark = {
|
||||
enabled: true,
|
||||
width: Math.max(0.001, Math.min(100, (channel.iconWidth*100) / w ) ),
|
||||
verticalMargin: Math.max(0.000, Math.min(100, 2000 / h ) ),
|
||||
horizontalMargin: Math.max(0.000, Math.min(100, 2000 / w ) ),
|
||||
duration: channel.iconDuration,
|
||||
fixedSize: false,
|
||||
position: [
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
][ channel.iconPosition ],
|
||||
url: '', //same as channel icon
|
||||
animated: false,
|
||||
}
|
||||
} else {
|
||||
channel.watermark = {
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
delete channel.overlayIcon;
|
||||
delete channel.iconDuration;
|
||||
delete channel.iconPosition;
|
||||
delete channel.iconWidth;
|
||||
return channel;
|
||||
}
|
||||
|
||||
console.log("Extracting fillers from channels...");
|
||||
let channels = path.join(process.env.DATABASE, 'channels');
|
||||
let channelFiles = fs.readdirSync(channels);
|
||||
for (let i = 0; i < channelFiles.length; i++) {
|
||||
if (path.extname( channelFiles[i] ) === '.json') {
|
||||
console.log("Migrating watermark in channel : " + channelFiles[i] +"..." );
|
||||
let channelPath = path.join(channels, channelFiles[i]);
|
||||
let channel = JSON.parse(fs.readFileSync(channelPath, 'utf-8'));
|
||||
channel = migrateChannel(channel);
|
||||
fs.writeFileSync( channelPath, JSON.stringify(channel), 'utf-8');
|
||||
}
|
||||
}
|
||||
console.log("Done migrating watermarks in channels.");
|
||||
}
|
||||
|
||||
function addScalingAlgorithm(db) {
|
||||
let ffmpegSettings = db['ffmpeg-settings'].find()[0];
|
||||
let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json');
|
||||
ffmpegSettings.scalingAlgorithm = "bicubic";
|
||||
fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) );
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
initDB: initDB,
|
||||
defaultFFMPEG: ffmpeg,
|
||||
|
||||
@ -15,7 +15,12 @@ class FFMPEGInfo {
|
||||
}
|
||||
});
|
||||
});
|
||||
return s.match( /version ([^\s]+) Copyright/ )[1];
|
||||
var m = s.match( /version\s+([^\s]+)\s+.*Copyright/ )
|
||||
if (m == null) {
|
||||
console.error("ffmpeg -version command output not in the expected format: " + s);
|
||||
return s;
|
||||
}
|
||||
return m[1];
|
||||
} catch (err) {
|
||||
console.error("Error getting ffmpeg version", err);
|
||||
return "Error";
|
||||
|
||||
131
src/ffmpeg.js
131
src/ffmpeg.js
@ -2,14 +2,16 @@ const spawn = require('child_process').spawn
|
||||
const events = require('events')
|
||||
|
||||
const MAXIMUM_ERROR_DURATION_MS = 60000;
|
||||
const REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE = 120;
|
||||
|
||||
class FFMPEG extends events.EventEmitter {
|
||||
constructor(opts, channel) {
|
||||
super()
|
||||
this.opts = opts;
|
||||
this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`;
|
||||
this.ffmpegName = "unnamed ffmpeg";
|
||||
if (! this.opts.enableFFMPEGTranscoding) {
|
||||
//this ensures transcoding is completely disabled even if
|
||||
//this ensures transcoding is completely disabled even if
|
||||
// some settings are true
|
||||
this.opts.normalizeAudio = false;
|
||||
this.opts.normalizeAudioCodec = false;
|
||||
@ -17,11 +19,40 @@ class FFMPEG extends events.EventEmitter {
|
||||
this.opts.errorScreen = 'kill';
|
||||
this.opts.normalizeResolution = false;
|
||||
this.opts.audioVolumePercent = 100;
|
||||
this.opts.maxFPS = REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE;
|
||||
}
|
||||
this.channel = channel
|
||||
this.ffmpegPath = opts.ffmpegPath
|
||||
|
||||
var parsed = parseResolutionString(opts.targetResolution);
|
||||
let resString = opts.targetResolution;
|
||||
if (
|
||||
(typeof(channel.transcoding) !== 'undefined')
|
||||
&& (channel.transcoding.targetResolution != null)
|
||||
&& (typeof(channel.transcoding.targetResolution) != 'undefined')
|
||||
&& (channel.transcoding.targetResolution != "")
|
||||
) {
|
||||
resString = channel.transcoding.targetResolution;
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof(channel.transcoding) !== 'undefined')
|
||||
&& (channel.transcoding.videoBitrate != null)
|
||||
&& (typeof(channel.transcoding.videoBitrate) != 'undefined')
|
||||
&& (channel.transcoding.videoBitrate != 0)
|
||||
) {
|
||||
opts.videoBitrate = channel.transcoding.videoBitrate;
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof(channel.transcoding) !== 'undefined')
|
||||
&& (channel.transcoding.videoBufSize != null)
|
||||
&& (typeof(channel.transcoding.videoBufSize) != 'undefined')
|
||||
&& (channel.transcoding.videoBufSize != 0)
|
||||
) {
|
||||
opts.videoBufSize = channel.transcoding.videoBufSize;
|
||||
}
|
||||
|
||||
let parsed = parseResolutionString(resString);
|
||||
this.wantedW = parsed.w;
|
||||
this.wantedH = parsed.h;
|
||||
|
||||
@ -71,7 +102,7 @@ class FFMPEG extends events.EventEmitter {
|
||||
};
|
||||
return await this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false);
|
||||
}
|
||||
async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) {
|
||||
async spawn(streamUrl, streamStats, startTime, duration, limitRead, watermark, type, isConcatPlaylist) {
|
||||
|
||||
let ffmpegArgs = [
|
||||
`-threads`, isConcatPlaylist? 1 : this.opts.threads,
|
||||
@ -108,7 +139,7 @@ class FFMPEG extends events.EventEmitter {
|
||||
// When we have an individual stream, there is a pipeline of possible
|
||||
// filters to apply.
|
||||
//
|
||||
var doOverlay = enableIcon;
|
||||
var doOverlay = ( (typeof(watermark)==='undefined') || (watermark != null) );
|
||||
var iW = streamStats.videoWidth;
|
||||
var iH = streamStats.videoHeight;
|
||||
|
||||
@ -129,6 +160,11 @@ class FFMPEG extends events.EventEmitter {
|
||||
// When adding filters, make sure that
|
||||
// videoComplex always begins wiht ; and doesn't end with ;
|
||||
|
||||
if ( streamStats.videoFramerate >= this.opts.maxFPS + 0.000001 ) {
|
||||
videoComplex += `;${currentVideo}fps=${this.opts.maxFPS}[fpchange]`;
|
||||
currentVideo ="[fpchange]";
|
||||
}
|
||||
|
||||
// prepare input streams
|
||||
if ( typeof(streamUrl.errorTitle) !== 'undefined') {
|
||||
doOverlay = false; //never show icon in the error screen
|
||||
@ -210,13 +246,17 @@ class FFMPEG extends events.EventEmitter {
|
||||
currentAudio = "[audiox]";
|
||||
}
|
||||
if (doOverlay) {
|
||||
ffmpegArgs.push(`-i`, `${this.channel.icon}` );
|
||||
if (watermark.animated === true) {
|
||||
ffmpegArgs.push('-ignore_loop', '0');
|
||||
}
|
||||
ffmpegArgs.push(`-i`, `${watermark.url}` );
|
||||
overlayFile = inputFiles++;
|
||||
this.ensureResolution = true;
|
||||
}
|
||||
|
||||
// Resolution fix: Add scale filter, current stream becomes [siz]
|
||||
let beforeSizeChange = currentVideo;
|
||||
let algo = "fast_bilinear";
|
||||
let algo = this.opts.scalingAlgorithm;
|
||||
let resizeMsg = "";
|
||||
if (
|
||||
(this.ensureResolution && ( streamStats.anamorphic || (iW != this.wantedW || iH != this.wantedH) ) )
|
||||
@ -263,26 +303,52 @@ class FFMPEG extends events.EventEmitter {
|
||||
currentVideo = "blackpadded";
|
||||
}
|
||||
let name = "siz";
|
||||
if (! this.ensureResolution) {
|
||||
if (! this.ensureResolution && (beforeSizeChange != '[fpchange]') ) {
|
||||
name = "minsiz";
|
||||
}
|
||||
videoComplex += `;[${currentVideo}]setsar=1[${name}]`;
|
||||
currentVideo = `[${name}]`;
|
||||
iW = this.wantedW;
|
||||
iH = this.wantedH;
|
||||
}
|
||||
|
||||
// Channel overlay:
|
||||
// Channel watermark:
|
||||
if (doOverlay) {
|
||||
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
|
||||
var pW =watermark.width;
|
||||
var w = Math.round( pW * iW / 100.0 );
|
||||
var mpHorz = watermark.horizontalMargin;
|
||||
var mpVert = watermark.verticalMargin;
|
||||
var horz = Math.round( mpHorz * iW / 100.0 );
|
||||
var vert = Math.round( mpVert * iH / 100.0 );
|
||||
|
||||
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 posAry = {
|
||||
'top-left': `x=${horz}:y=${vert}`,
|
||||
'top-right': `x=W-w-${horz}:y=${vert}`,
|
||||
'bottom-left': `x=${horz}:y=H-h-${vert}`,
|
||||
'bottom-right': `x=W-w-${horz}:y=H-h-${vert}`,
|
||||
}
|
||||
let icnDur = ''
|
||||
|
||||
if (this.channel.iconDuration > 0)
|
||||
icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
|
||||
|
||||
videoComplex += `;[${overlayFile}:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]`
|
||||
if (watermark.duration > 0) {
|
||||
icnDur = `:enable='between(t,0,${watermark.duration})'`
|
||||
}
|
||||
let waterVideo = `[${overlayFile}:v]`;
|
||||
if ( ! watermark.fixedSize) {
|
||||
videoComplex += `;${waterVideo}scale=${w}:-1[icn]`;
|
||||
waterVideo = '[icn]';
|
||||
}
|
||||
let p = posAry[watermark.position];
|
||||
if (typeof(p) === 'undefined') {
|
||||
throw Error("Invalid watermark position: " + watermark.position);
|
||||
}
|
||||
let overlayShortest = "";
|
||||
if (watermark.animated) {
|
||||
overlayShortest = "shortest=1:";
|
||||
}
|
||||
videoComplex += `;${currentVideo}${waterVideo}overlay=${overlayShortest}${p}${icnDur}[comb]`
|
||||
currentVideo = '[comb]';
|
||||
}
|
||||
|
||||
|
||||
if (this.volumePercent != 100) {
|
||||
var f = this.volumePercent / 100.0;
|
||||
audioComplex += `;${currentAudio}volume=${f}[boosted]`;
|
||||
@ -403,30 +469,45 @@ class FFMPEG extends events.EventEmitter {
|
||||
ffmpegArgs.push(`pipe:1`)
|
||||
|
||||
let doLogs = this.opts.logFfmpeg && !isConcatPlaylist;
|
||||
if (this.hasBeenKilled) {
|
||||
return ;
|
||||
}
|
||||
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } );
|
||||
if (this.hasBeenKilled) {
|
||||
this.ffmpeg.kill("SIGKILL");
|
||||
return;
|
||||
}
|
||||
|
||||
let ffmpegName = (isConcatPlaylist ? "Concat FFMPEG": "Stream FFMPEG");
|
||||
|
||||
this.ffmpegName = (isConcatPlaylist ? "Concat FFMPEG": "Stream FFMPEG");
|
||||
|
||||
this.ffmpeg.on('error', (code, signal) => {
|
||||
console.log( `${this.ffmpegName} received error event: ${code}, ${signal}` );
|
||||
});
|
||||
this.ffmpeg.on('exit', (code, signal) => {
|
||||
if (code === null) {
|
||||
console.log( `${ffmpegName} exited due to signal: ${signal}` );
|
||||
if (!this.hasBeenKilled) {
|
||||
console.log( `${this.ffmpegName} exited due to signal: ${signal}` );
|
||||
} else {
|
||||
console.log( `${this.ffmpegName} exited due to signal: ${signal} as expected.`);
|
||||
}
|
||||
this.emit('close', code)
|
||||
} else if (code === 0) {
|
||||
console.log( `${ffmpegName} exited normally.` );
|
||||
console.log( `${this.ffmpegName} exited normally.` );
|
||||
this.emit('end')
|
||||
} else if (code === 255) {
|
||||
if (this.hasBeenKilled) {
|
||||
console.log( `${ffmpegName} finished with code 255.` );
|
||||
console.log( `${this.ffmpegName} finished with code 255.` );
|
||||
this.emit('close', code)
|
||||
return;
|
||||
}
|
||||
if (! this.sentData) {
|
||||
this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` })
|
||||
}
|
||||
console.log( `${ffmpegName} exited with code 255.` );
|
||||
console.log( `${this.ffmpegName} exited with code 255.` );
|
||||
this.emit('close', code)
|
||||
} else {
|
||||
console.log( `${ffmpegName} exited with code ${code}.` );
|
||||
console.log( `${this.ffmpegName} exited with code ${code}.` );
|
||||
this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` })
|
||||
}
|
||||
});
|
||||
@ -434,9 +515,11 @@ class FFMPEG extends events.EventEmitter {
|
||||
return this.ffmpeg.stdout;
|
||||
}
|
||||
kill() {
|
||||
if (typeof this.ffmpeg != "undefined") {
|
||||
this.hasBeenKilled = true;
|
||||
this.ffmpeg.kill()
|
||||
console.log(`${this.ffmpegName} RECEIVED kill() command`);
|
||||
this.hasBeenKilled = true;
|
||||
if (typeof(this.ffmpeg) != "undefined") {
|
||||
console.log(`${this.ffmpegName} this.ffmpeg.kill()`);
|
||||
this.ffmpeg.kill("SIGKILL")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed,
|
||||
createLineup: createLineup,
|
||||
isChannelIconEnabled: isChannelIconEnabled,
|
||||
getWatermark: getWatermark,
|
||||
}
|
||||
|
||||
let channelCache = require('./channel-cache');
|
||||
@ -10,6 +10,8 @@ const randomJS = require("random-js");
|
||||
const Random = randomJS.Random;
|
||||
const random = new Random( randomJS.MersenneTwister19937.autoSeed() );
|
||||
|
||||
module.exports.random = random;
|
||||
|
||||
function getCurrentProgramAndTimeElapsed(date, channel) {
|
||||
let channelStartTime = (new Date(channel.startTime)).getTime();
|
||||
if (channelStartTime > date) {
|
||||
@ -251,19 +253,44 @@ function pickRandomWithMaxDuration(channel, fillers, maxDuration) {
|
||||
}
|
||||
}
|
||||
|
||||
function isChannelIconEnabled( ffmpegSettings, channel, type) {
|
||||
function getWatermark( ffmpegSettings, channel, type) {
|
||||
if (! ffmpegSettings.enableFFMPEGTranscoding || ffmpegSettings.disableChannelOverlay ) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
let d = channel.disableFillerOverlay;
|
||||
if (typeof(d) === 'undefined') {
|
||||
d = true;
|
||||
}
|
||||
if ( (typeof type !== `undefined`) && (type == 'commercial') && d ) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
if (channel.icon === '' || !channel.overlayIcon) {
|
||||
return false;
|
||||
let e = false;
|
||||
let icon = undefined;
|
||||
let watermark = {};
|
||||
if (typeof(channel.watermark) !== 'undefined') {
|
||||
watermark = channel.watermark;
|
||||
e = (watermark.enabled === true);
|
||||
icon = watermark.url;
|
||||
}
|
||||
return true;
|
||||
if (! e) {
|
||||
return null;
|
||||
}
|
||||
if ( (typeof(icon) === 'undefined') || (icon === '') ) {
|
||||
icon = channel.icon;
|
||||
if ( (typeof(icon) === 'undefined') || (icon === '') ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let result = {
|
||||
url: icon,
|
||||
width: watermark.width,
|
||||
verticalMargin: watermark.verticalMargin,
|
||||
horizontalMargin: watermark.horizontalMargin,
|
||||
duration: watermark.duration,
|
||||
position: watermark.position,
|
||||
fixedSize: (watermark.fixedSize === true),
|
||||
animated: (watermark.animated === true),
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@ class PlexPlayer {
|
||||
let plexSettings = db['plex-settings'].find()[0];
|
||||
let plexTranscoder = new PlexTranscoder(this.clientId, server, plexSettings, channel, lineupItem);
|
||||
this.plexTranscoder = plexTranscoder;
|
||||
let enableChannelIcon = this.context.enableChannelIcon;
|
||||
let watermark = this.context.watermark;
|
||||
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
|
||||
this.ffmpeg = ffmpeg;
|
||||
let streamDuration;
|
||||
@ -84,7 +84,7 @@ class PlexPlayer {
|
||||
|
||||
let emitter = new EventEmitter();
|
||||
//setTimeout( () => {
|
||||
let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, enableChannelIcon, lineupItem.type); // Spawn the ffmpeg process
|
||||
let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, watermark, lineupItem.type); // Spawn the ffmpeg process
|
||||
ff.pipe(outStream, {'end':false} );
|
||||
//}, 100);
|
||||
plexTranscoder.startUpdatingPlex();
|
||||
|
||||
@ -51,7 +51,7 @@ class ProgramPlayer {
|
||||
/* plex */
|
||||
this.delegate = new PlexPlayer(context);
|
||||
}
|
||||
this.context.enableChannelIcon = helperFuncs.isChannelIconEnabled( context.ffmpegSettings, context.channel, context.lineupItem.type);
|
||||
this.context.watermark = helperFuncs.getWatermark( context.ffmpegSettings, context.channel, context.lineupItem.type);
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
|
||||
466
src/services/time-slots-service.js
Normal file
466
src/services/time-slots-service.js
Normal file
@ -0,0 +1,466 @@
|
||||
const constants = require("../constants");
|
||||
|
||||
const random = require('../helperFuncs').random;
|
||||
|
||||
const MINUTE = 60*1000;
|
||||
const DAY = 24*60*MINUTE;
|
||||
const LIMIT = 40000;
|
||||
|
||||
|
||||
|
||||
//This is a triplicate code, but maybe it doesn't have to be?
|
||||
function getShow(program) {
|
||||
//used for equalize and frequency tweak
|
||||
if (program.isOffline) {
|
||||
if (program.type == 'redirect') {
|
||||
return {
|
||||
description : `Redirect to channel ${program.channel}`,
|
||||
id: "redirect." + program.channel,
|
||||
channel: program.channel,
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) {
|
||||
return {
|
||||
description: program.showTitle,
|
||||
id: "tv." + program.showTitle,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
description: "Movies",
|
||||
id: "movie.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function shuffle(array, lo, hi ) {
|
||||
if (typeof(lo) === 'undefined') {
|
||||
lo = 0;
|
||||
hi = array.length;
|
||||
}
|
||||
let currentIndex = hi, temporaryValue, randomIndex
|
||||
while (lo !== currentIndex) {
|
||||
randomIndex = random.integer(lo, currentIndex-1);
|
||||
currentIndex -= 1
|
||||
temporaryValue = array[currentIndex]
|
||||
array[currentIndex] = array[randomIndex]
|
||||
array[randomIndex] = temporaryValue
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
function _wait(t) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, t);
|
||||
});
|
||||
}
|
||||
|
||||
function getProgramId(program) {
|
||||
let s = program.serverKey;
|
||||
if (typeof(s) === 'undefined') {
|
||||
s = 'unknown';
|
||||
}
|
||||
let p = program.key;
|
||||
if (typeof(p) === 'undefined') {
|
||||
p = 'unknown';
|
||||
}
|
||||
return s + "|" + p;
|
||||
}
|
||||
|
||||
function addProgramToShow(show, program) {
|
||||
if ( (show.id == 'flex.') || show.id.startsWith("redirect.") ) {
|
||||
//nothing to do
|
||||
return;
|
||||
}
|
||||
let id = getProgramId(program)
|
||||
if(show.programs[id] !== true) {
|
||||
show.programs.push(program);
|
||||
show.programs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
function getShowOrderer(show) {
|
||||
if (typeof(show.orderer) === 'undefined') {
|
||||
|
||||
let sortedPrograms = JSON.parse( JSON.stringify(show.programs) );
|
||||
sortedPrograms.sort((a, b) => {
|
||||
if (a.season === b.season) {
|
||||
if (a.episode > b.episode) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
} else if (a.season > b.season) {
|
||||
return 1;
|
||||
} else if (b.season > a.season) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
});
|
||||
|
||||
let position = 0;
|
||||
while (
|
||||
(position + 1 < sortedPrograms.length )
|
||||
&&
|
||||
(
|
||||
show.founder.season !== sortedPrograms[position].season
|
||||
||
|
||||
show.founder.episode !== sortedPrograms[position].episode
|
||||
)
|
||||
) {
|
||||
position++;
|
||||
}
|
||||
|
||||
|
||||
show.orderer = {
|
||||
|
||||
current : () => {
|
||||
return sortedPrograms[position];
|
||||
},
|
||||
|
||||
next: () => {
|
||||
position = (position + 1) % sortedPrograms.length;
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
return show.orderer;
|
||||
}
|
||||
|
||||
|
||||
function getShowShuffler(show) {
|
||||
if (typeof(show.shuffler) === 'undefined') {
|
||||
if (typeof(show.programs) === 'undefined') {
|
||||
throw Error(show.id + " has no programs?")
|
||||
}
|
||||
|
||||
let randomPrograms = JSON.parse( JSON.stringify(show.programs) );
|
||||
let n = randomPrograms.length;
|
||||
shuffle( randomPrograms, 0, n);
|
||||
let position = 0;
|
||||
|
||||
show.shuffler = {
|
||||
|
||||
current : () => {
|
||||
return randomPrograms[position];
|
||||
},
|
||||
|
||||
next: () => {
|
||||
position++;
|
||||
if (position == n) {
|
||||
let a = Math.floor(n / 2);
|
||||
shuffle(randomPrograms, 0, a );
|
||||
shuffle(randomPrograms, a, n );
|
||||
position = 0;
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
return show.shuffler;
|
||||
}
|
||||
|
||||
module.exports = async( programs, schedule ) => {
|
||||
if (! Array.isArray(programs) ) {
|
||||
return { userError: 'Expected a programs array' };
|
||||
}
|
||||
if (typeof(schedule) === 'undefined') {
|
||||
return { userError: 'Expected a schedule' };
|
||||
}
|
||||
if (typeof(schedule.timeZoneOffset) === 'undefined') {
|
||||
return { userError: 'Expected a time zone offset' };
|
||||
}
|
||||
//verify that the schedule is in the correct format
|
||||
if (! Array.isArray(schedule.slots) ) {
|
||||
return { userError: 'Expected a "slots" array in schedule' };
|
||||
}
|
||||
for (let i = 0; i < schedule.slots.length; i++) {
|
||||
if (typeof(schedule.slots[i].time) === 'undefined') {
|
||||
return { userError: "Each slot should have a time" };
|
||||
}
|
||||
if (typeof(schedule.slots[i].showId) === 'undefined') {
|
||||
return { userError: "Each slot should have a showId" };
|
||||
}
|
||||
if (
|
||||
(schedule.slots[i].time < 0)
|
||||
|| (schedule.slots[i].time >= DAY)
|
||||
|| (Math.floor(schedule.slots[i].time) != schedule.slots[i].time)
|
||||
) {
|
||||
return { userError: "Slot times should be a integer number of milliseconds since the start of the day." };
|
||||
}
|
||||
schedule.slots[i].time = ( schedule.slots[i].time + 10*DAY + schedule.timeZoneOffset*MINUTE) % DAY;
|
||||
}
|
||||
schedule.slots.sort( (a,b) => {
|
||||
return (a.time - b.time);
|
||||
} );
|
||||
for (let i = 1; i < schedule.slots.length; i++) {
|
||||
if (schedule.slots[i].time == schedule.slots[i-1].time) {
|
||||
return { userError: "Slot times should be unique."};
|
||||
}
|
||||
}
|
||||
if (typeof(schedule.pad) === 'undefined') {
|
||||
return { userError: "Expected schedule.pad" };
|
||||
}
|
||||
|
||||
if (typeof(schedule.lateness) == 'undefined') {
|
||||
return { userError: "schedule.lateness must be defined." };
|
||||
}
|
||||
if (typeof(schedule.maxDays) == 'undefined') {
|
||||
return { userError: "schedule.maxDays must be defined." };
|
||||
}
|
||||
if (typeof(schedule.flexPreference) === 'undefined') {
|
||||
schedule.flexPreference = "distribute";
|
||||
}
|
||||
if (schedule.flexPreference !== "distribute" && schedule.flexPreference !== "end") {
|
||||
return { userError: `Invalid schedule.flexPreference value: "${schedule.flexPreference}"` };
|
||||
}
|
||||
let flexBetween = ( schedule.flexPreference !== "end" );
|
||||
|
||||
// throttle so that the stream is not affected negatively
|
||||
let steps = 0;
|
||||
let throttle = async() => {
|
||||
if (steps++ == 10) {
|
||||
steps = 0;
|
||||
await _wait(1);
|
||||
}
|
||||
}
|
||||
|
||||
let showsById = {};
|
||||
let shows = [];
|
||||
|
||||
function getNextForSlot(slot, remaining) {
|
||||
//remaining doesn't restrict what next show is picked. It is only used
|
||||
//for shows with flexible length (flex and redirects)
|
||||
if (slot.showId === "flex.") {
|
||||
return {
|
||||
isOffline: true,
|
||||
duration: remaining,
|
||||
}
|
||||
}
|
||||
let show = shows[ showsById[slot.showId] ];
|
||||
if (slot.showId.startsWith("redirect.")) {
|
||||
return {
|
||||
isOffline: true,
|
||||
type: "redirect",
|
||||
duration: remaining,
|
||||
channel: show.channel,
|
||||
}
|
||||
} else if (slot.order === 'shuffle') {
|
||||
return getShowShuffler(show).current();
|
||||
} else if (slot.order === 'next') {
|
||||
return getShowOrderer(show).current();
|
||||
}
|
||||
}
|
||||
|
||||
function advanceSlot(slot) {
|
||||
if ( (slot.showId === "flex.") || (slot.showId.startsWith("redirect") ) ) {
|
||||
return;
|
||||
}
|
||||
let show = shows[ showsById[slot.showId] ];
|
||||
if (slot.order === 'shuffle') {
|
||||
return getShowShuffler(show).next();
|
||||
} else if (slot.order === 'next') {
|
||||
return getShowOrderer(show).next();
|
||||
}
|
||||
}
|
||||
|
||||
function makePadded(item) {
|
||||
let x = item.duration;
|
||||
let m = x % schedule.pad;
|
||||
let f = 0;
|
||||
if ( (m > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) {
|
||||
f = schedule.pad - m;
|
||||
}
|
||||
return {
|
||||
item: item,
|
||||
pad: f,
|
||||
totalDuration: item.duration + f,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// load the programs
|
||||
for (let i = 0; i < programs.length; i++) {
|
||||
let p = programs[i];
|
||||
let show = getShow(p);
|
||||
if (show != null) {
|
||||
if (typeof(showsById[show.id] ) === 'undefined') {
|
||||
showsById[show.id] = shows.length;
|
||||
shows.push( show );
|
||||
show.founder = p;
|
||||
show.programs = [];
|
||||
} else {
|
||||
show = shows[ showsById[show.id] ];
|
||||
}
|
||||
addProgramToShow( show, p );
|
||||
}
|
||||
}
|
||||
|
||||
let s = schedule.slots;
|
||||
let d = (new Date() );
|
||||
d.setUTCMilliseconds(0);
|
||||
d.setUTCSeconds(0);
|
||||
d.setUTCMinutes(0);
|
||||
d.setUTCHours(0);
|
||||
d.setUTCMilliseconds( s[0].time );
|
||||
let t0 = d.getTime();
|
||||
let p = [];
|
||||
let t = t0;
|
||||
let wantedFinish = t % DAY;
|
||||
let hardLimit = t0 + schedule.maxDays * DAY;
|
||||
|
||||
let pushFlex = (d) => {
|
||||
if (d > 0) {
|
||||
t += d;
|
||||
if ( (p.length > 0) && p[p.length-1].isOffline && (p[p.length-1].type != 'redirect') ) {
|
||||
p[p.length-1].duration += d;
|
||||
} else {
|
||||
p.push( {
|
||||
duration: d,
|
||||
isOffline : true,
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while ( (t < hardLimit) && (p.length < LIMIT) ) {
|
||||
await throttle();
|
||||
//ensure t is padded
|
||||
let m = t % schedule.pad;
|
||||
if ( (t % schedule.pad > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) {
|
||||
pushFlex( schedule.pad - m );
|
||||
continue;
|
||||
}
|
||||
|
||||
let dayTime = t % DAY;
|
||||
let slot = null;
|
||||
let remaining = null;
|
||||
let late = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
let endTime;
|
||||
if (i == s.length - 1) {
|
||||
endTime = s[0].time + DAY;
|
||||
} else {
|
||||
endTime = s[i+1].time;
|
||||
}
|
||||
|
||||
if ((s[i].time <= dayTime) && (dayTime < endTime)) {
|
||||
slot = s[i];
|
||||
remaining = endTime - dayTime;
|
||||
late = dayTime - s[i].time;
|
||||
break;
|
||||
}
|
||||
if ((s[i].time <= dayTime + DAY) && (dayTime + DAY < endTime)) {
|
||||
slot = s[i];
|
||||
dayTime += DAY;
|
||||
remaining = endTime - dayTime;
|
||||
late = dayTime + DAY - s[i].time;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot == null) {
|
||||
throw Error("Unexpected. Unable to find slot for time of day " + t + " " + dayTime);
|
||||
}
|
||||
let item = getNextForSlot(slot, remaining);
|
||||
|
||||
if (late >= schedule.lateness + constants.SLACK ) {
|
||||
//it's late.
|
||||
item = {
|
||||
isOffline : true,
|
||||
duration: remaining,
|
||||
}
|
||||
}
|
||||
|
||||
if (item.isOffline) {
|
||||
//flex or redirect. We can just use the whole duration
|
||||
p.push(item);
|
||||
t += remaining;
|
||||
continue;
|
||||
}
|
||||
if (item.duration > remaining) {
|
||||
// Slide
|
||||
p.push(item);
|
||||
t += item.duration;
|
||||
advanceSlot(slot);
|
||||
continue;
|
||||
}
|
||||
|
||||
let padded = makePadded(item);
|
||||
let total = padded.totalDuration;
|
||||
advanceSlot(slot);
|
||||
let pads = [ padded ];
|
||||
|
||||
while(true) {
|
||||
let item2 = getNextForSlot(slot);
|
||||
if (total + item2.duration > remaining) {
|
||||
break;
|
||||
}
|
||||
let padded2 = makePadded(item2);
|
||||
pads.push(padded2);
|
||||
advanceSlot(slot);
|
||||
total += padded2.totalDuration;
|
||||
}
|
||||
let rem = Math.max(0, remaining - total);
|
||||
|
||||
if (flexBetween) {
|
||||
let div = Math.floor(rem / schedule.pad );
|
||||
let mod = rem % schedule.pad;
|
||||
// add mod to the latest item
|
||||
pads[ pads.length - 1].pad += mod;
|
||||
pads[ pads.length - 1].totalDuration += mod;
|
||||
|
||||
let sortedPads = pads.map( (p, $index) => {
|
||||
return {
|
||||
pad: p.pad,
|
||||
index : $index,
|
||||
}
|
||||
});
|
||||
sortedPads.sort( (a,b) => { return a.pad - b.pad; } );
|
||||
for (let i = 0; i < pads.length; i++) {
|
||||
let q = Math.floor( div / pads.length );
|
||||
if (i < div % pads.length) {
|
||||
q++;
|
||||
}
|
||||
let j = sortedPads[i].index;
|
||||
pads[j].pad += q * schedule.pad;
|
||||
}
|
||||
} else {
|
||||
//also add div to the latest item
|
||||
pads[ pads.length - 1].pad += rem;
|
||||
pads[ pads.length - 1].totalDuration += rem;
|
||||
}
|
||||
// now unroll them all
|
||||
for (let i = 0; i < pads.length; i++) {
|
||||
p.push( pads[i].item );
|
||||
t += pads[i].item.duration;
|
||||
pushFlex( pads[i].pad );
|
||||
}
|
||||
}
|
||||
while ( (t > hardLimit) || (p.length >= LIMIT) ) {
|
||||
t -= p.pop().duration;
|
||||
}
|
||||
let m = t % DAY;
|
||||
let rem = 0;
|
||||
if (m > wantedFinish) {
|
||||
rem = DAY + wantedFinish - m;
|
||||
} else if (m < wantedFinish) {
|
||||
rem = wantedFinish - m;
|
||||
}
|
||||
if (rem > constants.SLACK) {
|
||||
pushFlex(rem);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
programs: p,
|
||||
startTime: (new Date(t0)).toISOString(),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -185,10 +185,10 @@ class TVGuideService
|
||||
await this._throttle();
|
||||
if (
|
||||
(programs.length > 0)
|
||||
&& isProgramFlex(x.program)
|
||||
&& isProgramFlex(x.program, channel)
|
||||
&& (
|
||||
(x.program.duration <= constants.TVGUIDE_MAXIMUM_PADDING_LENGTH_MS)
|
||||
|| isProgramFlex(programs[ programs.length - 1].program)
|
||||
|| isProgramFlex(programs[ programs.length - 1].program, channel)
|
||||
)
|
||||
) {
|
||||
//meld with previous
|
||||
@ -197,7 +197,7 @@ class TVGuideService
|
||||
melded += x.program.duration;
|
||||
if (
|
||||
(melded > constants.TVGUIDE_MAXIMUM_PADDING_LENGTH_MS)
|
||||
&& !isProgramFlex(programs[ programs.length - 1].program)
|
||||
&& !isProgramFlex(programs[ programs.length - 1].program, channel)
|
||||
) {
|
||||
y.program.duration -= melded;
|
||||
programs[ programs.length - 1] = y;
|
||||
@ -214,7 +214,7 @@ class TVGuideService
|
||||
} else {
|
||||
programs[ programs.length - 1] = y;
|
||||
}
|
||||
} else if (isProgramFlex(x.program) ) {
|
||||
} else if (isProgramFlex(x.program, channel) ) {
|
||||
melded = 0;
|
||||
programs.push( {
|
||||
start: x.start,
|
||||
@ -238,12 +238,14 @@ class TVGuideService
|
||||
x.program = clone(x.program);
|
||||
x.program.duration -= d;
|
||||
}
|
||||
if (x.program.duration == 0) throw Error("D");
|
||||
if (x.program.duration == 0) {
|
||||
console.error("There's a program with duration 0?");
|
||||
}
|
||||
}
|
||||
result.programs = [];
|
||||
for (let i = 0; i < programs.length; i++) {
|
||||
await this._throttle();
|
||||
if (isProgramFlex( programs[i].program) ) {
|
||||
if (isProgramFlex( programs[i].program, channel) ) {
|
||||
let start = programs[i].start;
|
||||
let duration = programs[i].program.duration;
|
||||
if (start <= t0) {
|
||||
@ -412,10 +414,21 @@ function _wait(t) {
|
||||
}
|
||||
|
||||
|
||||
function getChannelStealthDuration(channel) {
|
||||
if (
|
||||
(typeof(channel.guideMinimumDurationSeconds) !== 'undefined')
|
||||
&&
|
||||
! isNaN(channel.guideMinimumDurationSeconds)
|
||||
) {
|
||||
return channel.guideMinimumDurationSeconds * 1000;
|
||||
} else {
|
||||
return constants.DEFAULT_GUIDE_STEALTH_DURATION;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
function isProgramFlex(program) {
|
||||
return program.isOffline || program.duration <= constants.STEALTH_DURATION
|
||||
function isProgramFlex(program, channel) {
|
||||
return program.isOffline || program.duration <= getChannelStealthDuration(channel)
|
||||
}
|
||||
|
||||
function clone(o) {
|
||||
@ -434,8 +447,13 @@ function makeEntry(channel, x) {
|
||||
let title = undefined;
|
||||
let icon = undefined;
|
||||
let sub = undefined;
|
||||
if (isProgramFlex(x.program)) {
|
||||
title = channel.name;
|
||||
if (isProgramFlex(x.program, channel)) {
|
||||
if ( (typeof(channel.guideFlexPlaceholder) === 'string')
|
||||
&& channel.guideFlexPlaceholder !== "") {
|
||||
title = channel.guideFlexPlaceholder;
|
||||
} else {
|
||||
title = channel.name;
|
||||
}
|
||||
icon = channel.icon;
|
||||
} else {
|
||||
title = x.program.showTitle;
|
||||
@ -473,4 +491,4 @@ function formatDateYYYYMMDD(date) {
|
||||
return year + "-" + month + "-" + day;
|
||||
}
|
||||
|
||||
module.exports = TVGuideService
|
||||
module.exports = TVGuideService
|
||||
|
||||
@ -287,11 +287,13 @@ function video( channelDB , fillerDB, db) {
|
||||
};
|
||||
}
|
||||
|
||||
let combinedChannel = JSON.parse( JSON.stringify(brandChannel) );
|
||||
combinedChannel.transcoding = channel.transcoding;
|
||||
|
||||
let playerContext = {
|
||||
lineupItem : lineupItem,
|
||||
ffmpegSettings : ffmpegSettings,
|
||||
channel: brandChannel,
|
||||
channel: combinedChannel,
|
||||
db: db,
|
||||
m3u8: m3u8,
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ var app = angular.module('myApp', ['ngRoute', 'vs-repeat', 'angularLazyImg', 'dn
|
||||
|
||||
app.service('plex', require('./services/plex'))
|
||||
app.service('dizquetv', require('./services/dizquetv'))
|
||||
app.service('resolutionOptions', require('./services/resolution-options'))
|
||||
|
||||
app.directive('plexSettings', require('./directives/plex-settings'))
|
||||
app.directive('ffmpegSettings', require('./directives/ffmpeg-settings'))
|
||||
@ -24,6 +25,7 @@ app.directive('removeShows', require('./directives/remove-shows'))
|
||||
app.directive('channelRedirect', require('./directives/channel-redirect'))
|
||||
app.directive('plexServerEdit', require('./directives/plex-server-edit'))
|
||||
app.directive('channelConfig', require('./directives/channel-config'))
|
||||
app.directive('timeSlotsScheduleEditor', require('./directives/time-slots-schedule-editor'))
|
||||
|
||||
app.controller('settingsCtrl', require('./controllers/settings'))
|
||||
app.controller('channelsCtrl', require('./controllers/channels'))
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
module.exports = function ($timeout, $location, dizquetv) {
|
||||
module.exports = function ($timeout, $location, dizquetv, resolutionOptions) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/channel-config.html',
|
||||
@ -9,24 +9,55 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
channel: "=channel",
|
||||
onDone: "=onDone"
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
link: {
|
||||
|
||||
post: function (scope, element, attrs) {
|
||||
scope.screenW = 1920;
|
||||
scope.screenh = 1080;
|
||||
|
||||
scope.maxSize = 50000;
|
||||
|
||||
scope.programming = {
|
||||
maxHeight: 30,
|
||||
step : 1,
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
let h = parseFloat( localStorage.getItem("channel-programming-list-height" ) );
|
||||
if (isNaN(h)) {
|
||||
h = 30;
|
||||
}
|
||||
h = Math.min(64, Math.max(1, h));
|
||||
console.log("loaded=" + h);
|
||||
scope.programming.maxHeight = h;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
scope.blockCount = 1;
|
||||
scope.showShuffleOptions = (localStorage.getItem("channel-tools") === "on");
|
||||
scope.reverseTools = (localStorage.getItem("channel-tools-position") === "left");
|
||||
|
||||
scope.hasFlex = false;
|
||||
scope.showHelp = false;
|
||||
scope.showHelp = { check: false }
|
||||
scope._frequencyModified = false;
|
||||
scope._frequencyMessage = "";
|
||||
scope.minProgramIndex = 0;
|
||||
scope.libraryLimit = 50000;
|
||||
scope.displayPlexLibrary = false;
|
||||
scope.episodeMemory = {
|
||||
saved : false,
|
||||
};
|
||||
if (typeof scope.channel === 'undefined' || scope.channel == null) {
|
||||
scope.channel = {}
|
||||
scope.channel.programs = []
|
||||
scope.channel.watermark = defaultWatermark();
|
||||
scope.channel.fillerCollections = []
|
||||
scope.channel.guideFlexPlaceholder = "";
|
||||
scope.channel.fillerRepeatCooldown = 30 * 60 * 1000;
|
||||
scope.channel.fallback = [];
|
||||
scope.channel.guideMinimumDurationSeconds = 5 * 60;
|
||||
scope.isNewChannel = true
|
||||
scope.channel.icon = `${$location.protocol()}://${location.host}/images/dizquetv.png`
|
||||
scope.channel.disableFillerOverlay = true;
|
||||
@ -51,26 +82,16 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
scope.channel.name = "Channel 1"
|
||||
}
|
||||
scope.showRotatedNote = false;
|
||||
scope.channel.transcoding = {
|
||||
targetResolution: "",
|
||||
}
|
||||
} else {
|
||||
scope.beforeEditChannelNumber = scope.channel.number
|
||||
let t = Date.now();
|
||||
let originalStart = scope.channel.startTime.getTime();
|
||||
let n = scope.channel.programs.length;
|
||||
let totalDuration = scope.channel.duration;
|
||||
let m = (t - originalStart) % totalDuration;
|
||||
let x = 0;
|
||||
let runningProgram = -1;
|
||||
let offset = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let d = scope.channel.programs[i].duration;
|
||||
if (x + d > m) {
|
||||
runningProgram = i
|
||||
offset = m - x;
|
||||
break;
|
||||
} else {
|
||||
x += d;
|
||||
}
|
||||
|
||||
if (typeof(scope.channel.watermark) === 'undefined') {
|
||||
scope.channel.watermark = defaultWatermark();
|
||||
}
|
||||
|
||||
if (typeof(scope.channel.fillerRepeatCooldown) === 'undefined') {
|
||||
scope.channel.fillerRepeatCooldown = 30 * 60 * 1000;
|
||||
}
|
||||
@ -91,14 +112,121 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
if (typeof(scope.channel.disableFillerOverlay) === 'undefined') {
|
||||
scope.channel.disableFillerOverlay = true;
|
||||
}
|
||||
scope.channel.startTime = new Date(t - offset);
|
||||
// move runningProgram to index 0
|
||||
scope.channel.programs = scope.channel.programs.slice(runningProgram, this.length)
|
||||
.concat(scope.channel.programs.slice(0, runningProgram) );
|
||||
if (
|
||||
(typeof(scope.channel.guideMinimumDurationSeconds) === 'undefined')
|
||||
|| isNaN(scope.channel.guideMinimumDurationSeconds)
|
||||
) {
|
||||
scope.channel.guideMinimumDurationSeconds = 5 * 60;
|
||||
}
|
||||
|
||||
if (typeof(scope.channel.transcoding) ==='undefined') {
|
||||
scope.channel.transcoding = {};
|
||||
}
|
||||
if (
|
||||
(scope.channel.transcoding.targetResolution == null)
|
||||
|| (typeof(scope.channel.transcoding.targetResolution) === 'undefined')
|
||||
|| (scope.channel.transcoding.targetResolution === '')
|
||||
) {
|
||||
scope.channel.transcoding.targetResolution = "";
|
||||
}
|
||||
|
||||
|
||||
adjustStartTimeToCurrentProgram();
|
||||
updateChannelDuration();
|
||||
setTimeout( () => { scope.showRotatedNote = true }, 1, 'funky');
|
||||
}
|
||||
|
||||
function defaultWatermark() {
|
||||
return {
|
||||
enabled: false,
|
||||
position: "bottom-right",
|
||||
width: 10.00,
|
||||
verticalMargin: 0.00,
|
||||
horizontalMargin: 0.00,
|
||||
duration: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function adjustStartTimeToCurrentProgram() {
|
||||
let t = Date.now();
|
||||
let originalStart = scope.channel.startTime.getTime();
|
||||
let n = scope.channel.programs.length;
|
||||
let totalDuration = scope.channel.duration;
|
||||
let m = (t - originalStart) % totalDuration;
|
||||
let x = 0;
|
||||
let runningProgram = -1;
|
||||
let offset = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let d = scope.channel.programs[i].duration;
|
||||
if (x + d > m) {
|
||||
runningProgram = i
|
||||
offset = m - x;
|
||||
break;
|
||||
} else {
|
||||
x += d;
|
||||
}
|
||||
}
|
||||
// move runningProgram to index 0
|
||||
scope.channel.programs = scope.channel.programs.slice(runningProgram)
|
||||
.concat(scope.channel.programs.slice(0, runningProgram) );
|
||||
scope.channel.startTime = new Date(t - offset);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
let addMinuteVersionsOfFields = () => {
|
||||
//add the minutes versions of the cooldowns:
|
||||
scope.channel.fillerRepeatCooldownMinutes = scope.channel.fillerRepeatCooldown / 1000 / 60;
|
||||
for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
|
||||
scope.channel.fillerCollections[i].cooldownMinutes = scope.channel.fillerCollections[i].cooldown / 1000 / 60;
|
||||
|
||||
}
|
||||
}
|
||||
addMinuteVersionsOfFields();
|
||||
|
||||
let removeMinuteVersionsOfFields = (channel) => {
|
||||
channel.fillerRepeatCooldown = channel.fillerRepeatCooldownMinutes * 60 * 1000;
|
||||
delete channel.fillerRepeatCooldownMinutes;
|
||||
for (let i = 0; i < channel.fillerCollections.length; i++) {
|
||||
channel.fillerCollections[i].cooldown = channel.fillerCollections[i].cooldownMinutes * 60 * 1000;
|
||||
delete channel.fillerCollections[i].cooldownMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
scope.tabOptions = [
|
||||
{ name: "Properties", id: "basic" },
|
||||
{ name: "Programming", id: "programming" },
|
||||
{ name: "Flex", id: "flex" },
|
||||
{ name: "EPG", id: "epg" },
|
||||
{ name: "FFmpeg", id: "ffmpeg" },
|
||||
];
|
||||
scope.setTab = (tab) => {
|
||||
scope.tab = tab;
|
||||
}
|
||||
|
||||
if (scope.isNewChannel) {
|
||||
scope.tab = "basic";
|
||||
} else {
|
||||
scope.tab = "programming";
|
||||
}
|
||||
|
||||
scope.getTitle = () => {
|
||||
if (scope.isNewChannel) {
|
||||
return "Create Channel";
|
||||
} else {
|
||||
let x = "?";
|
||||
if ( (scope.channel.number != null) && ( typeof(scope.channel.number) !== 'undefined') && (! isNaN(scope.channel.number) ) ) {
|
||||
x = "" + scope.channel.number;
|
||||
}
|
||||
let y = "Unnamed";
|
||||
if (typeof(scope.channel.name) !== 'undefined') {
|
||||
y = scope.channel.name;
|
||||
}
|
||||
return `${x} - ${y}`;
|
||||
}
|
||||
}
|
||||
|
||||
scope._selectedRedirect = {
|
||||
isOffline : true,
|
||||
type : "redirect",
|
||||
@ -128,34 +256,9 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
});
|
||||
};
|
||||
|
||||
let fixFillerCollection = (f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
weight: f.weight,
|
||||
cooldown: f.cooldown * 60000,
|
||||
};
|
||||
}
|
||||
let unfixFillerCollection = (f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
weight: f.weight,
|
||||
cooldown: Math.floor(f.cooldown / 60000),
|
||||
};
|
||||
}
|
||||
|
||||
scope.updateChannelFromOfflineResult = (program) => {
|
||||
scope.channel.offlineMode = program.channelOfflineMode;
|
||||
scope.channel.offlinePicture = program.channelPicture;
|
||||
scope.channel.offlineSoundtrack = program.channelSound;
|
||||
scope.channel.fillerRepeatCooldown = program.repeatCooldown * 60000;
|
||||
scope.channel.fillerCollections = JSON.parse( angular.toJson(program.filler.map(fixFillerCollection) ) );
|
||||
scope.channel.fallback = JSON.parse( angular.toJson(program.fallback) );
|
||||
scope.channel.disableFillerOverlay = program.disableOverlay;
|
||||
}
|
||||
scope.finishedOfflineEdit = (program) => {
|
||||
let editedProgram = scope.channel.programs[scope.selectedProgram];
|
||||
let duration = program.durationSeconds * 1000;
|
||||
scope.updateChannelFromOfflineResult(program);
|
||||
editedProgram.duration = duration;
|
||||
editedProgram.isOffline = true;
|
||||
scope._selectedOffline = null
|
||||
@ -167,7 +270,6 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
duration: duration,
|
||||
isOffline: true
|
||||
}
|
||||
scope.updateChannelFromOfflineResult(result);
|
||||
scope.channel.programs.splice(scope.minProgramIndex, 0, program);
|
||||
scope._selectedOffline = null
|
||||
scope._addingOffline = null;
|
||||
@ -270,6 +372,19 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
});
|
||||
updateChannelDuration()
|
||||
}
|
||||
scope.slideAllPrograms = (offset) => {
|
||||
let t0 = scope.channel.startTime.getTime();
|
||||
let t1 = t0 - offset;
|
||||
let t = (new Date()).getTime();
|
||||
let total = scope.channel.duration;
|
||||
while(t1 > t) {
|
||||
//TODO: Replace with division
|
||||
t1 -= total;
|
||||
}
|
||||
scope.channel.startTime = new Date(t1);
|
||||
adjustStartTimeToCurrentProgram();
|
||||
updateChannelDuration();
|
||||
}
|
||||
scope.removeDuplicates = () => {
|
||||
let tmpProgs = {}
|
||||
let progs = scope.channel.programs
|
||||
@ -420,7 +535,7 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
|
||||
}
|
||||
let f = interpolate;
|
||||
let w = 5.0;
|
||||
let w = 15.0;
|
||||
let t = 4*60*60*1000;
|
||||
//let d = Math.log( Math.min(t, program.duration) ) / Math.log(2);
|
||||
//let a = (d * Math.log(2) ) / Math.log(t);
|
||||
@ -869,14 +984,7 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
}
|
||||
scope.makeOfflineFromChannel = (duration) => {
|
||||
return {
|
||||
channelOfflineMode: scope.channel.offlineMode,
|
||||
channelPicture: scope.channel.offlinePicture,
|
||||
channelSound: scope.channel.offlineSoundtrack,
|
||||
repeatCooldown : Math.floor(scope.channel.fillerRepeatCooldown / 60000),
|
||||
filler: JSON.parse( angular.toJson(scope.channel.fillerCollections.map(unfixFillerCollection) ) ),
|
||||
fallback: JSON.parse( angular.toJson(scope.channel.fallback) ),
|
||||
durationSeconds: duration,
|
||||
disableOverlay : scope.channel.disableFillerOverlay,
|
||||
}
|
||||
}
|
||||
scope.addOffline = () => {
|
||||
@ -1055,6 +1163,7 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
scope.showRotatedNote = false;
|
||||
scope.channel.duration = 0
|
||||
scope.hasFlex = false;
|
||||
|
||||
for (let i = 0, l = scope.channel.programs.length; i < l; i++) {
|
||||
scope.channel.programs[i].start = new Date(scope.channel.startTime.valueOf() + scope.channel.duration)
|
||||
scope.channel.programs[i].$index = i;
|
||||
@ -1066,6 +1175,7 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
}
|
||||
scope.maxSize = Math.max(scope.maxSize, scope.channel.programs.length);
|
||||
scope.libraryLimit = Math.max(0, scope.maxSize - scope.channel.programs.length );
|
||||
scope.endTime = new Date( scope.channel.startTime.valueOf() + scope.channel.duration );
|
||||
}
|
||||
scope.error = {}
|
||||
scope._onDone = async (channel) => {
|
||||
@ -1079,43 +1189,87 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
// validate
|
||||
var now = new Date()
|
||||
scope.error.any = true;
|
||||
if (typeof channel.number === "undefined" || channel.number === null || channel.number === "")
|
||||
|
||||
|
||||
if (typeof channel.number === "undefined" || channel.number === null || channel.number === "") {
|
||||
scope.error.number = "Select a channel number"
|
||||
else if (channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1 && scope.isNewChannel) // we need the parseInt for indexOf to work properly
|
||||
scope.error.tab = "basic";
|
||||
} else if (channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1 && scope.isNewChannel) { // we need the parseInt for indexOf to work properly
|
||||
scope.error.number = "Channel number already in use."
|
||||
else if (!scope.isNewChannel && channel.number !== scope.beforeEditChannelNumber && channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1)
|
||||
scope.error.tab = "basic";
|
||||
} else if (!scope.isNewChannel && channel.number !== scope.beforeEditChannelNumber && channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1) {
|
||||
scope.error.number = "Channel number already in use."
|
||||
else if (channel.number < 0 || channel.number > 9999)
|
||||
scope.error.tab = "basic";
|
||||
} else if (channel.number < 0 || channel.number > 9999) {
|
||||
scope.error.name = "Enter a valid number (0-9999)"
|
||||
else if (typeof channel.name === "undefined" || channel.name === null || channel.name === "")
|
||||
scope.error.tab = "basic";
|
||||
} else if (typeof channel.name === "undefined" || channel.name === null || channel.name === "") {
|
||||
scope.error.name = "Enter a channel name."
|
||||
else if (channel.icon !== "" && !validURL(channel.icon))
|
||||
scope.error.tab = "basic";
|
||||
} else if (channel.icon !== "" && !validURL(channel.icon)) {
|
||||
scope.error.icon = "Please enter a valid image URL. Or leave blank."
|
||||
else if (channel.overlayIcon && !validURL(channel.icon))
|
||||
scope.error.tab = "basic";
|
||||
} else if (channel.overlayIcon && !validURL(channel.icon)) {
|
||||
scope.error.icon = "Please enter a valid image URL. Cant overlay an invalid image."
|
||||
else if (now < channel.startTime)
|
||||
scope.error.tab = "basic";
|
||||
} else if (now < channel.startTime) {
|
||||
scope.error.startTime = "Start time must not be set in the future."
|
||||
else if (channel.programs.length === 0)
|
||||
scope.error.tab = "programming";
|
||||
} else if (channel.programs.length === 0) {
|
||||
scope.error.programs = "No programs have been selected. Select at least one program."
|
||||
else {
|
||||
scope.error.tab = "programming";
|
||||
} else if ( channel.watermark.enabled && notValidNumber(scope.channel.watermark.width, 0.01,100)) {
|
||||
scope.error.watermark = "Please include a valid watermark width.";
|
||||
scope.error.tab = "ffmpeg";
|
||||
} else if ( channel.watermark.enabled && notValidNumber(scope.channel.watermark.verticalMargin, 0.00,100)) {
|
||||
scope.error.watermark = "Please include a valid watermark vertical margin.";
|
||||
scope.error.tab = "ffmpeg";
|
||||
} else if ( channel.watermark.enabled && notValidNumber(scope.channel.watermark.horizontalMargin, 0.00,100)) {
|
||||
scope.error.watermark = "Please include a valid watermark horizontal margin.";
|
||||
scope.error.tab = "ffmpeg";
|
||||
} else if ( channel.watermark.enabled && (scope.channel.watermark.width + scope.channel.watermark.horizontalMargin > 100.0) ) {
|
||||
scope.error.watermark = "Horizontal margin + width should not exceed 100.";
|
||||
scope.error.tab = "ffmpeg";
|
||||
} else if ( channel.watermark.enabled && notValidNumber(scope.channel.watermark.duration, 0)) {
|
||||
scope.error.watermark = "Please include a valid watermark duration.";
|
||||
scope.error.tab = "ffmpeg";
|
||||
} else if (
|
||||
channel.offlineMode != 'pic'
|
||||
&& (channel.fallback.length == 0)
|
||||
) {
|
||||
scope.error.fallback = 'Either add a fallback clip or change the fallback mode to Picture.';
|
||||
scope.error.tab = "flex";
|
||||
} else {
|
||||
scope.error.any = false;
|
||||
for (let i = 0; i < scope.channel.programs.length; i++) {
|
||||
delete scope.channel.programs[i].$index;
|
||||
}
|
||||
try {
|
||||
removeMinuteVersionsOfFields(channel);
|
||||
let s = angular.toJson(channel);
|
||||
addMinuteVersionsOfFields();
|
||||
if (s.length > 50*1000*1000) {
|
||||
scope.error.any = true;
|
||||
scope.error.programs = "Channel is too large, can't save.";
|
||||
scope.error.tab = "programming";
|
||||
} else {
|
||||
await scope.onDone(JSON.parse(s))
|
||||
let cloned = JSON.parse(s);
|
||||
//clean up some stuff that's only used by the UI:
|
||||
cloned.fillerCollections = cloned.fillerCollections.filter( (f) => { return f.id != 'none'; } );
|
||||
cloned.fillerCollections.forEach( (c) => {
|
||||
delete c.percentage;
|
||||
delete c.options;
|
||||
} );
|
||||
await scope.onDone(cloned)
|
||||
s = null;
|
||||
}
|
||||
} catch(err) {
|
||||
addMinuteVersionsOfFields();
|
||||
$timeout();
|
||||
console.error(err);
|
||||
scope.error.any = true;
|
||||
scope.error.programs = "Unable to save channel."
|
||||
scope.error.tab = "programming";
|
||||
}
|
||||
}
|
||||
$timeout(() => { scope.error = {} }, 60000)
|
||||
@ -1208,6 +1362,28 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
};
|
||||
scope.loadChannels();
|
||||
|
||||
scope.setTool = (toolName) => {
|
||||
scope.tool = toolName;
|
||||
}
|
||||
|
||||
scope.hasPrograms = () => {
|
||||
return scope.channel.programs.length > 0;
|
||||
}
|
||||
|
||||
scope.showPlexLibrary = () => {
|
||||
scope.displayPlexLibrary = true;
|
||||
}
|
||||
|
||||
scope.toggleTools = () => {
|
||||
scope.showShuffleOptions = !scope.showShuffleOptions
|
||||
localStorage.setItem("channel-tools", (scope.showShuffleOptions? 'on' : 'off') );
|
||||
}
|
||||
|
||||
scope.toggleToolsDirection = () => {
|
||||
scope.reverseTools = ! scope.reverseTools;
|
||||
localStorage.setItem("channel-tools-position", (scope.reverseTools? 'left' : 'right') );
|
||||
}
|
||||
|
||||
scope.disablePadding = () => {
|
||||
return (scope.paddingOption.id==-1) || (2*scope.channel.programs.length > scope.maxSize);
|
||||
}
|
||||
@ -1292,6 +1468,53 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
|
||||
}
|
||||
|
||||
scope.openFallbackLibrary = () => {
|
||||
scope.showFallbackPlexLibrary = true
|
||||
}
|
||||
|
||||
scope.importFallback = (selectedPrograms) => {
|
||||
for (let i = 0, l = selectedPrograms.length; i < l && i < 1; i++) {
|
||||
selectedPrograms[i].commercials = []
|
||||
}
|
||||
scope.channel.fallback = [];
|
||||
if (selectedPrograms.length > 0) {
|
||||
scope.channel.fallback = [ selectedPrograms[0] ];
|
||||
}
|
||||
scope.showFallbackPlexLibrary = false;
|
||||
}
|
||||
|
||||
scope.fillerOptions = scope.channel.fillerCollections.map( (f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
name: `(${f.id})`,
|
||||
}
|
||||
});
|
||||
|
||||
scope.slide = {
|
||||
value: -1,
|
||||
options: [
|
||||
{id:-1, description: "Time Amount" },
|
||||
{id: 1 * 60 * 1000, description: "1 minute" },
|
||||
{id: 10 * 60 * 1000, description: "10 minutes" },
|
||||
{id: 15 * 60 * 1000, description: "15 minutes" },
|
||||
{id: 30 * 60 * 1000, description: "30 minutes" },
|
||||
{id: 60 * 60 * 1000, description: "1 hour" },
|
||||
{id: 2 * 60 * 60 * 1000, description: "2 hours" },
|
||||
{id: 4 * 60 * 60 * 1000, description: "4 hours" },
|
||||
{id: 8 * 60 * 60 * 1000, description: "8 hours" },
|
||||
{id:12 * 60 * 60 * 1000, description: "12 hours" },
|
||||
{id:24 * 60 * 60 * 1000, description: "1 day" },
|
||||
{id: 7 * 24 * 60 * 60 * 1000, description: "1 week" },
|
||||
]
|
||||
}
|
||||
|
||||
scope.resolutionOptions = [
|
||||
{ id: "", description: "(Use global setting)" },
|
||||
];
|
||||
resolutionOptions.get()
|
||||
.forEach( (a) => {
|
||||
scope.resolutionOptions.push(a)
|
||||
} );
|
||||
|
||||
scope.nightStartHours = [ { id: -1, description: "Start" } ];
|
||||
scope.nightEndHours = [ { id: -1, description: "End" } ];
|
||||
@ -1307,6 +1530,337 @@ module.exports = function ($timeout, $location, dizquetv) {
|
||||
}
|
||||
scope.rerunStartHours = scope.nightStartHours;
|
||||
scope.paddingMod = 30;
|
||||
|
||||
let fillerOptionsFor = (index) => {
|
||||
let used = {};
|
||||
let added = {};
|
||||
for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
|
||||
if (scope.channel.fillerCollections[i].id != 'none' && i != index) {
|
||||
used[ scope.channel.fillerCollections[i].id ] = true;
|
||||
}
|
||||
}
|
||||
let options = [];
|
||||
for (let i = 0; i < scope.fillerOptions.length; i++) {
|
||||
if ( used[scope.fillerOptions[i].id] !== true) {
|
||||
added[scope.fillerOptions[i].id] = true;
|
||||
options.push( scope.fillerOptions[i] );
|
||||
}
|
||||
}
|
||||
if (scope.channel.fillerCollections[index].id == 'none') {
|
||||
added['none'] = true;
|
||||
options.push( {
|
||||
id: 'none',
|
||||
name: 'Add a filler list...',
|
||||
} );
|
||||
}
|
||||
if ( added[scope.channel.fillerCollections[index].id] !== true ) {
|
||||
options.push( {
|
||||
id: scope.channel.fillerCollections[index].id,
|
||||
name: `[${f.id}]`,
|
||||
} );
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
scope.programmingHeight = () => {
|
||||
return scope.programming.maxHeight + "rem";
|
||||
}
|
||||
let setProgrammingHeight = (h) => {
|
||||
scope.programming.step++;
|
||||
$timeout( () => {
|
||||
scope.programming.step--;
|
||||
}, 1000 )
|
||||
scope.programming.maxHeight = h;
|
||||
localStorage.setItem("channel-programming-list-height", "" + h );
|
||||
};
|
||||
scope.programmingZoomIn = () => {
|
||||
let h = scope.programming.maxHeight;
|
||||
h = Math.min( Math.ceil(h + scope.programming.step ), 64);
|
||||
setProgrammingHeight(h);
|
||||
}
|
||||
scope.programmingZoomOut = () => {
|
||||
let h = scope.programming.maxHeight;
|
||||
h = Math.max( Math.floor(h - scope.programming.step ), 2 );
|
||||
setProgrammingHeight(h);
|
||||
}
|
||||
|
||||
scope.refreshFillerStuff = () => {
|
||||
if (typeof(scope.channel.fillerCollections) === 'undefined') {
|
||||
return;
|
||||
}
|
||||
addAddFiller();
|
||||
updatePercentages();
|
||||
refreshIndividualOptions();
|
||||
}
|
||||
|
||||
let updatePercentages = () => {
|
||||
let w = 0;
|
||||
for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
|
||||
if (scope.channel.fillerCollections[i].id !== 'none') {
|
||||
w += scope.channel.fillerCollections[i].weight;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
|
||||
if (scope.channel.fillerCollections[i].id !== 'none') {
|
||||
scope.channel.fillerCollections[i].percentage = (scope.channel.fillerCollections[i].weight * 100 / w).toFixed(2) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
let addAddFiller = () => {
|
||||
if ( (scope.channel.fillerCollections.length == 0) || (scope.channel.fillerCollections[scope.channel.fillerCollections.length-1].id !== 'none') ) {
|
||||
scope.channel.fillerCollections.push ( {
|
||||
'id': 'none',
|
||||
'weight': 300,
|
||||
'cooldown': 0,
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let refreshIndividualOptions = () => {
|
||||
for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
|
||||
scope.channel.fillerCollections[i].options = fillerOptionsFor(i);
|
||||
}
|
||||
}
|
||||
|
||||
let refreshFillerOptions = async() => {
|
||||
|
||||
try {
|
||||
let r = await dizquetv.getAllFillersInfo();
|
||||
scope.fillerOptions = r.map( (f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
};
|
||||
} );
|
||||
scope.refreshFillerStuff();
|
||||
scope.$apply();
|
||||
} catch(err) {
|
||||
console.error("Unable to get filler info", err);
|
||||
}
|
||||
};
|
||||
scope.refreshFillerStuff();
|
||||
refreshFillerOptions();
|
||||
|
||||
function parseResolutionString(s) {
|
||||
var i = s.indexOf('x');
|
||||
if (i == -1) {
|
||||
i = s.indexOf("×");
|
||||
if (i == -1) {
|
||||
return {w:1920, h:1080}
|
||||
}
|
||||
}
|
||||
return {
|
||||
w: parseInt( s.substring(0,i) , 10 ),
|
||||
h: parseInt( s.substring(i+1) , 10 ),
|
||||
}
|
||||
}
|
||||
|
||||
scope.videoRateDefault = "(Use global setting)";
|
||||
scope.videoBufSizeDefault = "(Use global setting)";
|
||||
|
||||
let refreshScreenResolution = async () => {
|
||||
|
||||
|
||||
try {
|
||||
let ffmpegSettings = await dizquetv.getFfmpegSettings()
|
||||
if (
|
||||
(ffmpegSettings.targetResolution != null)
|
||||
&& (typeof(ffmpegSettings.targetResolution) !== 'undefined')
|
||||
&& (typeof(ffmpegSettings.targetResolution) !== '')
|
||||
) {
|
||||
let p = parseResolutionString( ffmpegSettings.targetResolution );
|
||||
scope.resolutionOptions[0] = {
|
||||
id: "",
|
||||
description: `Use global setting (${ffmpegSettings.targetResolution})`,
|
||||
}
|
||||
ffmpegSettings.targetResolution
|
||||
scope.screenW = p.w;
|
||||
scope.screenH = p.h;
|
||||
scope.videoRateDefault = `global setting=${ffmpegSettings.videoBitrate}`;
|
||||
scope.videoBufSizeDefault = `global setting=${ffmpegSettings.videoBufSize}`;
|
||||
|
||||
$timeout();
|
||||
}
|
||||
} catch(err) {
|
||||
console.error("Could not fetch ffmpeg settings", err);
|
||||
}
|
||||
}
|
||||
refreshScreenResolution();
|
||||
|
||||
scope.showList = () => {
|
||||
return ! scope.showFallbackPlexLibrary;
|
||||
}
|
||||
|
||||
|
||||
scope.deleteFillerList =(index) => {
|
||||
scope.channel.fillerCollections.splice(index, 1);
|
||||
scope.refreshFillerStuff();
|
||||
}
|
||||
|
||||
|
||||
|
||||
scope.durationString = (duration) => {
|
||||
var date = new Date(0);
|
||||
date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here
|
||||
return date.toISOString().substr(11, 8);
|
||||
}
|
||||
|
||||
scope.getCurrentWH = () => {
|
||||
if (scope.channel.transcoding.targetResolution !== '') {
|
||||
return parseResolutionString( scope.channel.transcoding.targetResolution );
|
||||
}
|
||||
return {
|
||||
w: scope.screenW,
|
||||
h: scope.screenH
|
||||
}
|
||||
}
|
||||
scope.getWatermarkPreviewOuter = () => {
|
||||
let tm = scope.getCurrentWH();
|
||||
let resolutionW = tm.w;
|
||||
let resolutionH = tm.h;
|
||||
let width = 100;
|
||||
let height = width / ( resolutionW / resolutionH );
|
||||
|
||||
|
||||
return {
|
||||
width: `${width}%`,
|
||||
"overflow" : "hidden",
|
||||
"padding-top": 0,
|
||||
"padding-left": 0,
|
||||
"padding-right": 0,
|
||||
"padding-bottom": `${height}%`,
|
||||
position: "relative",
|
||||
}
|
||||
}
|
||||
|
||||
scope.getWatermarkPreviewRectangle = (p,q) => {
|
||||
let s = scope.getCurrentWH();
|
||||
if ( (s.w*q) == (s.h*p) ) {
|
||||
//not necessary, hide it
|
||||
return {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
}
|
||||
} else {
|
||||
//assume width is equal
|
||||
// s.w / h2 = p / q
|
||||
let h2 = (s.w * q * 100) / (p * s.h);
|
||||
let w2 = 100;
|
||||
let left = undefined;
|
||||
let top = undefined;
|
||||
if (h2 > 100) {
|
||||
//wrong
|
||||
//the other way around
|
||||
w2 = (s.h / s.w) * p * 100 / q;
|
||||
left = (100 - w2) / 2;
|
||||
} else {
|
||||
top = (100 - h2) / 2;
|
||||
}
|
||||
let padding = (100 * q) / p;
|
||||
return {
|
||||
"width" : `${w2}%`,
|
||||
"padding-top": "0",
|
||||
"padding-left": "0",
|
||||
"padding-right": "0",
|
||||
"padding-bottom": `${padding}%`,
|
||||
"margin" : "0",
|
||||
"left": `${left}%`,
|
||||
"top" : `${top}%`,
|
||||
"position": "absolute",
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
scope.getWatermarkSrc = () => {
|
||||
let url = scope.channel.watermark.url;
|
||||
if ( url == null || typeof(url) == 'undefined' || url == '') {
|
||||
url = scope.channel.icon;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
scope.getWatermarkPreviewInner = () => {
|
||||
let width = Math.max(Math.min(100, scope.channel.watermark.width), 0);
|
||||
let res = {
|
||||
width: `${width}%`,
|
||||
margin: "0",
|
||||
position: "absolute",
|
||||
}
|
||||
if (scope.channel.watermark.fixedSize === true) {
|
||||
delete res.width;
|
||||
}
|
||||
let mH = scope.channel.watermark.horizontalMargin;
|
||||
let mV = scope.channel.watermark.verticalMargin;
|
||||
if (scope.channel.watermark.position == 'top-left') {
|
||||
res["top"] = `${mV}%`;
|
||||
res["left"] = `${mH}%`;
|
||||
} else if (scope.channel.watermark.position == 'top-right') {
|
||||
res["top"] = `${mV}%`;
|
||||
res["right"] = `${mH}%`;
|
||||
} else if (scope.channel.watermark.position == 'bottom-right') {
|
||||
res["bottom"] = `${mV}%`;
|
||||
res["right"] = `${mH}%`;
|
||||
} else if (scope.channel.watermark.position == 'bottom-left') {
|
||||
res["bottom"] = `${mV}%`;
|
||||
res["left"] = `${mH}%`;
|
||||
} else {
|
||||
console.log("huh? " + scope.channel.watermark.position );
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function notValidNumber(x, lower, upper) {
|
||||
if ( (x == null) || (typeof(x) === 'undefined') || isNaN(x) ) {
|
||||
return true;
|
||||
}
|
||||
if ( (typeof(lower) !== 'undefined') && (x < lower) ) {
|
||||
return true;
|
||||
}
|
||||
if ( (typeof(upper) !== 'undefined') && (x > upper) ) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
scope.onTimeSlotsDone = (slotsResult) => {
|
||||
scope.channel.programs = slotsResult.programs;
|
||||
|
||||
let t = (new Date()).getTime();
|
||||
let t1 =new Date( (new Date( slotsResult.startTime ) ).getTime() );
|
||||
let total = 0;
|
||||
for (let i = 0; i < slotsResult.programs.length; i++) {
|
||||
total += slotsResult.programs[i].duration;
|
||||
}
|
||||
|
||||
scope.channel.scheduleBackup = slotsResult.schedule;
|
||||
|
||||
while(t1 > t) {
|
||||
//TODO: Replace with division
|
||||
t1 -= total;
|
||||
}
|
||||
scope.channel.startTime = new Date(t1);
|
||||
adjustStartTimeToCurrentProgram();
|
||||
updateChannelDuration();
|
||||
}
|
||||
scope.onTimeSlotsButtonClick = () => {
|
||||
scope.timeSlots.startDialog(scope.channel.programs, scope.maxSize, scope.channel.scheduleBackup );
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
pre: function(scope) {
|
||||
scope.timeSlots = null;
|
||||
scope.registerTimeSlots = (timeSlots) => {
|
||||
scope.timeSlots = timeSlots;
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
module.exports = function (dizquetv) {
|
||||
module.exports = function (dizquetv, resolutionOptions) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/ffmpeg-settings.html',
|
||||
@ -6,6 +6,7 @@
|
||||
scope: {
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
//add validations to ffmpeg settings, speciall commas in codec name
|
||||
dizquetv.getFfmpegSettings().then((settings) => {
|
||||
scope.settings = settings
|
||||
})
|
||||
@ -25,18 +26,7 @@
|
||||
scope.hideIfNotAutoPlay = () => {
|
||||
return scope.settings.enableAutoPlay != true
|
||||
};
|
||||
scope.resolutionOptions=[
|
||||
{id:"420x420",description:"420x420 (1:1)"},
|
||||
{id:"480x270",description:"480x270 (HD1080/16 16:9)"},
|
||||
{id:"576x320",description:"576x320 (18:10)"},
|
||||
{id:"640x360",description:"640x360 (nHD 16:9)"},
|
||||
{id:"720x480",description:"720x480 (WVGA 3:2)"},
|
||||
{id:"800x600",description:"800x600 (SVGA 4:3)"},
|
||||
{id:"1024x768",description:"1024x768 (WXGA 4:3)"},
|
||||
{id:"1280x720",description:"1280x720 (HD 16:9)"},
|
||||
{id:"1920x1080",description:"1920x1080 (FHD 16:9)"},
|
||||
{id:"3840x2160",description:"3840x2160 (4K 16:9)"},
|
||||
];
|
||||
scope.resolutionOptions= resolutionOptions.get();
|
||||
scope.muxDelayOptions=[
|
||||
{id:"0",description:"0 Seconds"},
|
||||
{id:"1",description:"1 Seconds"},
|
||||
@ -59,6 +49,24 @@
|
||||
{value:"sine", description:"Beep"},
|
||||
{value:"silent", description:"No Audio"},
|
||||
]
|
||||
scope.fpsOptions = [
|
||||
{id: 23.976, description: "23.976 frames per second"},
|
||||
{id: 24, description: "24 frames per second"},
|
||||
{id: 25, description: "25 frames per second"},
|
||||
{id: 29.97, description: "29.97 frames per second"},
|
||||
{id: 30, description: "30 frames per second"},
|
||||
{id: 50, description: "50 frames per second"},
|
||||
{id: 59.94, description: "59.94 frames per second"},
|
||||
{id: 60, description: "60 frames per second"},
|
||||
{id: 120, description: "120 frames per second"},
|
||||
];
|
||||
scope.scalingOptions = [
|
||||
{id: "bicubic", description: "bicubic (default)"},
|
||||
{id: "fast_bilinear", description: "fast_bilinear"},
|
||||
{id: "lanczos", description: "lanczos"},
|
||||
{id: "spline", description: "spline"},
|
||||
];
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
const dizquetv = require("../services/dizquetv");
|
||||
|
||||
module.exports = function ($timeout, dizquetv) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
@ -12,7 +10,6 @@ module.exports = function ($timeout, dizquetv) {
|
||||
onDone: "=onDone"
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
scope.fillerOptions = [];
|
||||
let updateNext = true;
|
||||
scope.$watch('program', () => {
|
||||
try {
|
||||
@ -23,126 +20,14 @@ module.exports = function ($timeout, dizquetv) {
|
||||
return;
|
||||
}
|
||||
updateNext = false;
|
||||
let filler = scope.program.filler;
|
||||
if (typeof(filler) === 'undefined') {
|
||||
filler = [];
|
||||
}
|
||||
scope.program.filler = filler;
|
||||
scope.showFallbackPlexLibrary = false;
|
||||
scope.fillerOptions = filler.map( (f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
name: `(${f.id})`,
|
||||
}
|
||||
});
|
||||
|
||||
$timeout( () => {
|
||||
refreshFillerOptions();
|
||||
}, 0);
|
||||
} catch(err) {
|
||||
console.error("$watch error", err);
|
||||
scope.error = null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
})
|
||||
|
||||
let fillerOptionsFor = (index) => {
|
||||
let used = {};
|
||||
let added = {};
|
||||
for (let i = 0; i < scope.program.filler.length; i++) {
|
||||
if (scope.program.filler[i].id != 'none' && i != index) {
|
||||
used[ scope.program.filler[i].id ] = true;
|
||||
}
|
||||
}
|
||||
let options = [];
|
||||
for (let i = 0; i < scope.fillerOptions.length; i++) {
|
||||
if ( used[scope.fillerOptions[i].id] !== true) {
|
||||
added[scope.fillerOptions[i].id] = true;
|
||||
options.push( scope.fillerOptions[i] );
|
||||
}
|
||||
}
|
||||
if (scope.program.filler[index].id == 'none') {
|
||||
added['none'] = true;
|
||||
options.push( {
|
||||
id: 'none',
|
||||
name: 'Add a filler list...',
|
||||
} );
|
||||
}
|
||||
if ( added[scope.program.filler[index].id] !== true ) {
|
||||
options.push( {
|
||||
id: scope.program.filler[index].id,
|
||||
name: `[${f.id}]`,
|
||||
} );
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
scope.refreshFillerStuff = () => {
|
||||
if (typeof(scope.program) === 'undefined') {
|
||||
return;
|
||||
}
|
||||
addAddFiller();
|
||||
updatePercentages();
|
||||
refreshIndividualOptions();
|
||||
}
|
||||
|
||||
let updatePercentages = () => {
|
||||
let w = 0;
|
||||
for (let i = 0; i < scope.program.filler.length; i++) {
|
||||
if (scope.program.filler[i].id !== 'none') {
|
||||
w += scope.program.filler[i].weight;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < scope.program.filler.length; i++) {
|
||||
if (scope.program.filler[i].id !== 'none') {
|
||||
scope.program.filler[i].percentage = (scope.program.filler[i].weight * 100 / w).toFixed(2) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
let addAddFiller = () => {
|
||||
if ( (scope.program.filler.length == 0) || (scope.program.filler[scope.program.filler.length-1].id !== 'none') ) {
|
||||
scope.program.filler.push ( {
|
||||
'id': 'none',
|
||||
'weight': 300,
|
||||
'cooldown': 0,
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let refreshIndividualOptions = () => {
|
||||
for (let i = 0; i < scope.program.filler.length; i++) {
|
||||
scope.program.filler[i].options = fillerOptionsFor(i);
|
||||
}
|
||||
}
|
||||
|
||||
let refreshFillerOptions = async() => {
|
||||
|
||||
try {
|
||||
let r = await dizquetv.getAllFillersInfo();
|
||||
scope.fillerOptions = r.map( (f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
};
|
||||
} );
|
||||
scope.refreshFillerStuff();
|
||||
scope.$apply();
|
||||
} catch(err) {
|
||||
console.error("Unable to get filler info", err);
|
||||
}
|
||||
};
|
||||
scope.refreshFillerStuff();
|
||||
refreshFillerOptions();
|
||||
|
||||
scope.finished = (prog) => {
|
||||
if (
|
||||
prog.channelOfflineMode != 'pic'
|
||||
&& (prog.fallback.length == 0)
|
||||
) {
|
||||
scope.error = { fallback: 'Either add a fallback clip or change the fallback mode to Picture.' }
|
||||
}
|
||||
scope.error = null;
|
||||
if (isNaN(prog.durationSeconds) || prog.durationSeconds < 0 ) {
|
||||
scope.error = { duration: 'Duration must be a positive integer' }
|
||||
}
|
||||
@ -152,37 +37,9 @@ module.exports = function ($timeout, dizquetv) {
|
||||
}, 30000)
|
||||
return
|
||||
}
|
||||
prog.filler = prog.filler.filter( (f) => { return f.id != 'none'; } );
|
||||
scope.onDone(JSON.parse(angular.toJson(prog)))
|
||||
scope.program = null
|
||||
}
|
||||
scope.showList = () => {
|
||||
return ! scope.showFallbackPlexLibrary;
|
||||
}
|
||||
scope.importFallback = (selectedPrograms) => {
|
||||
for (let i = 0, l = selectedPrograms.length; i < l && i < 1; i++) {
|
||||
selectedPrograms[i].commercials = []
|
||||
}
|
||||
scope.program.fallback = [];
|
||||
if (selectedPrograms.length > 0) {
|
||||
scope.program.fallback = [ selectedPrograms[0] ];
|
||||
}
|
||||
scope.showFallbackPlexLibrary = false;
|
||||
}
|
||||
|
||||
|
||||
scope.deleteFillerList =(index) => {
|
||||
scope.program.filler.splice(index, 1);
|
||||
scope.refreshFillerStuff();
|
||||
}
|
||||
|
||||
|
||||
|
||||
scope.durationString = (duration) => {
|
||||
var date = new Date(0);
|
||||
date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here
|
||||
return date.toISOString().substr(11, 8);
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
283
web/directives/time-slots-schedule-editor.js
Normal file
283
web/directives/time-slots-schedule-editor.js
Normal file
@ -0,0 +1,283 @@
|
||||
|
||||
|
||||
module.exports = function ($timeout, dizquetv) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/time-slots-schedule-editor.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
linker: "=linker",
|
||||
onDone: "=onDone"
|
||||
},
|
||||
|
||||
link: function (scope, element, attrs) {
|
||||
scope.limit = 50000;
|
||||
scope.visible = false;
|
||||
scope.fake = { time: -1 };
|
||||
scope.timeOptions = []
|
||||
scope.badTimes = false;
|
||||
let showsById;
|
||||
let shows;
|
||||
|
||||
|
||||
function reset() {
|
||||
showsById = {};
|
||||
shows = [];
|
||||
scope.schedule = {
|
||||
lateness : 0,
|
||||
maxDays: 365,
|
||||
flexPreference : "distribute",
|
||||
slots : [],
|
||||
pad: 1,
|
||||
fake: { time: -1 },
|
||||
}
|
||||
|
||||
}
|
||||
reset();
|
||||
|
||||
function loadBackup(backup) {
|
||||
scope.schedule = JSON.parse( JSON.stringify(backup) );
|
||||
if (typeof(scope.schedule.pad) == 'undefined') {
|
||||
scope.schedule.pad = 1;
|
||||
}
|
||||
let slots = scope.schedule.slots;
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
let found = false;
|
||||
for (let j = 0; j < scope.showOptions.length; j++) {
|
||||
if (slots[i].showId == scope.showOptions[j].id) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (! found) {
|
||||
slots[i].showId = "flex.";
|
||||
slots[i].order = "shuffle";
|
||||
}
|
||||
}
|
||||
if (typeof(scope.schedule.flexPreference) === 'undefined') {
|
||||
scope.schedule.flexPreference = "distribute";
|
||||
}
|
||||
scope.schedule.fake = {
|
||||
time: -1,
|
||||
}
|
||||
}
|
||||
|
||||
for (let h = 0; h < 24; h++) {
|
||||
for (let m = 0; m < 60; m += 15) {
|
||||
scope.timeOptions.push( {
|
||||
id: (h * 60 + m) * 60 * 1000,
|
||||
description: niceLookingTime(h,m),
|
||||
} );
|
||||
}
|
||||
}
|
||||
scope.latenessOptions = [
|
||||
{ id: 0 , description: "Do not allow" },
|
||||
{ id: 5*60*1000, description: "5 minutes" },
|
||||
{ id: 10*60*1000 , description: "10 minutes" },
|
||||
{ id: 15*60*1000 , description: "15 minutes" },
|
||||
{ id: 1*60*60*1000 , description: "1 hour" },
|
||||
{ id: 2*60*60*1000 , description: "2 hours" },
|
||||
{ id: 3*60*60*1000 , description: "3 hours" },
|
||||
{ id: 4*60*60*1000 , description: "4 hours" },
|
||||
{ id: 8*60*60*1000 , description: "8 hours" },
|
||||
{ id: 24*60*60*1000 , description: "I don't care about lateness" },
|
||||
];
|
||||
scope.flexOptions = [
|
||||
{ id: "distribute", description: "Between videos" },
|
||||
{ id: "end", description: "End of the slot" },
|
||||
]
|
||||
scope.fakeTimeOptions = JSON.parse( JSON.stringify( scope.timeOptions ) );
|
||||
scope.fakeTimeOptions.push( {id: -1, description: "Add slot"} );
|
||||
|
||||
scope.padOptions = [
|
||||
{id: 1, description: "Do not pad" },
|
||||
{id: 5*60*1000, description: "0:00, 0:05, 0:10, ..., 0:55" },
|
||||
{id: 10*60*1000, description: "0:00, 0:10, 0:20, ..., 0:50" },
|
||||
{id: 15*60*1000, description: "0:00, 0:15, 0:30, ..., 0:45" },
|
||||
{id: 30*60*1000, description: "0:00, 0:30" },
|
||||
{id: 1*60*60*1000, description: "0:00" },
|
||||
];
|
||||
|
||||
scope.showOptions = [];
|
||||
scope.orderOptions = [
|
||||
{ id: "next", description: "Play Next" },
|
||||
{ id: "shuffle", description: "Shuffle" },
|
||||
];
|
||||
|
||||
let doIt = async() => {
|
||||
scope.schedule.timeZoneOffset = (new Date()).getTimezoneOffset();
|
||||
let res = await dizquetv.calculateTimeSlots(scope.programs, scope.schedule );
|
||||
res.schedule = scope.schedule;
|
||||
delete res.schedule.fake;
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
let startDialog = (programs, limit, backup) => {
|
||||
scope.limit = limit;
|
||||
scope.programs = programs;
|
||||
|
||||
reset();
|
||||
|
||||
|
||||
|
||||
programs.forEach( (p) => {
|
||||
let show = getShow(p);
|
||||
if (show != null) {
|
||||
if (typeof(showsById[show.id]) === 'undefined') {
|
||||
showsById[show.id] = shows.length;
|
||||
shows.push( show );
|
||||
} else {
|
||||
show = shows[ showsById[show.id] ];
|
||||
}
|
||||
}
|
||||
} );
|
||||
scope.showOptions = shows.map( (show) => { return show } );
|
||||
scope.showOptions.push( {
|
||||
id: "flex.",
|
||||
description: "Flex",
|
||||
} );
|
||||
if (typeof(backup) !== 'undefined') {
|
||||
loadBackup(backup);
|
||||
}
|
||||
|
||||
scope.visible = true;
|
||||
}
|
||||
|
||||
|
||||
scope.linker( {
|
||||
startDialog: startDialog,
|
||||
} );
|
||||
|
||||
scope.finished = async (cancel) => {
|
||||
scope.error = null;
|
||||
if (!cancel) {
|
||||
try {
|
||||
scope.loading = true;
|
||||
$timeout();
|
||||
scope.onDone( await doIt() );
|
||||
scope.visible = false;
|
||||
} catch(err) {
|
||||
console.error("Unable to generate channel lineup", err);
|
||||
scope.error = "There was an error processing the schedule";
|
||||
return;
|
||||
} finally {
|
||||
scope.loading = false;
|
||||
$timeout();
|
||||
}
|
||||
} else {
|
||||
scope.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
scope.fakeTimeChanged = () => {
|
||||
|
||||
if (scope.fake.time != -1) {
|
||||
scope.schedule.slots.push( {
|
||||
time: scope.fake.time,
|
||||
showId: "flex.",
|
||||
order: "next"
|
||||
} )
|
||||
scope.fake.time = -1;
|
||||
scope.refreshSlots();
|
||||
}
|
||||
}
|
||||
|
||||
scope.deleteSlot = (index) => {
|
||||
scope.schedule.slots.splice(index, 1);
|
||||
}
|
||||
|
||||
scope.hasTimeError = (slot) => {
|
||||
return typeof(slot.timeError) !== 'undefined';
|
||||
}
|
||||
|
||||
scope.disableCreateLineup = () => {
|
||||
if (scope.badTimes) {
|
||||
return true;
|
||||
}
|
||||
if (typeof(scope.schedule.maxDays) === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
if (scope.schedule.slots.length == 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
scope.canShowSlot = (slot) => {
|
||||
return (slot.showId != 'flex.') && !(slot.showId.startsWith('redirect.'));
|
||||
}
|
||||
|
||||
scope.refreshSlots = () => {
|
||||
scope.badTimes = false;
|
||||
//"Bubble sort ought to be enough for anybody"
|
||||
for (let i = 0; i < scope.schedule.slots.length; i++) {
|
||||
for (let j = i+1; j < scope.schedule.slots.length; j++) {
|
||||
if (scope.schedule.slots[j].time< scope.schedule.slots[i].time) {
|
||||
let x = scope.schedule.slots[i];
|
||||
scope.schedule.slots[i] = scope.schedule.slots[j];
|
||||
scope.schedule.slots[j] = x;
|
||||
}
|
||||
}
|
||||
if (scope.schedule.slots[i].showId == 'movie.') {
|
||||
scope.schedule.slots[i].order = "shuffle";
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < scope.schedule.slots.length; i++) {
|
||||
if (
|
||||
(i > 0 && (scope.schedule.slots[i].time == (scope.schedule.slots[i-1].time) ) )
|
||||
|| ( (i+1 < scope.schedule.slots.length) && (scope.schedule.slots[i].time == (scope.schedule.slots[i+1].time) ) )
|
||||
) {
|
||||
scope.badTimes = true;
|
||||
scope.schedule.slots[i].timeError = "Please select a unique time.";
|
||||
} else {
|
||||
delete scope.schedule.slots[i].timeError;
|
||||
}
|
||||
}
|
||||
$timeout();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function niceLookingTime(h, m) {
|
||||
let d = new Date();
|
||||
d.setHours(h);
|
||||
d.setMinutes(m);
|
||||
d.setSeconds(0);
|
||||
d.setMilliseconds(0);
|
||||
|
||||
return d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
//This is a duplicate code, but maybe it doesn't have to be?
|
||||
function getShow(program) {
|
||||
//used for equalize and frequency tweak
|
||||
if (program.isOffline) {
|
||||
if (program.type == 'redirect') {
|
||||
return {
|
||||
description : `Redirect to channel ${program.channel}`,
|
||||
id: "redirect." + program.channel,
|
||||
channel: program.channel,
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) {
|
||||
return {
|
||||
description: program.showTitle,
|
||||
id: "tv." + program.showTitle,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
description: "Movies",
|
||||
id: "movie.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
.pull-right { float: right; }
|
||||
|
||||
.modal-semi-body {
|
||||
padding: 1rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.commercials-panel {
|
||||
background-color: rgb(70, 70, 70);
|
||||
border-top: 1px solid #daa104;
|
||||
@ -249,17 +254,55 @@ table.tvguide {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.filler-list .list-group-item {
|
||||
.filler-list .list-group-item, .program-row {
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.filler-list .list-group-item .title {
|
||||
.filler-list .list-group-item .title, .program-row .title {
|
||||
margin-right: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
div.channel-tools {
|
||||
max-height: 20em;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
border-top: 1px solid #888;
|
||||
border-bottom: 1px solid #888;
|
||||
}
|
||||
div.channel-tools p {
|
||||
font-size: 0.5rem;
|
||||
margin-top: 0.01rem;
|
||||
}
|
||||
|
||||
div.programming-panes {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
div.programming-panes div.reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
div.programming-panes div.programming-pane {
|
||||
overflow-y: auto;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
div.programming-programs div.list-group-item {
|
||||
height: 1.5rem;
|
||||
}
|
||||
.channel-editor-modal-big {
|
||||
width:1200px;
|
||||
min-width: 98%;
|
||||
}
|
||||
|
||||
/* Safari */
|
||||
@-webkit-keyframes spin {
|
||||
0% { -webkit-transform: rotate(0deg); }
|
||||
@ -271,3 +314,45 @@ table.tvguide {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
.program-row:nth-child(odd), .filler-row:nth-child(odd), .channel-row:nth-child(odd) {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.tools-pane button {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tools-pane button:not(.btn-danger),
|
||||
.tools-pane .input-group-text,
|
||||
.tools-pane select {
|
||||
border: 1px solid #999999 !important;
|
||||
}
|
||||
.tools-pane input,
|
||||
.tools-pane select {
|
||||
font-size: 14px;
|
||||
}
|
||||
.tools-pane select {
|
||||
text-align: center;
|
||||
border-radius: 0;
|
||||
padding: 0 16px 0 0;
|
||||
height: initial;
|
||||
}
|
||||
.tools-pane select:first-of-type {
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
.tools-pane .input-group-prepend + button {
|
||||
border-left: 0;
|
||||
}
|
||||
.tools-pane input.form-control {
|
||||
border-color: #999999;
|
||||
}
|
||||
.watermark-preview {
|
||||
background: linear-gradient(180deg, rgb(90, 90, 90) 0%, rgb(110, 110, 110) 35%, rgb(130, 130, 130) 100%, rgb(150, 150, 150) 100%);
|
||||
border: 2px solid black;
|
||||
}
|
||||
.watermark-preview .alternate-aspect {
|
||||
background : rgba(255,255,255, 0.1);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -91,6 +91,18 @@
|
||||
<br />
|
||||
<label>Video Buffer Size (k)</label>
|
||||
<input type="number" class="form-control form-control-sm" ng-model="settings.videoBufSize"/>
|
||||
<br />
|
||||
<label>Max Frame Rate</label>
|
||||
<select class='form-control custom-select' ng-model="settings.maxFPS" ria-describedby="fpsHelp"
|
||||
ng-options="o.id as o.description for o in fpsOptions" ></select>
|
||||
<small id='fpsHelp' class='form-text text-muted'>Will transcode videos that have FPS higher than this.</small>
|
||||
|
||||
<br />
|
||||
<label>Scaling Algorithm</label>
|
||||
<select class='form-control custom-select' ng-model="settings.scalingAlgorithm" ria-describedby="scalingHelp"
|
||||
ng-options="o.id as o.description for o in scalingOptions" ></select>
|
||||
<small id='scalingHelp' class='form-text text-muted'>Scaling algorithm to use when the transcoder needs to change the video size.</small>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@ -172,8 +184,8 @@
|
||||
<div class="col-sm-9">
|
||||
<div class="form-group">
|
||||
<input id="disableOverlay" type="checkbox" ng-model="settings.disableChannelOverlay" ng-disabled="isTranscodingNotNeeded()" />
|
||||
<label for="disableOverlay">Disable Channel Overlay Globally</label>
|
||||
<small class="form-text text-muted">Toggling this option will disable channel overlays regardless of channel settings.
|
||||
<label for="disableOverlay">Disable Channel Watermark Globally</label>
|
||||
<small class="form-text text-muted">Toggling this option will disable channel watermarks regardless of channel settings.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -69,7 +69,7 @@
|
||||
dnd-list=""
|
||||
|
||||
>
|
||||
<div class="list-group-item flex-container" style="cursor: default; height:1.1em; overflow:hidden" ng-repeat="x in content" track-by="x.$index" dnd-draggable="x"
|
||||
<div class="list-group-item flex-container filler-row" style="cursor: default; height:1.1em; overflow:hidden" ng-repeat="x in content" track-by="x.$index" dnd-draggable="x"
|
||||
"
|
||||
dnd-effect-allowed="move"
|
||||
dnd-moved="movedFunction(x.$index)"
|
||||
@ -98,4 +98,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<plex-library limit=1000000000 height="300" visible="showPlexLibrary" on-finish="importPrograms"></plex-library>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,141 +12,6 @@
|
||||
<label>Duration (Seconds): <span class="text-danger pull-right">{{error.duration}}</span></label>
|
||||
<input type="number" class="form-control form-control-sm" ng-model="program.durationSeconds" ng-pattern="/^([1-9][0-9]*)$/"/>
|
||||
|
||||
<h6 style="margin-top: 10px;">Flex Settings:</h6>
|
||||
|
||||
<div class='row'>
|
||||
<div class="col-md-12">
|
||||
<label for="offlineMode" class="small">Fallback Mode:</label>
|
||||
<div class="input-group mb-1">
|
||||
<select class="form-control form-control-sm" id="offlineMode" ng-model="program.channelOfflineMode">
|
||||
<option value="pic">Picture</option>
|
||||
<option value="clip">Clip from Library</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="program.channelOfflineMode == 'clip'" >
|
||||
<p class="text-center text-info">Pick a video clip that will be used for fallback when there's no appropriate
|
||||
filler available for the time duration. It's recommended to use countdown or looping videos for this. <span class="text-danger">{{error.fallback}}</span></p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class='row' ng-show="program.channelOfflineMode == 'pic'" >
|
||||
<div class="col-md-3">
|
||||
<img ng-src="{{program.channelPicture}}" alt="Fallback preview" style="max-height: 120px;"/>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<div>
|
||||
<label for="offlinePicture" class="small">
|
||||
Picture: <span class="text-danger pull-right">{{error.picture}}</span></label>
|
||||
<input name="offlinePicture" id="offlinePicture" class="form-control form-control-sm" type="url" ng-model="program.channelPicture" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="offlineSound" class="small">Sound Track:<span class="text-danger pull-right">{{error.sound}}</span></label>
|
||||
<input name="offlineSound" id="offlineSound" class="form-control form-control-sm" type="url" ng-model="program.channelSound" placeholder="URL to a sound track that will loop during the offline screen, leave empty for silence." />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class='row' ng-show="program.channelOfflineMode == 'pic'" >
|
||||
<div class='col-md-12'><p class="text-center text-info">This picture is used in case there are no filler clips available with a shorter length than the Flex time duration. Requires ffmpeg transcoding.</p></div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div ng-show="program.channelOfflineMode == 'clip'">
|
||||
<div class="list-group list-group-root" dnd-list="program.fallback">
|
||||
<div class="list-group-item flex-container" style="cursor: default;" ng-repeat="x in program.fallback" dnd-draggable="x" dnd-moved="program.fallback.splice($index, 1)" dnd-effect-allowed="move">
|
||||
<div class="program-start" >
|
||||
{{durationString(x.duration)}}
|
||||
</div>
|
||||
<div ng-style="programSquareStyle(x, true)" />
|
||||
<div style="margin-right: 5px;">
|
||||
<strong>Fallback:</strong> {{x.title}}
|
||||
</div>
|
||||
<div class="flex-pull-right">
|
||||
<button class="btn btn-sm btn-link" ng-click="program.fallback.splice($index,1)">
|
||||
<i class="text-danger fa fa-trash-alt" ></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="program.fallback.length === 0">
|
||||
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="showFallbackPlexLibrary = true">Pick fallback</button>
|
||||
</div>
|
||||
<hr style='margin-top:0' />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 style="margin-top: 10px;">Filler</h5>
|
||||
<div>
|
||||
<label>Minimum time before replaying a filler (Minutes): </label>
|
||||
<input type="number" class="form-control form-control-sm" ng-model="program.repeatCooldown" ng-pattern="/^([1-9][0-9]*)$/" min='0' max='1440' />
|
||||
|
||||
<span class="text-danger pull-right">{{error.blockRepeats}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<input id="overlayDiableIcon" type="checkbox" ng-model="program.disableOverlay">
|
||||
<label class="small" for="overlayDisableIcon" style="margin-bottom: 4px;"> Disable overlay icon when playing filler </label>
|
||||
</div>
|
||||
<hr />
|
||||
<h6>Filler Lists</h6>
|
||||
<div id='fillerContainer'>
|
||||
<br />
|
||||
|
||||
<div class="form-row" ng-repeat = "x in program.filler" track-by = "$index">
|
||||
<div class='form-group col-md-5'>
|
||||
<label ng-if="$index==0" for="fillerselect{{$index}}">List</label>
|
||||
<select
|
||||
id="fillerselect{{$index}}" class="custom-select form-control"
|
||||
ng-model="x.id" ng-options="o.id as o.name for o in x.options"
|
||||
ng-change="refreshFillerStuff()"
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
<div class='form-group col-md-2' ng-if="x.id !== 'none' " >
|
||||
<label ng-if="$index==0" for="cooldown{{$index}}">Cooldown (minutes)</label>
|
||||
<input class='form-control' id="cooldown{{$index}}" type='number' ng-model='x.cooldown' ng-pattern="/^([0-9][0-9]*)$/"
|
||||
min='0' max='1440'
|
||||
data-toggle="tooltip" data-placement="bottom" title="The channel won't pick a video from this list if it played something from this list less than this amount of minutes ago."
|
||||
> </input>
|
||||
</div>
|
||||
<div class='form-group col-md-2' ng-if="x.id === 'none' " >
|
||||
</div>
|
||||
<div class='form-group col-md-3' ng-if="x.id !== 'none' && program.filler.length > 2 " >
|
||||
<label ng-if="$index==0" for="fillerrange{{$index}}">Weight</label>
|
||||
<input class='form-control-range custom-range' id="fillerrange{{$index}}" type='range' ng-model='x.weight' min=1 max=600
|
||||
data-toggle="tooltip" data-placement="bottom" title="Lists with more weight will be picked more frequently."
|
||||
ng-change="refreshFillerStuff()"
|
||||
>
|
||||
</input>
|
||||
</div>
|
||||
<div class='form-group col-md-4' ng-if="x.id === 'none' || program.filler.length <= 2 " >
|
||||
</div>
|
||||
<div class='form-group col-md-1' ng-if="x.id !== 'none' && program.filler.length > 2" >
|
||||
<label ng-if="$index==0" for="fillerp{{$index}}">%</label>
|
||||
<input class='form-control flex-filler-percent' id="fillerp{{$index}}" type='text' ng-model='x.percentage'
|
||||
data-toggle="tooltip" data-placement="bottom" title="This is the overall probability this list might be picked, assuming all lists are available." readonly
|
||||
>
|
||||
</input>
|
||||
</div>
|
||||
<div class='form-group col-md-1' ng-if="x.id !== 'none'" >
|
||||
<label ng-if="$index==0" for="delete{{$index}}">-</label>
|
||||
<button id='delete{{$index}}' class='btn btn-link form-control' ng-click='deleteFillerList($index)' >
|
||||
<i class='text-danger fa fa-trash-alt'></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<hr></hr >
|
||||
<p class="text-center text-info">Videos from the filler list will be randomly picked to play unless there are cooldown restrictions to place or if no videos are short enough for the remaining Flex time. Use the Filler tab at the top to define filler lists. If no videos are available, the fallback will be used instead.</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
@ -158,6 +23,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<plex-library limit=1000000000 height="300" visible="showPlexLibrary" on-finish="importPrograms"></plex-library>
|
||||
<plex-library height="300" limit=1 visible="showFallbackPlexLibrary" on-finish="importFallback"></plex-library>
|
||||
|
||||
</div>
|
||||
140
web/public/templates/time-slots-schedule-editor.html
Normal file
140
web/public/templates/time-slots-schedule-editor.html
Normal file
@ -0,0 +1,140 @@
|
||||
<div ng-show="visible">
|
||||
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Time Slots</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" ng-show='loading' >
|
||||
<p><span class='loader'></span> Generating lineup, please wait...</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" ng-show='! loading' >
|
||||
<div class="form-row" ng-repeat = "slot in schedule.slots" track-by = "$index">
|
||||
<div class='form-group col-md-2'>
|
||||
<label ng-if="$index==0" for="showTime{{$index}}">Time</label>
|
||||
<select
|
||||
id="showTime{{$index}}" class="custom-select form-control"
|
||||
ng-class="{'is-invalid': hasTimeError(slot) }"
|
||||
ng-model="slot.time" ng-options="o.id as o.description for o in timeOptions"
|
||||
ng-change="refreshSlots()"
|
||||
aria-describedby="showTime{{$index}}Help"
|
||||
>
|
||||
</select>
|
||||
<small class='form-text text-danger'>{{slot.timeError}}</small>
|
||||
</div>
|
||||
<div class='form-group col-md-7'>
|
||||
<label ng-if="$index==0" for="showId{{$index}}">Program</label>
|
||||
<select
|
||||
id="showId{{$index}}" class="custom-select form-control"
|
||||
ng-model="slot.showId" ng-options="o.id as o.description for o in showOptions"
|
||||
ng-change="refreshSlots()"
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
<div class='form-group col-md-2'>
|
||||
<label ng-if="$index==0" for="showOrder{{$index}}" ng-show="canShowSlot(slot)" >Order</label>
|
||||
<select
|
||||
id="showOrder{{$index}}" class="custom-select form-control"
|
||||
ng-model="slot.order" ng-options="o.id as o.description for o in orderOptions"
|
||||
ng-change="refreshSlots()"
|
||||
ng-show="canShowSlot(slot)"
|
||||
ng-disabled="slot.showId == 'movie.'"
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class='form-group col-md-1'>
|
||||
<label ng-if="$index==0" for="delete{{$index}}">-</label>
|
||||
<button id='delete{{$index}}' class='btn btn-link form-control' ng-click='deleteSlot($index)' >
|
||||
<i class='text-danger fa fa-trash-alt'></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class='form-group col-md-2'>
|
||||
<label ng-if="schedule.slots.length==0" for="fakeTime">Time</label>
|
||||
<select
|
||||
id="fakeTime" class="custom-select form-control"
|
||||
ng-model="fake.time" ng-options="o.id as o.description for o in fakeTimeOptions"
|
||||
ng-change="fakeTimeChanged()"
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class='form-group'>
|
||||
<label for="lateness">Maximum lateness</label>
|
||||
<select
|
||||
id="lateness" class="custom-select form-control"
|
||||
ng-model="schedule.lateness" ng-options="o.id as o.description for o in latenessOptions"
|
||||
aria-describedby="latenessHelp"
|
||||
>
|
||||
</select>
|
||||
<small id='latenessHelp' class='form-text text-muted'>
|
||||
Allows programs to play a bit late if the previous program took longer than usual. If a program is too late, Flex is scheduled instead.
|
||||
</small>
|
||||
|
||||
</div>
|
||||
|
||||
<div class='form-group'>
|
||||
<label for="pad">Pad times</label>
|
||||
<select
|
||||
id="pad" class="custom-select form-control"
|
||||
ng-model="schedule.pad" ng-options="o.id as o.description for o in padOptions"
|
||||
aria-describedby="padHelp"
|
||||
>
|
||||
</select>
|
||||
<small id='padHelp' class='form-text text-muted'>
|
||||
Ensures programs have a nice-looking start time, it will add Flex time to fill the gaps.
|
||||
</small>
|
||||
|
||||
</div>
|
||||
|
||||
<div class='form-group'>
|
||||
<label for="pad">What to do with flex?</label>
|
||||
<select
|
||||
id="flexPreference" class="custom-select form-control"
|
||||
ng-model="schedule.flexPreference" ng-options="o.id as o.description for o in flexOptions"
|
||||
aria-describedby="flexPreferenceHelp"
|
||||
>
|
||||
</select>
|
||||
<small id='flexPreferenceHelp' class='form-text text-muted'>
|
||||
Usually slots need to add flex time to ensure that the next slot starts at the correct time. When there are multiple videos in the slot, you might prefer to distribute the flex time between the videos or to place most of the flex time at the end of the slot.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class='form-group'>
|
||||
<label for="lateness">Maximum days to precalculate</label>
|
||||
<input
|
||||
id="maxDays" class="form-control"
|
||||
type='number'
|
||||
ng-model="schedule.maxDays"
|
||||
min = 1
|
||||
max = 3652
|
||||
aria-describedby="maxDaysHelp"
|
||||
required
|
||||
>
|
||||
</input>
|
||||
<small id="maxDaysHelp" class='form-text text-muted'>
|
||||
Maximum number of days to precalculate the schedule. Note that the length of the schedule is also bounded by the maximum number of programs allowed in a channel.
|
||||
</small>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" ng-show='!loading'>
|
||||
<div class='text-danger small'>{{error}}</div>
|
||||
<button type="button" class="btn btn-sm btn-link" ng-click="finished(true)">Cancel</button>
|
||||
<button ng-disabled='disableCreateLineup()' type="button" class="btn btn-sm btn-primary" ng-click="finished(false);">Create Lineup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -19,7 +19,7 @@
|
||||
<p class="text-center text-danger">No channels found. Click the <span class="fa fa-plus"></span> to create a channel.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-repeat="x in channels" ng-click="selectChannel($index)" style="cursor: pointer; height: 3em" ng-class="{'stealth-channel':(x.stealth===true)}" >
|
||||
<tr ng-repeat="x in channels" ng-click="selectChannel($index)" style="cursor: pointer; height: 3em" ng-class="{'stealth-channel':(x.stealth===true), 'channel-row' : true }" >
|
||||
<td style='height: 3em'>
|
||||
<div class="loader" ng-if="x.pending"></div>
|
||||
<span ng-show="!x.pending">{{x.number}}</span>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<p class="text-center text-danger">No filler sources set. Click the <span class="fa fa-plus"></span> to add filler lists.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-repeat="x in fillers" ng-click="selectFiller($index)" style="cursor: pointer; height: 3em" >
|
||||
<tr class='filler-row' ng-repeat="x in fillers" ng-click="selectFiller($index)" style="cursor: pointer; height: 3em" >
|
||||
<td style='height: 3em'>
|
||||
<div class="loader" ng-if="x.pending"></div>
|
||||
<span ng-show="!x.pending">{{x.name}}</span>
|
||||
|
||||
@ -234,6 +234,22 @@ module.exports = function ($http) {
|
||||
return d.data;
|
||||
},
|
||||
|
||||
/*======================================================================
|
||||
* Channel Tool Services
|
||||
*/
|
||||
calculateTimeSlots: async( programs, schedule) => {
|
||||
let d = await $http( {
|
||||
method: "POST",
|
||||
url : "/api/channel-tools/time-slots",
|
||||
data: {
|
||||
programs: programs,
|
||||
schedule: schedule,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
} );
|
||||
return d.data;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
18
web/services/resolution-options.js
Normal file
18
web/services/resolution-options.js
Normal file
@ -0,0 +1,18 @@
|
||||
module.exports = function () {
|
||||
return {
|
||||
get: () => {
|
||||
return [
|
||||
{id:"420x420",description:"420x420 (1:1)"},
|
||||
{id:"480x270",description:"480x270 (HD1080/16 16:9)"},
|
||||
{id:"576x320",description:"576x320 (18:10)"},
|
||||
{id:"640x360",description:"640x360 (nHD 16:9)"},
|
||||
{id:"720x480",description:"720x480 (WVGA 3:2)"},
|
||||
{id:"800x600",description:"800x600 (SVGA 4:3)"},
|
||||
{id:"1024x768",description:"1024x768 (WXGA 4:3)"},
|
||||
{id:"1280x720",description:"1280x720 (HD 16:9)"},
|
||||
{id:"1920x1080",description:"1920x1080 (FHD 16:9)"},
|
||||
{id:"3840x2160",description:"3840x2160 (4K 16:9)"},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user