Merge with branch 'vexorian/development'
This commit is contained in:
commit
9fb1498225
56
index.js
56
index.js
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
422
src/database-migration.js
Normal 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,
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}`;
|
||||
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 <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> 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 "Flex" Time Slot. Can be configured to play a fallback screen and/or random "filler" content (e.g "commercials", 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 > 100" type="range" ng-model="minProgramIndex" min="0" max="{{ channel.programs.length - 100 }}" />
|
||||
<div ng-if="minProgramIndex > 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 <= $index && $index < 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 < 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">
|
||||
⋮
|
||||
</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 > 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>
|
||||
|
||||
@ -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;"> Disable overlay icon when playing filler </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> 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user