diff --git a/README.md b/README.md index e2c098e..5db83f2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# dizqueTV 1.1.4 +# dizqueTV 1.2.3 ![Discord](https://img.shields.io/discord/711313431457693727?logo=discord&logoColor=fff&style=flat-square) ![GitHub top language](https://img.shields.io/github/languages/top/vexorian/dizquetv?logo=github&style=flat-square) ![Docker Pulls](https://img.shields.io/docker/pulls/vexorian/dizquetv?logo=docker&logoColor=fff&style=flat-square) Create live TV channel streams from media on your Plex servers. diff --git a/src/api.js b/src/api.js index 006c8ae..9365120 100644 --- a/src/api.js +++ b/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` } } diff --git a/src/constants.js b/src/constants.js index 898d3a1..997c6a7 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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" } diff --git a/src/database-migration.js b/src/database-migration.js index 90f9995..1c6f0ef 100644 --- a/src/database-migration.js +++ b/src/database-migration.js @@ -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, diff --git a/src/ffmpeg-info.js b/src/ffmpeg-info.js index ef264fd..00cb1c9 100644 --- a/src/ffmpeg-info.js +++ b/src/ffmpeg-info.js @@ -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"; diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 801b9ba..b884c1c 100644 --- a/src/ffmpeg.js +++ b/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") } } } diff --git a/src/helperFuncs.js b/src/helperFuncs.js index e1708ad..abae7d0 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -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; } + diff --git a/src/plex-player.js b/src/plex-player.js index e204476..9bde669 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -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(); diff --git a/src/program-player.js b/src/program-player.js index 576cff9..260ff10 100644 --- a/src/program-player.js +++ b/src/program-player.js @@ -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() { diff --git a/src/services/time-slots-service.js b/src/services/time-slots-service.js new file mode 100644 index 0000000..9868ea2 --- /dev/null +++ b/src/services/time-slots-service.js @@ -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(), + } + +} + + + + diff --git a/src/tv-guide-service.js b/src/tv-guide-service.js index a252899..9227827 100644 --- a/src/tv-guide-service.js +++ b/src/tv-guide-service.js @@ -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 \ No newline at end of file +module.exports = TVGuideService diff --git a/src/video.js b/src/video.js index 4ebab8f..d82073d 100644 --- a/src/video.js +++ b/src/video.js @@ -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, } diff --git a/web/app.js b/web/app.js index b608412..af58820 100644 --- a/web/app.js +++ b/web/app.js @@ -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')) diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index cd4b9e5..0cb41b8 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -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; + } + }, + } } } diff --git a/web/directives/ffmpeg-settings.js b/web/directives/ffmpeg-settings.js index 23dfdc4..f4f66c7 100644 --- a/web/directives/ffmpeg-settings.js +++ b/web/directives/ffmpeg-settings.js @@ -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"}, + ]; + } } } \ No newline at end of file diff --git a/web/directives/flex-config.js b/web/directives/flex-config.js index ba0a6e0..0009264 100644 --- a/web/directives/flex-config.js +++ b/web/directives/flex-config.js @@ -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); - } } }; diff --git a/web/directives/time-slots-schedule-editor.js b/web/directives/time-slots-schedule-editor.js new file mode 100644 index 0000000..9abe540 --- /dev/null +++ b/web/directives/time-slots-schedule-editor.js @@ -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.", + } + } +} + + diff --git a/web/public/style.css b/web/public/style.css index 518db8a..12e6445 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -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); +} diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 8e38fa3..f88c4b6 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -1,13 +1,26 @@