Merge with branch 'vexorian/development'

This commit is contained in:
vexorian 2020-08-09 15:14:25 -04:00
commit 9fb1498225
14 changed files with 745 additions and 310 deletions

View File

@ -6,7 +6,7 @@ const express = require('express')
const bodyParser = require('body-parser')
const api = require('./src/api')
const defaultSettings = require('./src/defaultSettings')
const dbMigration = require('./src/database-migration');
const video = require('./src/video')
const HDHR = require('./src/hdhr')
@ -33,7 +33,7 @@ if (!fs.existsSync(process.env.DATABASE))
if(!fs.existsSync(path.join(process.env.DATABASE, 'images')))
fs.mkdirSync(path.join(process.env.DATABASE, 'images'))
db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings'])
db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings', 'db-version'])
initDB(db)
@ -102,8 +102,7 @@ app.listen(process.env.PORT, () => {
})
function initDB(db) {
let ffmpegSettings = db['ffmpeg-settings'].find()
let plexSettings = db['plex-settings'].find()
dbMigration.initDB(db);
if (!fs.existsSync(process.env.DATABASE + '/font.ttf')) {
let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/font.ttf')))
fs.writeFileSync(process.env.DATABASE + '/font.ttf', data)
@ -121,54 +120,5 @@ function initDB(db) {
fs.writeFileSync(process.env.DATABASE + '/images/generic-offline-screen.png', data)
}
var ffmpegRepaired = defaultSettings.repairFFmpeg(ffmpegSettings);
if (ffmpegRepaired.hasBeenRepaired) {
var fixed = ffmpegRepaired.fixedConfig;
var i = fixed._id;
if ( i == null || typeof(i) == 'undefined') {
db['ffmpeg-settings'].save(fixed);
} else {
db['ffmpeg-settings'].update( { _id: i } , fixed );
}
}
if (plexSettings.length === 0) {
db['plex-settings'].save({
streamPath: 'plex',
debugLogging: true,
directStreamBitrate: '40000',
transcodeBitrate: '3000',
mediaBufferSize: 1000,
transcodeMediaBufferSize: 20000,
maxPlayableResolution: "1920x1080",
maxTranscodeResolution: "1920x1080",
videoCodecs: 'h264,hevc,mpeg2video',
audioCodecs: 'ac3',
maxAudioChannels: '2',
audioBoost: '100',
enableSubtitles: false,
subtitleSize: '100',
updatePlayStatus: false,
streamProtocol: 'http',
forceDirectPlay: false,
pathReplace: '',
pathReplaceWith: ''
})
}
let xmltvSettings = db['xmltv-settings'].find()
if (xmltvSettings.length === 0) {
db['xmltv-settings'].save({
cache: 12,
refresh: 4,
file: `${process.env.DATABASE}/xmltv.xml`
})
}
let hdhrSettings = db['hdhr-settings'].find()
if (hdhrSettings.length === 0) {
db['hdhr-settings'].save({
tunerCount: 1,
autoDiscovery: true
})
}
}

View File

@ -1,7 +1,7 @@
const express = require('express')
const fs = require('fs')
const defaultSettings = require('./defaultSettings')
const databaseMigration = require('./database-migration');
const channelCache = require('./channel-cache')
const constants = require('./constants')
@ -74,7 +74,7 @@ function api(db, xmltvInterval) {
res.send(ffmpeg)
})
router.post('/api/ffmpeg-settings', (req, res) => { // RESET
let ffmpeg = defaultSettings.ffmpeg();
let ffmpeg = databaseMigration.defaultFFMPEG() ;
ffmpeg.ffmpegPath = req.body.ffmpegPath;
db['ffmpeg-settings'].update({ _id: req.body._id }, ffmpeg)
ffmpeg = db['ffmpeg-settings'].find()[0]

422
src/database-migration.js Normal file
View File

@ -0,0 +1,422 @@
/**
* Setting up channels is a lot of work. Same goes for configuration.
* Also, it's always healthy to be releasing versions frequently and have people
* test them frequently. But if losing data after upgrades is a common ocurrence
* then users will just not want to give new versions a try. That's why
* starting with version 0.0.54 and forward we don't want users to be losing
* data just because they upgraded their channels. In order to accomplish that
* we need some work put into the db structure so that it is capable of
* being updated.
*
* Even if we reached a point like in 0.0.53 where the old channels have to
* be completely deleted and can't be recovered. Then that's what the migration
* should do. Remove the information that can't be recovered and notify the
* user about it.
*
* A lot of this will look like overkill during the first versions it's used
* but with time it will be worth it, really.
*
***/
const TARGET_VERSION = 200;
const STEPS = [
// [v, v2, x] : if the current version is v, call x(db), and version becomes v2
[ 0, 100, (db) => basicDB(db) ],
[ 100, 200, (db) => commercialsRemover(db) ],
]
function basicDB(db) {
//this one should either try recovering the db from a very old version
//or buildl a completely empty db at version 0
let ffmpegSettings = db['ffmpeg-settings'].find()
let plexSettings = db['plex-settings'].find()
var ffmpegRepaired = repairFFmpeg0(ffmpegSettings);
if (ffmpegRepaired.hasBeenRepaired) {
var fixed = ffmpegRepaired.fixedConfig;
var i = fixed._id;
if ( i == null || typeof(i) == 'undefined') {
db['ffmpeg-settings'].save(fixed);
} else {
db['ffmpeg-settings'].update( { _id: i } , fixed );
}
}
if (plexSettings.length === 0) {
db['plex-settings'].save({
streamPath: 'plex',
debugLogging: true,
directStreamBitrate: '40000',
transcodeBitrate: '3000',
mediaBufferSize: 1000,
transcodeMediaBufferSize: 20000,
maxPlayableResolution: "1920x1080",
maxTranscodeResolution: "1920x1080",
videoCodecs: 'h264,hevc,mpeg2video',
audioCodecs: 'ac3',
maxAudioChannels: '2',
audioBoost: '100',
enableSubtitles: false,
subtitleSize: '100',
updatePlayStatus: false,
streamProtocol: 'http',
forceDirectPlay: false,
pathReplace: '',
pathReplaceWith: ''
})
}
let plexServers = db['plex-servers'].find();
//plex servers exist, but they could be old
let newPlexServers = {};
for (let i = 0; i < plexServers.length; i++) {
let plex = plexServers[i];
if ( (typeof(plex.connections) === 'undefined') || plex.connections.length==0) {
let newPlex = attemptMigratePlexFrom51(plex);
newPlexServers[plex.name] = newPlex;
db['plex-servers'].update( { _id: plex._id }, newPlex );
}
}
if (Object.keys(newPlexServers).length !== 0) {
migrateChannelsFrom51(db, newPlexServers);
}
let xmltvSettings = db['xmltv-settings'].find()
if (xmltvSettings.length === 0) {
db['xmltv-settings'].save({
cache: 12,
refresh: 4,
file: `${process.env.DATABASE}/xmltv.xml`
})
}
let hdhrSettings = db['hdhr-settings'].find()
if (hdhrSettings.length === 0) {
db['hdhr-settings'].save({
tunerCount: 1,
autoDiscovery: true
})
}
}
function migrateChannelsFrom51(db, newPlexServers) {
console.log("Attempting to migrate channels from old format. This may take a while...");
let channels = db['channels'].find();
function fix(program) {
if (typeof(program.plexFile) === 'undefined') {
let file = program.file;
program.plexFile = file.slice( program.server.uri.length );
let i = 0;
while (i < program.plexFile.length && program.plexFile.charAt(i) != '?') {
i++;
}
program.plexFile = program.plexFile.slice(0, i);
delete program.file;
}
};
for (let i = 0; i < channels.length; i++) {
let channel = channels[i];
let programs = channel.programs;
let newPrograms = [];
for (let j = 0; j < programs.length; j++) {
let program = programs[j];
if (
(typeof(program.server) === 'undefined') || (typeof(program.server.name) === 'undefined')
|| ( ( typeof(program.plexFile)==='undefined' ) && ( typeof(program.file)==='undefined' ) )
) {
let duration = program.duration;
if (typeof(duration) !== 'undefined') {
console.log(`A program in channel ${channel.number} doesn't have server/plex file information. Replacing it with Flex time`);
program = {
isOffline : true,
actualDuration : duration,
duration : duration,
};
newPrograms.push( program );
} else {
console.log(`A program in channel ${channel.number} is completely invalid and has been removed.`);
}
} else {
if ( typeof(newPlexServers[program.server.name]) !== 'undefined' ) {
program.server = newPlexServers[program.server.name];
} else {
console.log("turns out '" + program.server.name + "' is not in " + JSON.stringify(newPlexServers) );
}
let commercials = program.commercials;
fix(program);
if ( (typeof(commercials)==='undefined') || (commercials.length == 0)) {
commercials = [];
}
let newCommercials = [];
for (let k = 0; k < commercials.length; k++) {
let commercial = commercials[k];
if (
(typeof(commercial.server) === 'undefined')
|| (typeof(commercial.server.name) === 'undefined')
|| ( ( typeof(commercial.plexFile)==='undefined' ) && ( typeof(commercial.file)==='undefined' ) )
) {
console.log(`A commercial in channel ${channel.number} has invalid server/plex file information and has been removed.`);
} else {
if (typeof(newPlexServers[commercial.server.name]) !== 'undefined') {
commercial.server = newPlexServers[commercial.server.name];
}
fix(commercial);
newCommercials.push(commercial);
}
}
program.commercials = newCommercials;
newPrograms.push(program);
}
}
channel.programs = newPrograms;
db.channels.update( { number: channel.number }, channel );
}
}
function attemptMigratePlexFrom51(plex) {
console.log("Attempting to migrate existing Plex server: " + plex.name + "...");
let u = "unknown(migrated from 0.0.51)";
//most of the new variables aren't really necessary so it doesn't matter
//to replace them with placeholders
let uri = plex.protocol + "://" + plex.host + ":" + plex.port;
let newPlex = {
"name": plex.name,
"product": "Plex Media Server",
"productVersion": u,
"platform": u,
"platformVersion": u,
"device": u,
"clientIdentifier": u,
"createdAt": u,
"lastSeenAt": u,
"provides": "server",
"ownerId": null,
"sourceTitle": null,
"publicAddress": plex.host,
"accessToken": plex.token,
"owned": true,
"home": false,
"synced": false,
"relay": true,
"presence": true,
"httpsRequired": true,
"publicAddressMatches": true,
"dnsRebindingProtection": false,
"natLoopbackSupported": false,
"connections": [
{
"protocol": plex.protocol,
"address": plex.host,
"port": plex.port,
"uri": uri,
"local": true,
"relay": false,
"IPv6": false
}
],
"uri": uri,
"protocol": plex.protocol,
"address": plex.host,
"port": plex.host,
"arGuide": plex.arGuide,
"arChannels": plex.arChannels,
"_id": plex._id,
};
console.log("Sucessfully migrated plex server: " + plex.name);
return newPlex;
}
function commercialsRemover(db) {
let getKey = (program) => {
let key = program.key;
return (typeof(key) === 'undefined')? "?" : key;
}
let channels = db['channels'].find();
for (let i = 0; i < channels.length; i++) {
let channel = channels[i];
let fixedPrograms = [];
let fixedFiller = channel.fillerContent;
if ( typeof(fixedFiller) === 'undefined') {
fixedFiller = [];
}
let addedFlex = false;
let seenPrograms = {};
for (let i = 0; i < fixedFiller.length; i++) {
seenPrograms[getKey( fixedFiller[i] ) ] = true;
}
for (let j = 0; j < channel.programs.length; j++) {
let fixedProgram = channel.programs[j];
let commercials = fixedProgram.commercials;
if ( typeof(commercials) == 'undefined') {
commercials = [];
}
delete fixedProgram.commercials;
for (let k = 0; k < commercials.length; k++) {
if ( typeof(seenPrograms[ getKey( commercials[k] ) ]) === 'undefined') {
seenPrograms[ getKey( commercials[k] ) ] = true;
fixedFiller.push( commercials[k] );
}
}
let diff = fixedProgram.duration - fixedProgram.actualDuration;
fixedProgram.duration = fixedProgram.actualDuration;
fixedPrograms.push( fixedProgram );
if (diff > 0) {
addedFlex = true;
fixedPrograms.push( {
isOffline : true,
duration : diff,
actualDuration : diff,
} );
}
}
channel.programs = fixedPrograms;
channel.fillerContent = fixedFiller;
//TODO: maybe remove duplicates?
if (addedFlex) {
//fill up some flex settings just in case
if ( typeof(channel.fillerRepeatCooldown) === 'undefined' ) {
channel.fillerRepeatCooldown = 10*60*1000;
}
if ( typeof(channel.offlineMode) === 'undefined' ) {
console.log("Added provisional fallback to channel #" + channel.number + " " + channel.name + " . You might want to tweak this value in channel configuration.");
channel.offlineMode = "pic";
channel.fallback = [ ];
channel.offlinePicture = `http://localhost:${process.env.PORT}/images/generic-offline-screen.png`
channel.offlineSoundtrack = ''
}
if ( typeof(channel.disableFillerOverlay) === 'undefined' ) {
channel.disableFillerOverlay = true;
}
}
db.channels.update( { number: channel.number }, channel );
}
}
function initDB(db) {
let dbVersion = db['db-version'].find()[0];
if (typeof(dbVersion) === 'undefined') {
dbVersion = { 'version': 0 };
}
while (dbVersion.version != TARGET_VERSION) {
let ran = false;
for (let i = 0; i < STEPS.length; i++) {
if (STEPS[i][0] == dbVersion.version) {
ran = true;
console.log("Migrating from db version " + dbVersion.version + " to: " + STEPS[i][1] + "...");
try {
STEPS[i][2](db);
if (typeof(dbVersion._id) === 'undefined') {
db['db-version'].save( {'version': STEPS[i][1] } );
} else {
db['db-version'].update( {_id: dbVersion._id} , {'version': STEPS[i][1] } );
}
dbVersion = db['db-version'].find()[0];
console.log("Done migrating db to version : " + dbVersion.version);
} catch (e) {
console.log("Error during migration. Sorry, we can't continue. Wiping out your .pseudotv folder might be a workaround, but that means you lose all your settings.", e);
throw Error("Migration error, step=" + dbVersion.version);
}
} else {
console.log(STEPS[i][0], " != " , dbVersion.version );
}
}
if (!ran) {
throw Error("Unable to find migration step from version: " + dbVersion.version );
}
}
console.log(`DB Version correct: ${dbVersion.version}` );
}
function ffmpeg() {
return {
//How default ffmpeg settings should look
configVersion: 5,
ffmpegPath: "/usr/bin/ffmpeg",
threads: 4,
concatMuxDelay: "0",
logFfmpeg: false,
enableFFMPEGTranscoding: true,
audioVolumePercent: 100,
videoEncoder: "mpeg2video",
audioEncoder: "ac3",
targetResolution: "1920x1080",
videoBitrate: 10000,
videoBufSize: 2000,
audioBitrate: 192,
audioBufSize: 50,
audioSampleRate: 48,
audioChannels: 2,
errorScreen: "pic",
errorAudio: "silent",
normalizeVideoCodec: true,
normalizeAudioCodec: true,
normalizeResolution: true,
normalizeAudio: true,
}
}
//This initializes ffmpeg config for db version 0
//there used to be a concept of configVersion which worked like this database
//migration thing but only for settings. Nowadays that sort of migration should
//be done at a db-version level.
function repairFFmpeg0(existingConfigs) {
var hasBeenRepaired = false;
var currentConfig = {};
var _id = null;
if (existingConfigs.length === 0) {
currentConfig = {};
} else {
currentConfig = existingConfigs[0];
_id = currentConfig._id;
}
if (
(typeof(currentConfig.configVersion) === 'undefined')
|| (currentConfig.configVersion < 3)
) {
hasBeenRepaired = true;
currentConfig = ffmpeg();
currentConfig._id = _id;
}
if (currentConfig.configVersion == 3) {
//migrate from version 3 to 4
hasBeenRepaired = true;
//new settings:
currentConfig.audioBitrate = 192;
currentConfig.audioBufSize = 50;
currentConfig.audioChannels = 2;
currentConfig.audioSampleRate = 48;
//this one has been renamed:
currentConfig.normalizeAudio = currentConfig.alignAudio;
currentConfig.configVersion = 4;
}
if (currentConfig.configVersion == 4) {
//migrate from version 4 to 5
hasBeenRepaired = true;
//new settings:
currentConfig.enableFFMPEGTranscoding = true;
currentConfig.normalizeVideoCodec = true;
currentConfig.normalizeAudioCodec = true;
currentConfig.normalizeResolution = true;
currentConfig.normalizeAudio = true;
currentConfig.configVersion = 5;
}
return {
hasBeenRepaired: hasBeenRepaired,
fixedConfig : currentConfig,
};
}
module.exports = {
initDB: initDB,
defaultFFMPEG: ffmpeg,
}

View File

@ -1,73 +0,0 @@
function ffmpeg() {
return {
//a record of the config version will help migrating between versions
// in the future. Always increase the version when new ffmpeg configs
// are added.
//
// configVersion 3: First versioned config.
//
configVersion: 4,
ffmpegPath: "/usr/bin/ffmpeg",
threads: 4,
concatMuxDelay: "0",
logFfmpeg: false,
enableFFMPEGTranscoding: false,
audioVolumePercent: 100,
videoEncoder: "mpeg2video",
audioEncoder: "ac3",
targetResolution: "1920x1080",
videoBitrate: 10000,
videoBufSize: 2000,
audioBitrate: 192,
audioBufSize: 50,
audioSampleRate: 48,
audioChannels: 2,
errorScreen: "pic",
errorAudio: "silent",
normalizeVideoCodec: false,
normalizeAudioCodec: false,
normalizeResolution: false,
normalizeAudio: false,
}
}
function repairFFmpeg(existingConfigs) {
var hasBeenRepaired = false;
var currentConfig = {};
var _id = null;
if (existingConfigs.length === 0) {
currentConfig = {};
} else {
currentConfig = existingConfigs[0];
_id = currentConfig._id;
}
if (
(typeof(currentConfig.configVersion) === 'undefined')
|| (currentConfig.configVersion < 3)
) {
hasBeenRepaired = true;
currentConfig = ffmpeg();
currentConfig._id = _id;
}
if (currentConfig.configVersion == 3) {
//migrate from version 3 to 4
hasBeenRepaired = true;
//new settings:
currentConfig.audioBitrate = 192;
currentConfig.audioBufSize = 50;
currentConfig.audioChannels = 2;
currentConfig.audioSampleRate = 48;
//this one has been renamed:
currentConfig.normalizeAudio = currentConfig.alignAudio;
currentConfig.configVersion = 4;
}
return {
hasBeenRepaired: hasBeenRepaired,
fixedConfig : currentConfig,
};
}
module.exports = {
ffmpeg: ffmpeg,
repairFFmpeg: repairFFmpeg,
}

View File

@ -2,14 +2,14 @@ const spawn = require('child_process').spawn
const events = require('events')
//they can customize this by modifying the picture in .pseudotv folder
const ERROR_PICTURE_PATH = 'http://localhost:8000/images/generic-error-screen.png'
const MAXIMUM_ERROR_DURATION_MS = 60000;
class FFMPEG extends events.EventEmitter {
constructor(opts, channel) {
super()
this.opts = opts
this.opts = opts;
this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`;
if (! this.opts.enableFFMPEGTranscoding) {
//this ensures transcoding is completely disabled even if
// some settings are true
@ -34,15 +34,15 @@ class FFMPEG extends events.EventEmitter {
this.volumePercent = this.opts.audioVolumePercent;
}
async spawnConcat(streamUrl) {
this.spawn(streamUrl, undefined, undefined, undefined, false, false, undefined, true)
this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true)
}
async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) {
this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false);
}
async spawnError(title, subtitle, duration) {
if (! this.opts.enableFFMPEGTranscoding || this.opts.errorScreen == 'kill') {
console.log("error: " + title + " ; " + subtitle);
this.emit('error', { code: -1, cmd: `error stream disabled. ${title} ${subtitles}`} )
console.error("error: " + title + " ; " + subtitle);
this.emit('error', { code: -1, cmd: `error stream disabled. ${title} ${subtitle}`} )
return;
}
if (typeof(duration) === 'undefined') {
@ -61,7 +61,7 @@ class FFMPEG extends events.EventEmitter {
async spawnOffline(duration) {
if (! this.opts.enableFFMPEGTranscoding) {
console.log("The channel has an offline period scheduled for this time slot. FFMPEG transcoding is disabled, so it is not possible to render an offline screen. Ending the stream instead");
this.emit('end', { code: -1, cmd: `error stream disabled. ${title} ${subtitles}`} )
this.emit('end', { code: -1, cmd: `offline stream disabled.`} )
return;
}
@ -171,7 +171,7 @@ class FFMPEG extends events.EventEmitter {
} else {//'pic'
ffmpegArgs.push(
'-loop', '1',
'-i', `${ERROR_PICTURE_PATH}`,
'-i', `${this.errorPicturePath}`,
);
videoComplex = `;[0:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`;
}
@ -226,7 +226,6 @@ class FFMPEG extends events.EventEmitter {
videoComplex += `;[1:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]`
currentVideo = '[comb]';
}
if (this.volumePercent != 100) {
var f = this.volumePercent / 100.0;
audioComplex += `;${currentAudio}volume=${f}[boosted]`;
@ -332,16 +331,12 @@ class FFMPEG extends events.EventEmitter {
ffmpegArgs.push(`pipe:1`)
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs)
let doLogs = this.opts.logFfmpeg && !isConcatPlaylist;
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } );
this.ffmpeg.stdout.on('data', (chunk) => {
this.sentData = true;
this.emit('data', chunk)
})
if (this.opts.logFfmpeg) {
this.ffmpeg.stderr.on('data', (chunk) => {
process.stderr.write(chunk)
})
}
this.ffmpeg.on('close', (code) => {
if (code === null) {
this.emit('close', code)

View File

@ -110,85 +110,18 @@ function createLineup(obj, channel, isFirst) {
timeElapsed = 0
}
let programStartTimes = [0, activeProgram.actualDuration * .25, activeProgram.actualDuration * .50, activeProgram.actualDuration * .75, activeProgram.actualDuration]
let commercials = [[], [], [], [], []]
for (let i = 0, l = activeProgram.commercials.length; i < l; i++) // Sort the commercials into their own commerical "slot" array
commercials[activeProgram.commercials[i].commercialPosition].push(activeProgram.commercials[i])
let foundFirstVideo = false
let progTimeElapsed = 0
for (let i = 0, l = commercials.length; i < l; i++) { // Foreach commercial slot
for (let y = 0, l2 = commercials[i].length; y < l2; y++) { // Foreach commercial in that slot
if (!foundFirstVideo && timeElapsed - commercials[i][y].duration < 0) { // If havent already found the starting video AND the this is a the starting video
foundFirstVideo = true // We found the fucker
lineup.push({
type: 'commercial',
title: commercials[i][y].title,
key: commercials[i][y].key,
plexFile: commercials[i][y].plexFile,
file: commercials[i][y].file,
ratingKey: commercials[i][y].ratingKey,
start: timeElapsed, // start time will be the time elapsed, cause this is the first video
streamDuration: commercials[i][y].duration - timeElapsed, // stream duration set accordingly
duration: commercials[i][y].duration,
server: commercials[i][y].server
})
} else if (foundFirstVideo) { // Otherwise, if weve already found the starting video
lineup.push({ // just add the video, starting at 0, playing the entire duration
type: 'commercial',
title: commercials[i][y].title,
key: commercials[i][y].key,
plexFile: commercials[i][y].plexFile,
file: commercials[i][y].file,
ratingKey: commercials[i][y].ratingKey,
start: 0,
streamDuration: commercials[i][y].actualDuration,
duration: commercials[i][y].actualDuration,
server: commercials[i][y].server
})
} else { // Otherwise, this bitch has already been played.. Reduce the time elapsed by its duration
timeElapsed -= commercials[i][y].actualDuration
}
}
if (i < l - 1) { // The last commercial slot is END, so dont write a program..
if (!foundFirstVideo && timeElapsed - (programStartTimes[i + 1] - programStartTimes[i]) < 0) { // same shit as above..
foundFirstVideo = true
lineup.push({
type: 'program',
title: activeProgram.title,
key: activeProgram.key,
plexFile: activeProgram.plexFile,
file: activeProgram.file,
ratingKey: activeProgram.ratingKey,
start: progTimeElapsed + timeElapsed, // add the duration of already played program chunks to the timeElapsed
streamDuration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed,
duration: activeProgram.actualDuration,
server: activeProgram.server
})
} else if (foundFirstVideo) {
if (lineup[lineup.length - 1].type === 'program') { // merge consecutive programs..
lineup[lineup.length - 1].streamDuration += (programStartTimes[i + 1] - programStartTimes[i])
} else {
lineup.push({
return [ {
type: 'program',
title: activeProgram.title,
key: activeProgram.key,
plexFile: activeProgram.plexFile,
file: activeProgram.file,
ratingKey: activeProgram.ratingKey,
start: programStartTimes[i],
streamDuration: (programStartTimes[i + 1] - programStartTimes[i]),
start: timeElapsed,
streamDuration: activeProgram.actualDuration - timeElapsed,
duration: activeProgram.actualDuration,
server: activeProgram.server
})
}
} else {
timeElapsed -= (programStartTimes[i + 1] - programStartTimes[i])
progTimeElapsed += (programStartTimes[i + 1] - programStartTimes[i]) // add the duration of already played program chunks together
}
}
}
return lineup
} ];
}
function pickRandomWithMaxDuration(channel, list, maxDuration) {

View File

@ -39,7 +39,7 @@ class PlexPlayer {
try {
let plexSettings = db['plex-settings'].find()[0];
let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem);
let plexTranscoder = new PlexTranscoder(plexSettings, channel, lineupItem);
this.plexTranscoder = plexTranscoder;
let enableChannelIcon = this.context.enableChannelIcon;
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options

View File

@ -2,9 +2,14 @@ const { v4: uuidv4 } = require('uuid');
const axios = require('axios');
class PlexTranscoder {
constructor(settings, lineupItem) {
constructor(settings, channel, lineupItem) {
this.session = uuidv4()
this.device = "channel-" + channel.number;
this.deviceName = this.device;
this.clientIdentifier = this.session.replace(/-/g,"").slice(0,16) + "-org-pseudotv-" + process.platform;
this.product = "PseudoTV";
this.settings = settings
this.log("Plex transcoder initiated")
@ -12,7 +17,9 @@ class PlexTranscoder {
this.key = lineupItem.key
this.plexFile = `${lineupItem.server.uri}${lineupItem.plexFile}?X-Plex-Token=${lineupItem.server.accessToken}`
this.file = lineupItem.file.replace(settings.pathReplace, settings.pathReplaceWith)
if (typeof(lineupItem.file)!=='undefined') {
this.file = lineupItem.file.replace(settings.pathReplace, settings.pathReplaceWith)
}
this.transcodeUrlBase = `${lineupItem.server.uri}/video/:/transcode/universal/start.m3u8?`
this.ratingKey = lineupItem.ratingKey
this.currTimeMs = lineupItem.start
@ -60,8 +67,11 @@ class PlexTranscoder {
// Update transcode decision for session
await this.getDecision(stream.directPlay);
stream.streamUrl = (this.settings.streamPath === 'direct') ? this.file : this.plexFile;
if (typeof(stream.streamUrl) == 'undefined') {
throw Error("Direct path playback is not possible for this program because it was registered at a time when the direct path settings were not set. To fix this, you must either revert the direct path setting or rebuild this channel.");
}
} else if (this.isVideoDirectStream() === false) {
this.log("Decision: File can direct play")
this.log("Decision: Should transcode")
// Change transcoding arguments to be the user chosen transcode parameters
this.setTranscodingArgs(stream.directPlay, false, deinterlace)
// Update transcode decision for session
@ -127,8 +137,12 @@ add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.height&va
let clientProfile_enc=encodeURIComponent(clientProfile)
this.transcodingArgs=`X-Plex-Platform=${profileName}&\
X-Plex-Product=${this.product}&\
X-Plex-Client-Platform=${profileName}&\
X-Plex-Client-Profile-Name=${profileName}&\
X-Plex-Device-Name=${this.deviceName}&\
X-Plex-Device=${this.device}&\
X-Plex-Client-Identifier=${this.clientIdentifier}&\
X-Plex-Platform=${profileName}&\
X-Plex-Token=${this.server.accessToken}&\
X-Plex-Client-Profile-Extra=${clientProfile_enc}&\
@ -270,12 +284,13 @@ state=${this.playState}&\
key=${this.key}&\
time=${this.currTimeMs}&\
duration=${this.duration}&\
X-Plex-Product=${this.product}&\
X-Plex-Platform=${profileName}&\
X-Plex-Client-Platform=${profileName}&\
X-Plex-Client-Profile-Name=${profileName}&\
X-Plex-Device-Name=PseudoTV-Plex&\
X-Plex-Device=PseudoTV-Plex&\
X-Plex-Client-Identifier=${this.session}&\
X-Plex-Device-Name=${this.deviceName}&\
X-Plex-Device=${this.device}&\
X-Plex-Client-Identifier=${this.clientIdentifier}&\
X-Plex-Platform=${profileName}&\
X-Plex-Token=${this.server.accessToken}`;

View File

@ -9,6 +9,7 @@ module.exports = function ($timeout, $location) {
onDone: "=onDone"
},
link: function (scope, element, attrs) {
scope.hasFlex = false;
scope.showHelp = false;
scope._frequencyModified = false;
scope._frequencyMessage = "";
@ -183,7 +184,19 @@ module.exports = function ($timeout, $location) {
updateChannelDuration()
}
scope.dateForGuide = (date) => {
return date.toLocaleString();
let t = date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
if (t.charCodeAt(1) == 58) {
t = "0" + t;
}
return date.toLocaleDateString(undefined,{
year: "numeric",
month: "2-digit",
day: "2-digit"
}) + " " + t;
}
scope.sortByDate = () => {
scope.removeOffline();
@ -254,6 +267,88 @@ module.exports = function ($timeout, $location) {
scope.channel.programs = tmpProgs
updateChannelDuration()
}
scope.describeFallback = () => {
if (scope.channel.offlineMode === 'pic') {
if (
(typeof(scope.channel.offlineSoundtrack) !== 'undefined')
&& (scope.channel.offlineSoundtrack.length > 0)
) {
return "pic+sound";
} else {
return "pic";
}
} else {
return "clip";
}
}
scope.programSquareStyle = (program) => {
let background ="";
if (program.isOffline) {
background = "rgb(255, 255, 255)";
} else {
let r = 0, g = 0, b = 0, r2=0, g2=0,b2=0;
let i = 0;
let angle = 45;
let w = 3;
if (program.type === 'episode') {
let h = Math.abs(scope.getHashCode(program.showTitle, false));
let h2 = Math.abs(scope.getHashCode(program.showTitle, true));
r = h % 256;
g = (h / 256) % 256;
b = (h / (256*256) ) % 256;
i = h % 360;
r2 = (h2 / (256*256) ) % 256;
g2 = (h2 / (256*256) ) % 256;
b2 = (h2 / (256*256) ) % 256;
angle = -90 + h % 180
} else {
r = 10, g = 10, b = 10;
r2 = 245, g2 = 245, b2 = 245;
angle = 45;
w = 6;
}
let rgb1 = "rgb("+ r + "," + g + "," + b +")";
let rgb2 = "rgb("+ r2 + "," + g2 + "," + b2 +")"
background = "repeating-linear-gradient( " + angle + "deg, " + rgb1 + ", " + rgb1 + " " + w + "px, " + rgb2 + " " + w + "px, " + rgb2 + " " + (w*2) + "px)";
}
let ems = Math.pow( Math.min(24*60*60*1000, program.actualDuration), 0.7 );
ems = ems / Math.pow(5*60*1000., 0.7);
ems = Math.max( 0.25 , ems);
let top = Math.max(0.0, (1.75 - ems) / 2.0) ;
if (top == 0.0) {
top = "1px";
} else {
top = top + "em";
}
return {
'width': '0.5em',
'height': ems + 'em',
'margin-right': '0.50em',
'background': background,
'border': '1px solid black',
'margin-top': top,
'margin-bottom': '1px',
};
}
scope.getHashCode = (s, rev) => {
var hash = 0;
if (s.length == 0) return hash;
let inc = 1, st = 0, e = s.length;
if (rev) {
inc = -1, st = e - 1, e = -1;
}
for (var i = st; i != e; i+= inc) {
hash = s.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
scope.nightChannel = (a, b) => {
let o =(new Date()).getTimezoneOffset() * 60 * 1000;
let m = 24*60*60*1000;
@ -650,10 +745,14 @@ module.exports = function ($timeout, $location) {
function updateChannelDuration() {
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.duration += scope.channel.programs[i].duration
scope.channel.programs[i].stop = new Date(scope.channel.startTime.valueOf() + scope.channel.duration)
if (scope.channel.programs[i].isOffline) {
scope.hasFlex = true;
}
}
}
scope.error = {}

View File

@ -10,6 +10,7 @@ module.exports = function ($timeout) {
onDone: "=onDone"
},
link: function (scope, element, attrs) {
scope.showTools = false;
scope.showPlexLibrary = false;
scope.showFallbackPlexLibrary = false;
scope.finished = (prog) => {
@ -32,6 +33,28 @@ module.exports = function ($timeout) {
scope.onDone(JSON.parse(angular.toJson(prog)))
scope.program = null
}
scope.sortFillers = () => {
scope.program.filler.sort( (a,b) => { return a.actualDuration - b.actualDuration } );
}
scope.fillerRemoveAllFiller = () => {
scope.program.filler = [];
}
scope.fillerRemoveDuplicates = () => {
function getKey(p) {
return p.server.uri + "|" + p.server.accessToken + "|" + p.plexFile;
}
let seen = {};
let newFiller = [];
for (let i = 0; i < scope.program.filler.length; i++) {
let p = scope.program.filler[i];
let k = getKey(p);
if ( typeof(seen[k]) === 'undefined') {
seen[k] = true;
newFiller.push(p);
}
}
scope.program.filler = newFiller;
}
scope.importPrograms = (selectedPrograms) => {
for (let i = 0, l = selectedPrograms.length; i < l; i++) {
selectedPrograms[i].commercials = []
@ -55,6 +78,29 @@ module.exports = function ($timeout) {
date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here
return date.toISOString().substr(11, 8);
}
scope.programSquareStyle = (program, dash) => {
let background = "rgb(255, 255, 255)";
let ems = Math.pow( Math.min(60*60*1000, program.actualDuration), 0.7 );
ems = ems / Math.pow(1*60*1000., 0.7);
ems = Math.max( 0.25 , ems);
let top = Math.max(0.0, (1.75 - ems) / 2.0) ;
if (top == 0.0) {
top = "1px";
}
let solidOrDash = (dash? 'dashed' : 'solid');
return {
'width': '0.5em',
'height': ems + 'em',
'margin-right': '0.50em',
'background': background,
'border': `1px ${solidOrDash} black`,
'margin-top': top,
'margin-bottom': '1px',
};
}
}
};
}

View File

@ -17,8 +17,9 @@
}
.flex-pull-right {
margin-left: auto;
padding-right: 20px
padding-right: 0.2em
}
.list-group-item-video {
background-color: rgb(70, 70, 70);
border-top: 1px solid #daa104;
@ -61,4 +62,34 @@
display: inline-block;
text-align: center;
cursor: pointer;
}
.program-start {
margin-right: 2.5em;
display: inline-block;
vertical-align: top;
/*color: rgb(96,96,96);*/
color: #0c5c68;
font-size: 80%;
font-weight: 400;
font-family: monospace;
}
.program-row {
align-items: start;
}
.programming-counter {
white-space: nowrap;
margin-right: 1em;
font-size: 80%;
}
.programming-counter > span {
font-weight: 300;
}
.programming-counter > b {
font-weight: 400;
}
.btn-programming-tools {
padding: .25rem .5rem;
font-size: .875rem;
line-height: 1.0;
margin-right: 0.5rem;
}

View File

@ -76,24 +76,37 @@
<hr />
<div>
<h6>Programs
<span class="small">Total: {{channel.programs.length}}</span>
<span class="btn fa fa-trash" ng-click="wipeSchedule()"></span>
<span class="badge badge-dark" style="margin-left: 15px;"
ng-show="channel.programs.length !== 0">Commercials</span>
<button class="btn btn-sm btn-secondary" style="margin-left: 10px"
ng-click="showShuffleOptions = !showShuffleOptions"
ng-show="channel.programs.length !== 0">
Tools&nbsp;&nbsp;<span
class="fa {{ showShuffleOptions ? 'fa-chevron-down' : 'fa-chevron-right'}}"></span>
</button>
<span class="pull-right">
<span class="text-danger small">{{error.programs}}</span>
<button class="btn btn-sm btn-primary" ng-click="displayPlexLibrary = true">
<span class="fa fa-plus"></span>
</button>
</span>
</h6>
<h6>Programming</h6>
<div class="flex-container">
<div class='programming-counter'>
<span class="small"><b>Programs:</b> {{channel.programs.length}}</span>
</div>
<div class='programming-counter' ng-show='hasFlex'>
<span class="small"><b>Filler:</b> {{channel.fillerContent.length}}</span>
</div>
<div class='programming-counter' ng-show='hasFlex'>
<span class="small"><b>Fallback:</b> {{describeFallback()}}</span>
</div>
<div class='flex-pull-right' />
<div>
<button class="btn btn-sm btn-secondary btn-programming-tools"
ng-click="showShuffleOptions = !showShuffleOptions"
ng-show="channel.programs.length !== 0">
<span
class="fa {{ showShuffleOptions ? 'fa-chevron-down' : 'fa-chevron-right'}}"></span>&nbsp;&nbsp;Tools
</button>
</div>
<div style='margin-left:0'>
<span class="text-danger small">{{error.programs}}</span>
<button class="btn btn-sm btn-primary" ng-click="displayPlexLibrary = true">
<span class="fa fa-plus"></span>
</button>
</div>
</div>
<div ng-init="blockCount = 1; showShuffleOptions = false" ng-show="showShuffleOptions">
<p class="text-center text-info small">
Tools to modify the schedule.
@ -118,8 +131,6 @@
<h6>Sort Release Dates</h6>
<p>Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.</p>
<h6>Remove Duplicates</h6>
<p>Removes repeated videos.</p>
<h6>Balance Shows</h6>
<p>Attempts to make the total amount of time each TV show appears in the programming as balanced as possible. This works by adding multiple copies of TV shows that have too little total time and by possibly removing duplicated episodes from TV shows that have too much total time. Note that in many situations it would be impossible to achieve perfect balance because channel duration is not infinite. Movies/Clips are treated as a single TV show. Note that this will most likely result in a larger channel and that having large channels makes some UI operations slower.</p>
@ -130,9 +141,6 @@
<h6>Add Flex</h6>
<p>Adds a &quot;Flex&quot; Time Slot. Can be configured to play a fallback screen and/or random &quot;filler&quot; content (e.g &quot;commercials&quot;, trailers, prerolls, countdowns, music videos, channel bumpers, etc.). Short Flex periods are hidden from the TV guide and are displayed as extensions to the previous program. Long Flex periods appear as the channel name in the TV guide.</p>
<h6>Remove Flex</h6>
<p>Removes any Flex periods from the schedule.</p>
<h6>Pad Times</h6>
<p>Adds Flex breaks after each TV episode or movie to ensure that the program starts at one of the allowed minute marks. For example, you can use this to ensure that all your programs start at either XX:00 times or XX:30 times. Removes any existing Flex periods before adding the new ones.</p>
@ -141,6 +149,17 @@
<h6>Add Breaks</h6>
<p>Adds Flex breaks between programs, attempting to avoid groups of consecutive programs that exceed the specified number of minutes.</p>
<h6>Remove Duplicates</h6>
<p>Removes repeated videos.</p>
<h6>Remove Flex</h6>
<p>Removes any Flex periods from the schedule.</p>
<h6>Remove All</h6>
<p>Wipes out the schedule so that you can start over.</p>
</div>
<div class="row">
@ -173,14 +192,6 @@
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortByDate()">Sort Release Dates</button>
</div>
</div>
<div class="row">
<div class="input-group col-md-6" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="removeDuplicates()">Remove Duplicates</button>
</div>
<div class="input-group col-md-6" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="removeOffline()">Remove Flex</button>
</div>
</div>
<div class="row">
<div class="input-group col-md-6" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="equalizeShows()">Balance Shows</button>
@ -235,9 +246,17 @@
</div>
</div>
<div class="row">
<div class="input-group col-md-3" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="removeDuplicates()">Remove Duplicates</button>
</div>
<div class="input-group col-md-3" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="removeOffline()">Remove Flex</button>
</div>
<div class="input-group col-md-6" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="wipeSchedule()">Remove All</button>
</div>
</div>
</div>
<div ng-if="channel.programs.length === 0">
<div class="small">Add programs to this channel by selecting media from your Plex library</div>
@ -254,9 +273,8 @@
</div>
<input ng-show="channel.programs.length &gt; 100" type="range" ng-model="minProgramIndex" min="0" max="{{ channel.programs.length - 100 }}" />
<div ng-if="minProgramIndex &gt; 0" class="list-group-item flex-container" >
<div class="small" style="width: 180px; margin-right: 5px;">
<div class="text-success">{{ dateForGuide(channel.startTime) }}</div>
<div class="text-danger">{{ dateForGuide(channel.programs[minProgramIndex-1].stop)}}</div>
<div class="program-start">
{{ dateForGuide(channel.startTime) }}
</div>
<div style="margin-right: 5px; font-weight:ligther; text-align:center">
@ -265,14 +283,14 @@
</div>
<div ng-if="minProgramIndex &lt;= $index &amp;&amp; $index &lt; minProgramIndex+100" ng-repeat="x in channel.programs" ng-click="selectProgram($index)" dnd-list="" dnd-drop="dropFunction(index , $index, item)"
>
<div class="list-group-item flex-container" dnd-draggable="x" dnd-moved="channel.programs.splice($index, 1);" dnd-effect-allowed="move" >
<div class="small" style="width: 180px; margin-right: 5px;">
<div class="text-success">{{ dateForGuide(x.start) }}</div>
<div class="text-danger">{{ dateForGuide(x.stop) }}</div>
</div>
<div style="margin-right: 15px; text-align: center" >
<span class="badge badge-dark">{{x.isOffline? channel.fillerContent.length: x.commercials.length}}</span>
<div class="list-group-item flex-container program-row" dnd-draggable="x" dnd-moved="channel.programs.splice($index, 1);" dnd-effect-allowed="move"
>
<div class="program-start">
{{ dateForGuide(x.start) }}
</div>
<div ng-style="programSquareStyle(x)" />
<div style="margin-right: 5px;" ng-hidden="x.isOffline">
{{ x.type === 'episode' ? x.showTitle + ' - S' + x.season.toString().padStart(2, '0') + 'E' + x.episode.toString().padStart(2, '0') : x.title}}
</div>
@ -283,18 +301,17 @@
</div>
</div>
<div ng-if="minProgramIndex &lt; channel.programs.length - 100" class="list-group-item flex-container" >
<div class="small" style="width: 180px; margin-right: 5px;">
<div class="text-success">{{ dateForGuide(channel.programs[minProgramIndex + 100 - 1].stop)}}</div>
<div class="text-danger">{{ dateForGuide(channel.programs[channel.programs.length-1].stop) }}</div>
<div class="program-start">
{{ dateForGuide(channel.programs[minProgramIndex + 100 - 1].stop)}}
</div>
<div style="margin-right: 5px; font-weight:ligther; text-align:center">
&#8942;
</div>
</div>
<div class="list-group-item flex-container" >
<div class="small" style="width: 180px; margin-right: 5px;">
<div class="text-success">{{ dateForGuide(channel.programs[channel.programs.length-1].stop)}}</div>
<div class="list-group-item flex-container" ng-if="channel.programs.length &gt; 0" >
<div class="program-start">
{{ dateForGuide(channel.programs[channel.programs.length-1].stop)}}
</div>
<div style="margin-right: 5px; font-weight:ligther; text-align:center">
@ -323,4 +340,4 @@
<frequency-tweak programs="_programFrequencies" message="_frequencyMessage" modified="_frequencyModified" on-done="tweakFrequencies"></frequency-tweak>
<offline-config offline-title="Add Flex Time" program="_addingOffline" on-done="finishedAddingOffline"></offline-config>
<plex-library height="300" visible="displayPlexLibrary" on-finish="importPrograms"></plex-library>
</div>
</div>

View File

@ -60,9 +60,10 @@
<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="small text-success" style="margin-right: 15px; font-family:monospace">
<div class="program-start" >
{{durationString(x.actualDuration)}}
</div>
<div ng-style="programSquareStyle(x, true)" />
<div style="margin-right: 5px;">
<strong>Fallback:</strong> {{x.title}}
</div>
@ -74,7 +75,7 @@
<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 />
<hr style='margin-top:0' />
</div>
<div>
@ -92,24 +93,49 @@
<label class="small" for="overlayDisableIcon" style="margin-bottom: 4px;">&nbsp;Disable overlay icon when playing filler&nbsp;&nbsp;</label>
</div>
<hr />
<div>
<span class="small" ng-show="program.filler.length > 0">
Filler Clips: {{program.filler.length}}<span class="btn fa fa-trash" ng-click="program.filler=[]"></span>
</span>
<button class="btn btn-sm btn-primary pull-right" ng-click="showPlexLibrary = true">
<span class="fa fa-plus"></span>
</button>
<div class="flex-container">
<div class="programming-counter small" ng-show="program.filler.length > 0">
<span class="small"><b>Filler Clips:</b> {{program.filler.length}}</span>
</div>
<div class='flex-pull-right' />
<div>
<button class="btn btn-sm btn-secondary btn-programming-tools"
ng-click="showTools = !showTools"
ng-show="channel.programs.length !== 0">
<span
class="fa {{ showTools ? 'fa-chevron-down' : 'fa-chevron-right'}}"></span>&nbsp;&nbsp;Tools
</button>
</div>
<div>
<button class="btn btn-sm btn-primary" ng-click="showPlexLibrary = true">
<span class="fa fa-plus"></span>
</button>
</div>
</div>
<div ng-show="showTools">
<div class="row">
<div class="input-group col-md-3" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortFillers()">Sort Lengths</button>
</div>
<div class="input-group col-md-3" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="fillerRemoveDuplicates()">Remove Duplicates</button>
</div>
<div class="input-group col-md-6" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="fillerRemoveAllFiller()">Remove All Filler</button>
</div>
</div>
</div>
<div ng-show="program.filler.length === 0">
<p class="text-center text-info">Click the <span class="fa fa-plus"></span> to import filler content from your Plex server(s).</p>
</div>
<div class="list-group list-group-root" dnd-list="program.filler">
<div class="list-group-item flex-container" style="cursor: default;" ng-repeat="x in program.filler" dnd-draggable="x" dnd-moved="program.filler.splice($index, 1)" dnd-effect-allowed="move">
<div class="small text-success" style="margin-right: 15px; font-family:monospace">
<div class="program-start" >
{{durationString(x.actualDuration)}}
</div>
<div ng-style="programSquareStyle(x, false)" />
<div style="margin-right: 5px;">
{{x.title}}
</div>

View File

@ -76,32 +76,6 @@
</div>
</div>
</div>
<div>
<h6 style="margin-top: 10px;">Commercials
<button class="btn btn-sm btn-primary pull-right" ng-click="showPlexLibrary = true">
<span class="fa fa-plus"></span>
</button>
</h6>
<div ng-show="program.commercials.length === 0">
<p class="text-center text-info">Click the <span class="fa fa-plus"></span> to import "commercials" from your Plex server(s).</p>
</div>
<div class="list-group list-group-root" dnd-list="program.commercials">
<div class="list-group-item flex-container" style="cursor: default;" ng-repeat="x in program.commercials" dnd-draggable="x" dnd-moved="program.commercials.splice($index, 1)" dnd-effect-allowed="move">
{{x.title}}
<div class="flex-pull-right">
<span class="small" style="display: inline-block;">
<b>Position</b><br/>
{{x.commercialPosition===0?'START':x.commercialPosition=== 1?'1/4':x.commercialPosition===2?'1/2':x.commercialPosition===3?'3/4':'END'}}
</span>
<span style="padding-top: 10px; display: inline-block;">
<input type="range" min="0" max="4" ng-model="x.commercialPosition"/>
</span>
<span class="btn fa fa-trash" ng-click="program.commercials.splice($index,1)"></span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-link" ng-click="program = null">Cancel</button>