Merge branch 'dev/1.5.x' into edge

This commit is contained in:
vexorian 2021-09-21 22:08:58 -04:00
commit 7665dcf6e8
45 changed files with 1679 additions and 513 deletions

View File

@ -1,6 +1,6 @@
node_modules
npm-debug.log
Dockerfile
*Dockerfile*
.dockerignore
.git
.gitignore

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx --no-install commitlint --edit ""

4
.husky/prepare-commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
exec < /dev/tty && node_modules/.bin/cz --hook || true

View File

@ -1,4 +1,4 @@
FROM node:12.18-alpine3.12
FROM node:14-alpine3.14
WORKDIR /home/node/app
COPY package*.json ./
RUN npm install && npm install -g browserify nexe@3.3.7

View File

@ -1,4 +1,4 @@
FROM node:12.18-alpine3.12
FROM node:14-alpine3.14
WORKDIR /home/node/app
COPY package*.json ./
RUN npm install && npm install -g browserify nexe@3.3.7

View File

@ -1,4 +1,4 @@
# dizqueTV 1.4.5
# dizqueTV 1.5.0-development
![Discord](https://img.shields.io/discord/711313431457693727?logo=discord&logoColor=fff&style=flat-square) ![GitHub top language](https://img.shields.io/github/languages/top/vexorian/dizquetv?logo=github&style=flat-square) ![Docker Pulls](https://img.shields.io/docker/pulls/vexorian/dizquetv?logo=docker&logoColor=fff&style=flat-square)
Create live TV channel streams from media on your Plex servers.

1
commitlint.config.js Normal file
View File

@ -0,0 +1 @@
module.exports = {extends: ['@commitlint/config-conventional']}

105
index.js
View File

@ -5,6 +5,9 @@ const path = require('path')
const express = require('express')
const bodyParser = require('body-parser')
const fileUpload = require('express-fileupload');
const i18next = require('i18next');
const i18nextMiddleware = require('i18next-http-middleware/cjs');
const i18nextBackend = require('i18next-fs-backend/cjs');
const api = require('./src/api')
const dbMigration = require('./src/database-migration');
@ -12,10 +15,10 @@ const video = require('./src/video')
const HDHR = require('./src/hdhr')
const FileCacheService = require('./src/services/file-cache-service');
const CacheImageService = require('./src/services/cache-image-service');
const ChannelService = require("./src/services/channel-service");
const xmltv = require('./src/xmltv')
const Plex = require('./src/plex');
const channelCache = require('./src/channel-cache');
const constants = require('./src/constants')
const ChannelDB = require("./src/dao/channel-db");
const M3uService = require("./src/services/m3u-service");
@ -23,6 +26,10 @@ const FillerDB = require("./src/dao/filler-db");
const CustomShowDB = require("./src/dao/custom-show-db");
const TVGuideService = require("./src/services/tv-guide-service");
const EventService = require("./src/services/event-service");
const OnDemandService = require("./src/services/on-demand-service");
const ProgrammingService = require("./src/services/programming-service");
const ActiveChannelService = require('./src/services/active-channel-service')
const onShutdown = require("node-graceful-shutdown").onShutdown;
console.log(
@ -80,51 +87,70 @@ if(!fs.existsSync(path.join(process.env.DATABASE, 'cache','images'))) {
channelDB = new ChannelDB( path.join(process.env.DATABASE, 'channels') );
fillerDB = new FillerDB( path.join(process.env.DATABASE, 'filler') , channelDB, channelCache );
customShowDB = new CustomShowDB( path.join(process.env.DATABASE, 'custom-shows') );
db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings', 'db-version', 'client-id', 'cache-images', 'settings'])
initDB(db, channelDB)
channelService = new ChannelService(channelDB);
fillerDB = new FillerDB( path.join(process.env.DATABASE, 'filler') , channelService );
customShowDB = new CustomShowDB( path.join(process.env.DATABASE, 'custom-shows') );
fileCache = new FileCacheService( path.join(process.env.DATABASE, 'cache') );
cacheImageService = new CacheImageService(db, fileCache);
m3uService = new M3uService(channelDB, fileCache, channelCache)
m3uService = new M3uService(fileCache, channelService)
onDemandService = new OnDemandService(channelService);
programmingService = new ProgrammingService(onDemandService);
activeChannelService = new ActiveChannelService(onDemandService, channelService);
eventService = new EventService();
initDB(db, channelDB)
i18next
.use(i18nextBackend)
.use(i18nextMiddleware.LanguageDetector)
.init({
// debug: true,
initImmediate: false,
backend: {
loadPath: path.join(__dirname, '/locales/server/{{lng}}.json'),
addPath: path.join(__dirname, '/locales/server/{{lng}}.json')
},
lng: 'en',
fallbackLng: 'en',
preload: ['en'],
});
const guideService = new TVGuideService(xmltv, db, cacheImageService);
const guideService = new TVGuideService(xmltv, db, cacheImageService, null, i18next);
let xmltvInterval = {
interval: null,
lastRefresh: null,
updateXML: async () => {
let getChannelsCached = async() => {
let channelNumbers = await channelDB.getAllChannelNumbers();
return await Promise.all( channelNumbers.map( async (x) => {
return (await channelCache.getChannelConfig(channelDB, x))[0];
}) );
}
let channels = [];
try {
channels = await getChannelsCached();
channels = await channelService.getAllChannels();
let xmltvSettings = db['xmltv-settings'].find()[0];
let t = guideService.prepareRefresh(channels, xmltvSettings.cache*60*60*1000);
channels = null;
await guideService.refresh(t);
xmltvInterval.lastRefresh = new Date()
console.log('XMLTV Updated at ', xmltvInterval.lastRefresh.toLocaleString());
guideService.refresh(t);
} catch (err) {
console.error("Unable to update TV guide?", err);
return;
}
channels = await getChannelsCached();
},
notifyPlex: async() => {
xmltvInterval.lastRefresh = new Date()
console.log('XMLTV Updated at ', xmltvInterval.lastRefresh.toLocaleString());
channels = await channelService.getAllChannels();
let plexServers = db['plex-servers'].find()
for (let i = 0, l = plexServers.length; i < l; i++) { // Foreach plex server
@ -155,6 +181,7 @@ let xmltvInterval = {
}
}
},
startInterval: () => {
let xmltvSettings = db['xmltv-settings'].find()[0]
if (xmltvSettings.refresh !== 0) {
@ -174,13 +201,39 @@ let xmltvInterval = {
}
}
guideService.on("xmltv-updated", (data) => {
try {
xmltvInterval.notifyPlex();
} catch (err) {
console.error("Unexpected issue when reacting to xmltv update", err);
}
} );
xmltvInterval.updateXML()
xmltvInterval.startInterval()
//setup xmltv update
channelService.on("channel-update", (data) => {
try {
console.log("Updating TV Guide due to channel update...");
//TODO: this could be smarter, like avoid updating 3 times if the channel was saved three times in a short time interval...
xmltvInterval.updateXML()
xmltvInterval.restartInterval()
} catch (err) {
console.error("Unexpected error issuing TV Guide udpate", err);
}
} );
let hdhr = HDHR(db, channelDB)
let app = express()
eventService.setup(app);
app.use(
i18nextMiddleware.handle(i18next, {})
);
app.use(fileUpload({
createParentPath: true
}));
@ -214,10 +267,10 @@ app.use('/favicon.svg', express.static(
app.use('/custom.css', express.static(path.join(process.env.DATABASE, 'custom.css')))
// API Routers
app.use(api.router(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService, m3uService, eventService ))
app.use(api.router(db, channelService, fillerDB, customShowDB, xmltvInterval, guideService, m3uService, eventService ))
app.use('/api/cache/images', cacheImageService.apiRouters())
app.use(video.router( channelDB, fillerDB, db))
app.use(video.router( channelService, fillerDB, db, programmingService, activeChannelService ))
app.use(hdhr.router)
app.listen(process.env.PORT, () => {
console.log(`HTTP server running on port: http://*:${process.env.PORT}`)
@ -277,7 +330,7 @@ async function sendEventAfterTime() {
eventService.push(
"lifecycle",
{
"message": `Server Started`,
"message": i18next.t("event.server_started"),
"detail" : {
"time": t,
},
@ -296,7 +349,7 @@ onShutdown("log" , [], async() => {
eventService.push(
"lifecycle",
{
"message": `Initiated Server Shutdown`,
"message": i18next.t("event.server_shutdown"),
"detail" : {
"time": t,
},
@ -310,4 +363,10 @@ onShutdown("log" , [], async() => {
onShutdown("xmltv-writer" , [], async() => {
await xmltv.shutdown();
} );
onShutdown("active-channels", [], async() => {
await activeChannelService.shutdown();
} );
onShutdown("video", [], async() => {
await video.shutdown();
} );

15
locales/server/en.json Normal file
View File

@ -0,0 +1,15 @@
{
"event":{
"server_started": "Server Started",
"server_shutdown": "Initiated Server Shutdown"
},
"api": {
"plex_server_not_found": "Plex server not found.",
"missing_name": "Missing name"
},
"tvGuide": {
"no_channels": "No channels configured",
"no_channels_summary": "Use the dizqueTV web UI to configure channels.",
"xmltv_updated": "XMLTV updated at server time {{t}}"
}
}

View File

@ -10,6 +10,7 @@ npm run build || exit 1
npm run compile || exit 1
cp -R ./web ./dist/web
cp -R ./resources ./dist/
cp -R ./locales/ ./dist/locales/
cd dist
if [ "$MODE" == "all" ]; then
nexe --temp /var/nexe -r "./**/*" -t windows-x64-12.18.2 --output $WIN64

View File

@ -11,21 +11,28 @@
"dev-server": "nodemon index.js --ignore ./web/ --ignore ./db/ --ignore ./xmltv.xml",
"compile": "babel index.js -d dist && babel src -d dist/src",
"package": "sh ./make_dist.sh",
"clean": "del-cli --force ./bin ./dist ./.dizquetv ./web/public/bundle.js"
"clean": "del-cli --force ./bin ./dist ./.dizquetv ./web/public/bundle.js",
"prepare": "husky install"
},
"author": "vexorian",
"license": "Zlib",
"dependencies": {
"JSONStream": "1.0.5",
"angular": "^1.8.0",
"angular-router-browserify": "0.0.2",
"angular-sanitize": "^1.8.2",
"angular-vs-repeat": "2.0.13",
"axios": "^0.21.1",
"body-parser": "^1.19.0",
"merge" : "2.1.1",
"diskdb": "0.1.17",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"i18next": "^20.3.2",
"i18next-fs-backend": "^1.1.1",
"i18next-http-backend": "^1.2.6",
"i18next-http-middleware": "^3.1.4",
"JSONStream": "1.0.5",
"merge": "2.1.1",
"ng-i18next": "^1.0.7",
"node-graceful-shutdown": "1.1.0",
"node-ssdp": "^4.0.0",
"random-js": "2.1.0",
@ -39,16 +46,25 @@
"@babel/core": "^7.9.0",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/preset-env": "^7.9.5",
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"browserify": "^16.5.1",
"copyfiles": "^2.2.0",
"cz-conventional-changelog": "^3.3.0",
"del-cli": "^3.0.0",
"husky": "^7.0.0",
"nexe": "^3.3.7",
"nodemon": "^2.0.3",
"watchify": "^3.11.1",
"nexe": "^3.3.7"
"watchify": "^3.11.1"
},
"babel": {
"plugins": [
"@babel/plugin-proposal-class-properties"
]
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

View File

@ -3,7 +3,6 @@ const express = require('express')
const path = require('path')
const fs = require('fs')
const databaseMigration = require('./database-migration');
const channelCache = require('./channel-cache')
const constants = require('./constants');
const JSONStream = require('JSONStream');
const FFMPEGInfo = require('./ffmpeg-info');
@ -26,10 +25,10 @@ function safeString(object) {
}
module.exports = { router: api }
function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService, _m3uService, eventService ) {
function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideService, _m3uService, eventService ) {
let m3uService = _m3uService;
const router = express.Router()
const plexServerDB = new PlexServerDB(channelDB, channelCache, fillerDB, customShowDB, db);
const plexServerDB = new PlexServerDB(channelService, fillerDB, customShowDB, db);
router.get('/api/version', async (req, res) => {
try {
@ -63,7 +62,7 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
name: req.body.name,
});
if (servers.length != 1) {
return res.status(404).send("Plex server not found.");
return res.status(404).send(req.t("api.plex_server_not_found"));
}
let plex = new Plex(servers[0]);
let s = await Promise.race( [
@ -223,7 +222,7 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
// Channels
router.get('/api/channels', async (req, res) => {
try {
let channels = await channelDB.getAllChannels();
let channels = await channelService.getAllChannelNumbers();
channels.sort((a, b) => { return a.number < b.number ? -1 : 1 })
res.send(channels)
} catch(err) {
@ -234,10 +233,9 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
router.get('/api/channel/:number', async (req, res) => {
try {
let number = parseInt(req.params.number, 10);
let channel = await channelCache.getChannelConfig(channelDB, number);
let channel = await channelService.getChannel(number);
if (channel.length == 1) {
channel = channel[0];
if (channel != null) {
res.send(channel);
} else {
return res.status(404).send("Channel not found");
@ -250,10 +248,9 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
router.get('/api/channel/programless/:number', async (req, res) => {
try {
let number = parseInt(req.params.number, 10);
let channel = await channelCache.getChannelConfig(channelDB, number);
let channel = await channelService.getChannel(number);
if (channel.length == 1) {
channel = channel[0];
if (channel != null) {
let copy = {};
Object.keys(channel).forEach( (key) => {
if (key != 'programs') {
@ -273,10 +270,9 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
router.get('/api/channel/programs/:number', async (req, res) => {
try {
let number = parseInt(req.params.number, 10);
let channel = await channelCache.getChannelConfig(channelDB, number);
let channel = await channelService.getChannel(number);
if (channel.length == 1) {
channel = channel[0];
if (channel != null) {
let programs = channel.programs;
if (typeof(programs) === 'undefined') {
return res.status(404).send("Channel doesn't have programs?");
@ -305,9 +301,8 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
router.get('/api/channel/description/:number', async (req, res) => {
try {
let number = parseInt(req.params.number, 10);
let channel = await channelCache.getChannelConfig(channelDB, number);
if (channel.length == 1) {
channel = channel[0];
let channel = await channelService.getChannel(number);
if (channel != null) {
res.send({
number: channel.number,
icon: channel.icon,
@ -324,7 +319,7 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
})
router.get('/api/channelNumbers', async (req, res) => {
try {
let channels = await channelDB.getAllChannelNumbers();
let channels = await channelService.getAllChannelNumbers();
channels.sort( (a,b) => { return parseInt(a) - parseInt(b) } );
res.send(channels)
} catch(err) {
@ -332,39 +327,30 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
res.status(500).send("error");
}
})
// we urgently need an actual channel service
router.post('/api/channel', async (req, res) => {
try {
await m3uService.clearCache();
cleanUpChannel(req.body);
await channelDB.saveChannel( req.body.number, req.body );
channelCache.clear();
await channelService.saveChannel( req.body.number, req.body );
res.send( { number: req.body.number} )
updateXmltv()
} catch(err) {
console.error(err);
res.status(500).send("error");
res.status(500).send("error");
}
})
router.put('/api/channel', async (req, res) => {
try {
await m3uService.clearCache();
cleanUpChannel(req.body);
await channelDB.saveChannel( req.body.number, req.body );
channelCache.clear();
await channelService.saveChannel( req.body.number, req.body );
res.send( { number: req.body.number} )
updateXmltv()
} catch(err) {
console.error(err);
res.status(500).send("error");
res.status(500).send("error");
}
})
router.delete('/api/channel', async (req, res) => {
try {
await m3uService.clearCache();
await channelDB.deleteChannel( req.body.number );
channelCache.clear();
await channelService.deleteChannel(req.body.number);
res.send( { number: req.body.number} )
updateXmltv()
} catch(err) {
console.error(err);
res.status(500).send("error");
@ -1047,53 +1033,11 @@ function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService
xmltvInterval.updateXML()
xmltvInterval.restartInterval()
}
function cleanUpProgram(program) {
delete program.start
delete program.stop
delete program.streams;
delete program.durationStr;
delete program.commercials;
if (
(typeof(program.duration) === 'undefined')
||
(program.duration <= 0)
) {
console.error(`Input contained a program with invalid duration: ${program.duration}. This program has been deleted`);
return [];
}
if (! Number.isInteger(program.duration) ) {
console.error(`Input contained a program with invalid duration: ${program.duration}. Duration got fixed to be integer.`);
program.duration = Math.ceil(program.duration);
}
return [ program ];
}
function cleanUpChannel(channel) {
if (
(typeof(channel.groupTitle) === 'undefined')
||
(channel.groupTitle === '')
) {
channel.groupTitle = "dizqueTV";
}
channel.programs = channel.programs.flatMap( cleanUpProgram );
delete channel.fillerContent;
delete channel.filler;
channel.fallback = channel.fallback.flatMap( cleanUpProgram );
channel.duration = 0;
for (let i = 0; i < channel.programs.length; i++) {
channel.duration += channel.programs[i].duration;
}
}
async function streamToolResult(toolRes, res) {
let programs = toolRes.programs;
delete toolRes.programs;
let s = JSON.stringify(toolRes);
s = s.slice(0, -1);
console.log( JSON.stringify(toolRes));
res.writeHead(200, {
'Content-Type': 'application/json'

View File

@ -24,7 +24,7 @@ async function getChannelConfig(channelDB, channelId) {
async function getAllNumbers(channelDB) {
if (numbers === null) {
let n = channelDB.getAllChannelNumbers();
let n = await channelDB.getAllChannelNumbers();
numbers = n;
}
return numbers;
@ -32,14 +32,41 @@ async function getAllNumbers(channelDB) {
async function getAllChannels(channelDB) {
let channelNumbers = await getAllNumbers(channelDB);
return await Promise.all( channelNumbers.map( async (x) => {
return (await Promise.all( channelNumbers.map( async (x) => {
return (await getChannelConfig(channelDB, x))[0];
}) );
}) )).filter( (channel) => {
if (channel == null) {
console.error("Found a null channel " + JSON.stringify(channelNumbers) );
return false;
}
if ( typeof(channel) === "undefined") {
console.error("Found a undefined channel " + JSON.stringify(channelNumbers) );
return false;
}
if ( typeof(channel.number) === "undefined") {
console.error("Found a channel without number " + JSON.stringify(channelNumbers) );
return false;
}
return true;
} );
}
function saveChannelConfig(number, channel ) {
configCache[number] = [channel];
// flush the item played cache for the channel and any channel in its
// redirect chain
if (typeof(cache[number]) !== 'undefined') {
let lineupItem = cache[number].lineupItem;
for (let i = 0; i < lineupItem.redirectChannels.length; i++) {
delete cache[ lineupItem.redirectChannels[i].number ];
}
delete cache[number];
}
numbers = null;
}
function getCurrentLineupItem(channelId, t1) {
@ -157,6 +184,7 @@ module.exports = {
clear: clear,
getProgramLastPlayTime: getProgramLastPlayTime,
getAllChannels: getAllChannels,
getAllNumbers: getAllNumbers,
getChannelConfig: getChannelConfig,
saveChannelConfig: saveChannelConfig,
getFillerLastPlayTime: getFillerLastPlayTime,

View File

@ -3,7 +3,30 @@ module.exports = {
TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30*60*1000,
DEFAULT_GUIDE_STEALTH_DURATION: 5 * 60* 1000,
TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000,
TOO_FREQUENT: 100,
TOO_FREQUENT: 1000,
VERSION_NAME: "1.4.5"
//when a channel is forcibly stopped due to an update, let's mark it as active
// for a while during the transaction just in case.
CHANNEL_STOP_SHIELD : 5000,
START_CHANNEL_GRACE_PERIOD: 15 * 1000,
// if a channel is stopped while something is playing, subtract
// this amount of milliseconds from the last-played timestamp, because
// video playback has latency and also because maybe the user wants
// the last 30 seconds to remember what was going on...
FORGETFULNESS_BUFFER: 30 * 1000,
// When a channel stops playing, this is a grace period before the channel is
// considered offline. It could be that the client halted the playback for some
// reason and is about to start playing again. Or maybe the user switched
// devices or something. Otherwise we would have on-demand channels constantly
// reseting on their own.
MAX_CHANNEL_IDLE: 60*1000,
// there's a timer that checks all active channels to see if they really are
// staying active, it checks every 5 seconds
PLAYED_MONITOR_CHECK_FREQUENCY: 5*1000,
VERSION_NAME: "1.5.0-development"
}

View File

@ -4,11 +4,10 @@ let fs = require('fs');
class FillerDB {
constructor(folder, channelDB, channelCache) {
constructor(folder, channelService) {
this.folder = folder;
this.cache = {};
this.channelDB = channelDB;
this.channelCache = channelCache;
this.channelService = channelService;
}
@ -79,10 +78,10 @@ class FillerDB {
}
async getFillerChannels(id) {
let numbers = await this.channelDB.getAllChannelNumbers();
let numbers = await this.channelService.getAllChannelNumbers();
let channels = [];
await Promise.all( numbers.map( async(number) => {
let ch = await this.channelDB.getChannel(number);
let ch = await this.channelService.getChannel(number);
let name = ch.name;
let fillerCollections = ch.fillerCollections;
for (let i = 0 ; i < fillerCollections.length; i++) {
@ -105,13 +104,13 @@ class FillerDB {
let channels = await this.getFillerChannels(id);
await Promise.all( channels.map( async(channel) => {
console.log(`Updating channel ${channel.number} , remove filler: ${id}`);
let json = await channelDB.getChannel(channel.number);
let json = await channelService.getChannel(channel.number);
json.fillerCollections = json.fillerCollections.filter( (col) => {
return col.id != id;
} );
await this.channelDB.saveChannel( channel.number, json );
await this.channelService.saveChannel( channel.number, json );
} ) );
this.channelCache.clear();
let f = path.join(this.folder, `${id}.json` );
await new Promise( (resolve, reject) => {
fs.unlink(f, function (err) {

View File

@ -4,20 +4,21 @@ const ICON_REGEX = /https?:\/\/.*(\/library\/metadata\/\d+\/thumb\/\d+).X-Plex-T
const ICON_FIELDS = ["icon", "showIcon", "seasonIcon", "episodeIcon"];
// DB is a misnomer here, this is closer to a service
class PlexServerDB
{
constructor(channelDB, channelCache, fillerDB, showDB, db) {
this.channelDB = channelDB;
constructor(channelService, fillerDB, showDB, db) {
this.channelService = channelService;
this.db = db;
this.channelCache = channelCache;
this.fillerDB = fillerDB;
this.showDB = showDB;
}
async fixupAllChannels(name, newServer) {
let channelNumbers = await this.channelDB.getAllChannelNumbers();
let channelNumbers = await this.channelService.getAllChannelNumbers();
let report = await Promise.all( channelNumbers.map( async (i) => {
let channel = await this.channelDB.getChannel(i);
let channel = await this.channelService.getChannel(i);
let channelReport = {
channelNumber : channel.number,
channelName : channel.name,
@ -38,10 +39,10 @@ class PlexServerDB
}
}
this.fixupProgramArray(channel.fallback, name,newServer, channelReport);
await this.channelDB.saveChannel(i, channel);
await this.channelService.saveChannel(i, channel);
return channelReport;
}) );
this.channelCache.clear();
return report;
}

View File

@ -0,0 +1,150 @@
const constants = require("../constants");
/* Keeps track of which channels are being played, calls on-demand service
when they stop playing.
*/
class ActiveChannelService
{
/****
*
**/
constructor(onDemandService, channelService ) {
this.cache = {};
this.onDemandService = onDemandService;
this.onDemandService.setActiveChannelService(this);
this.channelService = channelService;
this.timeNoDelta = new Date().getTime();
this.loadChannelsForFirstTry();
this.setupTimer();
}
loadChannelsForFirstTry() {
let fun = async() => {
try {
let numbers = await this.channelService.getAllChannelNumbers();
numbers.forEach( (number) => {
this.ensure(this.timeNoDelta, number);
} );
this.checkChannels();
} catch (err) {
console.error("Unexpected error when checking channels for the first time.", err);
}
}
fun();
}
async shutdown() {
try {
let t = new Date().getTime() - constants.FORGETFULNESS_BUFFER;
for (const [channelNumber, value] of Object.entries(this.cache)) {
console.log("Forcefully registering channel " + channelNumber + " as stopped...");
delete this.cache[ channelNumber ];
await this.onDemandService.registerChannelStopped( channelNumber, t , true);
}
} catch (err) {
console.error("Unexpected error when shutting down active channels service.", err);
}
}
setupTimer() {
this.handle = setTimeout( () => this.timerLoop(), constants.PLAYED_MONITOR_CHECK_FREQUENCY );
}
checkChannel(t, channelNumber, value) {
if (value.active === 0) {
let delta = t - value.lastUpdate;
if ( (delta >= constants.MAX_CHANNEL_IDLE) || (value.lastUpdate <= this.timeNoDelta) ) {
console.log("Channel : " + channelNumber + " is not playing...");
onDemandService.registerChannelStopped(channelNumber, value.stopTime);
delete this.cache[channelNumber];
}
}
}
checkChannels() {
let t = new Date().getTime();
for (const [channelNumber, value] of Object.entries(this.cache)) {
this.checkChannel(t, channelNumber, value);
}
}
timerLoop() {
try {
this.checkChannels();
} catch (err) {
console.error("There was an error in active channel timer loop", err);
} finally {
this.setupTimer();
}
}
registerChannelActive(t, channelNumber) {
this.ensure(t, channelNumber);
if (this.cache[channelNumber].active === 0) {
console.log("Channel is being played: " + channelNumber );
}
this.cache[channelNumber].active++;
//console.log(channelNumber + " ++active=" + this.cache[channelNumber].active );
this.cache[channelNumber].stopTime = 0;
this.cache[channelNumber].lastUpdate = new Date().getTime();
}
registerChannelStopped(t, channelNumber) {
this.ensure(t, channelNumber);
if (this.cache[channelNumber].active === 1) {
console.log("Register that channel is no longer being played: " + channelNumber );
}
if (this.cache[channelNumber].active === 0) {
console.error("Serious issue with channel active service, double delete");
} else {
this.cache[channelNumber].active--;
//console.log(channelNumber + " --active=" + this.cache[channelNumber].active );
let s = this.cache[channelNumber].stopTime;
if ( (typeof(s) === 'undefined') || (s < t) ) {
this.cache[channelNumber].stopTime = t;
}
this.cache[channelNumber].lastUpdate = new Date().getTime();
}
}
ensure(t, channelNumber) {
if (typeof(this.cache[channelNumber]) === 'undefined') {
this.cache[channelNumber] = {
active: 0,
stopTime: t,
lastUpdate: t,
}
}
}
peekChannel(t, channelNumber) {
this.ensure(t, channelNumber);
}
isActiveWrapped(channelNumber) {
if (typeof(this.cache[channelNumber]) === 'undefined') {
return false;
}
if (typeof(this.cache[channelNumber].active) !== 'number') {
return false;
}
return (this.cache[channelNumber].active !== 0);
}
isActive(channelNumber) {
let bol = this.isActiveWrapped(channelNumber);
return bol;
}
}
module.exports = ActiveChannelService

View File

@ -0,0 +1,103 @@
const events = require('events')
const channelCache = require("../channel-cache");
class ChannelService extends events.EventEmitter {
constructor(channelDB) {
super();
this.channelDB = channelDB;
this.onDemandService = null;
}
setOnDemandService(onDemandService) {
this.onDemandService = onDemandService;
}
async saveChannel(number, channelJson, options) {
let channel = cleanUpChannel(channelJson);
let ignoreOnDemand = true;
if (
(this.onDemandService != null)
&&
( (typeof(options) === 'undefined') || (options.ignoreOnDemand !== true) )
) {
ignoreOnDemand = false;
this.onDemandService.fixupChannelBeforeSave( channel );
}
channelCache.saveChannelConfig( number, channel);
await channelDB.saveChannel( number, channel );
this.emit('channel-update', { channelNumber: number, channel: channel, ignoreOnDemand: ignoreOnDemand} );
}
async deleteChannel(number) {
await channelDB.deleteChannel( number );
this.emit('channel-update', { channelNumber: number, channel: null} );
channelCache.clear();
}
async getChannel(number) {
let lis = await channelCache.getChannelConfig(this.channelDB, number)
if ( lis == null || lis.length !== 1) {
return null;
}
return lis[0];
}
async getAllChannelNumbers() {
return await channelCache.getAllNumbers(this.channelDB);
}
async getAllChannels() {
return await channelCache.getAllChannels(this.channelDB);
}
}
function cleanUpProgram(program) {
delete program.start
delete program.stop
delete program.streams;
delete program.durationStr;
delete program.commercials;
if (
(typeof(program.duration) === 'undefined')
||
(program.duration <= 0)
) {
console.error(`Input contained a program with invalid duration: ${program.duration}. This program has been deleted`);
return [];
}
if (! Number.isInteger(program.duration) ) {
console.error(`Input contained a program with invalid duration: ${program.duration}. Duration got fixed to be integer.`);
program.duration = Math.ceil(program.duration);
}
return [ program ];
}
function cleanUpChannel(channel) {
if (
(typeof(channel.groupTitle) === 'undefined')
||
(channel.groupTitle === '')
) {
channel.groupTitle = "dizqueTV";
}
channel.programs = channel.programs.flatMap( cleanUpProgram );
delete channel.fillerContent;
delete channel.filler;
channel.fallback = channel.fallback.flatMap( cleanUpProgram );
channel.duration = 0;
for (let i = 0; i < channel.programs.length; i++) {
channel.duration += channel.programs[i].duration;
}
return channel;
}
module.exports = ChannelService

View File

@ -12,6 +12,7 @@ module.exports = function () {
showId : "custom." + program.customShowId,
showDisplayName : program.customShowName,
order : program.customOrder,
shuffleOrder : program.shuffleOrder,
}
} else if (program.isOffline && program.type === 'redirect') {
return {
@ -35,6 +36,7 @@ module.exports = function () {
showId : "movie.",
showDisplayName : "Movies",
order : movieTitleOrder[key],
shuffleOrder : program.shuffleOrder,
}
} else if ( (program.type === 'episode') || (program.type === 'track') ) {
let s = 0;
@ -54,6 +56,7 @@ module.exports = function () {
showId : prefix + program.showTitle,
showDisplayName : program.showTitle,
order : s * 1000000 + e,
shuffleOrder : program.shuffleOrder,
}
} else {
return {

View File

@ -4,11 +4,13 @@
* @class M3uService
*/
class M3uService {
constructor(dataBase, fileCacheService, channelCache) {
this.dataBase = dataBase;
constructor(fileCacheService, channelService) {
this.channelService = channelService;
this.cacheService = fileCacheService;
this.channelCache = channelCache;
this.cacheReady = false;
this.channelService.on("channel-update", (data) => {
this.clearCache();
} );
}
/**
@ -37,7 +39,7 @@ class M3uService {
return this.replaceHostOnM3u(host, cachedM3U);
}
}
let channels = await this.channelCache.getAllChannels(this.dataBase);
let channels = await this.channelService.getAllChannels();
channels.sort((a, b) => {

View File

@ -0,0 +1,226 @@
const constants = require("../constants");
const SLACK = constants.SLACK;
class OnDemandService
{
/****
*
**/
constructor(channelService) {
this.channelService = channelService;
this.channelService.setOnDemandService(this);
this.activeChannelService = null;
}
setActiveChannelService(activeChannelService) {
this.activeChannelService = activeChannelService;
}
activateChannelIfNeeded(moment, channel) {
if ( this.isOnDemandChannelPaused(channel) ) {
channel = this.resumeOnDemandChannel(moment, channel);
this.updateChannelAsync(channel);
}
return channel;
}
async registerChannelStopped(channelNumber, stopTime, waitForSave) {
try {
let channel = await this.channelService.getChannel(channelNumber);
if (channel == null) {
console.error("Could not stop channel " + channelNumber + " because it apparently no longer exists"); // I guess if someone deletes the channel just in the grace period?
return
}
if ( (typeof(channel.onDemand) !== 'undefined') && channel.onDemand.isOnDemand && ! channel.onDemand.paused) {
//pause the channel
channel = this.pauseOnDemandChannel( channel , stopTime );
if (waitForSave) {
await this.updateChannelSync(channel);
} else {
this.updateChannelAsync(channel);
}
}
} catch (err) {
console.error("Error stopping channel", err);
}
}
pauseOnDemandChannel(originalChannel, stopTime) {
console.log("Pause on-demand channel : " + originalChannel.number);
let channel = clone(originalChannel);
// first find what the heck is playing
let t = stopTime;
let s = new Date(channel.startTime).getTime();
let onDemand = channel.onDemand;
onDemand.paused = true;
if ( channel.programs.length == 0) {
console.log("On-demand channel has no programs. That doesn't really make a lot of sense...");
onDemand.firstProgramModulo = s % onDemand.modulo;
onDemand.playedOffset = 0;
} else if (t < s) {
// the first program didn't even play.
onDemand.firstProgramModulo = s % onDemand.modulo;
onDemand.playedOffset = 0;
} else {
let i = 0;
let total = 0;
while (true) {
let d = channel.programs[i].duration;
if ( (s + total <= t) && (t < s + total + d) ) {
break;
}
total += d;
i = (i + 1) % channel.programs.length;
}
// rotate
let programs = [];
for (let j = i; j < channel.programs.length; j++) {
programs.push( channel.programs[j] );
}
for (let j = 0; j <i; j++) {
programs.push( channel.programs[j] );
}
onDemand.firstProgramModulo = (s + total) % onDemand.modulo;
onDemand.playedOffset = t - (s + total);
channel.programs = programs;
channel.startTime = new Date(s + total).toISOString();
}
return channel;
}
async updateChannelSync(channel) {
try {
await this.channelService.saveChannel(
channel.number,
channel,
{ignoreOnDemand: true}
);
console.log("Channel " + channel.number + " saved by on-demand service...");
} catch (err) {
console.error("Error saving resumed channel: " + channel.number, err);
}
}
updateChannelAsync(channel) {
this.updateChannelSync(channel);
}
fixupChannelBeforeSave(channel) {
let isActive = false;
if (this.activeChannelService != null && this.activeChannelService.isActive(channel.number) ) {
isActive = true;
}
if (typeof(channel.onDemand) === 'undefined') {
channel.onDemand = {};
}
if (typeof(channel.onDemand.isOnDemand) !== 'boolean') {
channel.onDemand.isOnDemand = false;
}
if ( channel.onDemand.isOnDemand !== true ) {
channel.onDemand.modulo = 1;
channel.onDemand.firstProgramModulo = 1;
channel.onDemand.playedOffset = 0;
channel.onDemand.paused = false;
} else {
if ( typeof(channel.onDemand.modulo) !== 'number') {
channel.onDemand.modulo = 1;
}
if (isActive) {
// if it is active, the channel isn't paused
channel.onDemand.paused = false;
} else {
let s = new Date(channel.startTime).getTime();
channel.onDemand.paused = true;
channel.onDemand.firstProgramModulo = s % channel.onDemand.modulo;
channel.onDemand.playedOffset = 0;
}
}
}
resumeOnDemandChannel(t, originalChannel) {
let channel = clone(originalChannel);
console.log("Resume on-demand channel: " + channel.name);
let programs = channel.programs;
let onDemand = channel.onDemand;
onDemand.paused = false; //should be the invariant
if (programs.length == 0) {
console.log("On-demand channel is empty. This doesn't make a lot of sense...");
return channel;
}
let i = 0;
let backupFo = onDemand.firstProgramModulo;
while (i < programs.length) {
let program = programs[i];
if ( program.isOffline && (program.type !== 'redirect') ) {
//skip flex
i++;
onDemand.playedOffset = 0;
onDemand.firstProgramModulo = ( onDemand.firstProgramModulo + program.duration ) % onDemand.modulo;
} else {
break;
}
}
if (i == programs.length) {
console.log("Everything in the channel is flex... This doesn't really make a lot of sense for an onDemand channel, you know...");
i = 0;
onDemand.playedOffset = 0;
onDemand.firstProgramModulo = backupFo;
}
// Last we've seen this channel, it was playing program #i , played the first playedOffset milliseconds.
// move i to the beginning of the program list
let newPrograms = []
for (let j = i; j < programs.length; j++) {
newPrograms.push( programs[j] );
}
for (let j = 0; j < i; j++) {
newPrograms.push( programs[j] );
}
// now the start program is 0, and the "only" thing to do now is change the start time
let startTime = t - onDemand.playedOffset;
// with this startTime, it would work perfectly if modulo is 1. But what about other cases?
let tm = t % onDemand.modulo;
let pm = (onDemand.firstProgramModulo + onDemand.playedOffset) % onDemand.modulo;
if (tm < pm) {
startTime += (pm - tm);
} else {
let o = (tm - pm);
startTime = startTime - o;
//It looks like it is convenient to make the on-demand a bit more lenient SLACK-wise tha
//other parts of the schedule process. So SLACK*2 instead of just SLACK
if (o >= SLACK*2) {
startTime += onDemand.modulo;
}
}
channel.startTime = (new Date(startTime)).toISOString();
channel.programs = newPrograms;
return channel;
}
isOnDemandChannelPaused(channel) {
return (
(typeof(channel.onDemand) !== 'undefined')
&&
(channel.onDemand.isOnDemand === true)
&&
(channel.onDemand.paused === true)
);
}
}
function clone(channel) {
return JSON.parse( JSON.stringify(channel) );
}
module.exports = OnDemandService

View File

@ -0,0 +1,35 @@
const helperFuncs = require("../helperFuncs");
/* Tells us what is or should be playing in some channel
If the channel is a an on-demand channel and is paused, resume the channel.
Before running the logic.
This hub for the programming logic used to be helperFuncs.getCurrentProgramAndTimeElapsed.
This class will still call that function, but this should be the entry point
for that logic.
Eventually it looks like a good idea to move that logic here.
*/
class ProgrammingService
{
/****
*
**/
constructor(onDemandService) {
this.onDemandService = onDemandService;
}
getCurrentProgramAndTimeElapsed(moment, channel) {
channel = onDemandService.activateChannelIfNeeded(moment, channel);
return helperFuncs.getCurrentProgramAndTimeElapsed(moment, channel);
}
}
module.exports = ProgrammingService

View File

@ -2,7 +2,7 @@ const constants = require("../constants");
const getShowData = require("./get-show-data")();
const random = require('../helperFuncs').random;
const throttle = require('./throttle');
const orderers = require("./show-orderers");
const MINUTE = 60*1000;
const DAY = 24*60*MINUTE;
@ -22,29 +22,6 @@ function getShow(program) {
}
}
function shuffle(array, lo, hi ) {
if (typeof(lo) === 'undefined') {
lo = 0;
hi = array.length;
}
let currentIndex = hi, temporaryValue, randomIndex
while (lo !== currentIndex) {
randomIndex = random.integer(lo, currentIndex-1);
currentIndex -= 1
temporaryValue = array[currentIndex]
array[currentIndex] = array[randomIndex]
array[randomIndex] = temporaryValue
}
return array
}
function _wait(t) {
return new Promise((resolve) => {
setTimeout(resolve, t);
});
}
function getProgramId(program) {
let s = program.serverKey;
if (typeof(s) === 'undefined') {
@ -69,78 +46,6 @@ function addProgramToShow(show, program) {
}
}
function getShowOrderer(show) {
if (typeof(show.orderer) === 'undefined') {
let sortedPrograms = JSON.parse( JSON.stringify(show.programs) );
sortedPrograms.sort((a, b) => {
let showA = getShowData(a);
let showB = getShowData(b);
return showA.order - showB.order;
});
let position = 0;
while (
(position + 1 < sortedPrograms.length )
&&
(
getShowData(show.founder).order
!==
getShowData(sortedPrograms[position]).order
)
) {
position++;
}
show.orderer = {
current : () => {
return sortedPrograms[position];
},
next: () => {
position = (position + 1) % sortedPrograms.length;
},
}
}
return show.orderer;
}
function getShowShuffler(show) {
if (typeof(show.shuffler) === 'undefined') {
if (typeof(show.programs) === 'undefined') {
throw Error(show.id + " has no programs?")
}
let randomPrograms = JSON.parse( JSON.stringify(show.programs) );
let n = randomPrograms.length;
shuffle( randomPrograms, 0, n);
let position = 0;
show.shuffler = {
current : () => {
return randomPrograms[position];
},
next: () => {
position++;
if (position == n) {
let a = Math.floor(n / 2);
shuffle(randomPrograms, 0, a );
shuffle(randomPrograms, a, n );
position = 0;
}
},
}
}
return show.shuffler;
}
module.exports = async( programs, schedule ) => {
if (! Array.isArray(programs) ) {
return { userError: 'Expected a programs array' };
@ -192,9 +97,6 @@ module.exports = async( programs, schedule ) => {
}
let flexBetween = ( schedule.flexPreference !== "end" );
// throttle so that the stream is not affected negatively
let steps = 0;
let showsById = {};
let shows = [];
@ -216,9 +118,9 @@ module.exports = async( programs, schedule ) => {
channel: show.channel,
}
} else if (slot.order === 'shuffle') {
return getShowShuffler(show).current();
return orderers.getShowShuffler(show).current();
} else if (slot.order === 'next') {
return getShowOrderer(show).current();
return orderers.getShowOrderer(show).current();
}
}
@ -228,9 +130,9 @@ module.exports = async( programs, schedule ) => {
}
let show = shows[ showsById[slot.showId] ];
if (slot.order === 'shuffle') {
return getShowShuffler(show).next();
return orderers.getShowShuffler(show).next();
} else if (slot.order === 'next') {
return getShowOrderer(show).next();
return orderers.getShowOrderer(show).next();
}
}

View File

@ -0,0 +1,156 @@
const random = require('../helperFuncs').random;
const getShowData = require("./get-show-data")();
const randomJS = require("random-js");
const Random = randomJS.Random;
/****
*
* Code shared by random slots and time slots for keeping track of the order
* of episodes
*
**/
function shuffle(array, lo, hi, randomOverride ) {
let r = randomOverride;
if (typeof(r) === 'undefined') {
r = random;
}
if (typeof(lo) === 'undefined') {
lo = 0;
hi = array.length;
}
let currentIndex = hi, temporaryValue, randomIndex
while (lo !== currentIndex) {
randomIndex = r.integer(lo, currentIndex-1);
currentIndex -= 1
temporaryValue = array[currentIndex]
array[currentIndex] = array[randomIndex]
array[randomIndex] = temporaryValue
}
return array
}
function getShowOrderer(show) {
if (typeof(show.orderer) === 'undefined') {
let sortedPrograms = JSON.parse( JSON.stringify(show.programs) );
sortedPrograms.sort((a, b) => {
let showA = getShowData(a);
let showB = getShowData(b);
return showA.order - showB.order;
});
let position = 0;
while (
(position + 1 < sortedPrograms.length )
&&
(
getShowData(show.founder).order
!==
getShowData(sortedPrograms[position]).order
)
) {
position++;
}
show.orderer = {
current : () => {
return sortedPrograms[position];
},
next: () => {
position = (position + 1) % sortedPrograms.length;
},
}
}
return show.orderer;
}
function getShowShuffler(show) {
if (typeof(show.shuffler) === 'undefined') {
if (typeof(show.programs) === 'undefined') {
throw Error(show.id + " has no programs?")
}
let sortedPrograms = JSON.parse( JSON.stringify(show.programs) );
sortedPrograms.sort((a, b) => {
let showA = getShowData(a);
let showB = getShowData(b);
return showA.order - showB.order;
});
let n = sortedPrograms.length;
let splitPrograms = [];
let randomPrograms = [];
for (let i = 0; i < n; i++) {
splitPrograms.push( sortedPrograms[i] );
randomPrograms.push( {} );
}
let showId = getShowData(show.programs[0]).showId;
let position = show.founder.shuffleOrder;
if (typeof(position) === 'undefined') {
position = 0;
}
let localRandom = null;
let initGeneration = (generation) => {
let seed = [];
for (let i = 0 ; i < show.showId.length; i++) {
seed.push( showId.charCodeAt(i) );
}
seed.push(generation);
localRandom = new Random( randomJS.MersenneTwister19937.seedWithArray(seed) )
if (generation == 0) {
shuffle( splitPrograms, 0, n , localRandom );
}
for (let i = 0; i < n; i++) {
randomPrograms[i] = splitPrograms[i];
}
let a = Math.floor(n / 2);
shuffle( randomPrograms, 0, a, localRandom );
shuffle( randomPrograms, a, n, localRandom );
};
initGeneration(0);
let generation = Math.floor( position / n );
initGeneration( generation );
show.shuffler = {
current : () => {
let prog = JSON.parse(
JSON.stringify(randomPrograms[position % n] )
);
prog.shuffleOrder = position;
return prog;
},
next: () => {
position++;
if (position % n == 0) {
let generation = Math.floor( position / n );
initGeneration( generation );
}
},
}
}
return show.shuffler;
}
module.exports = {
getShowOrderer : getShowOrderer,
getShowShuffler: getShowShuffler,
}

View File

@ -4,6 +4,7 @@ const constants = require("../constants");
const getShowData = require("./get-show-data")();
const random = require('../helperFuncs').random;
const throttle = require('./throttle');
const orderers = require("./show-orderers");
const MINUTE = 60*1000;
const DAY = 24*60*MINUTE;
@ -22,28 +23,6 @@ function getShow(program) {
}
}
function shuffle(array, lo, hi ) {
if (typeof(lo) === 'undefined') {
lo = 0;
hi = array.length;
}
let currentIndex = hi, temporaryValue, randomIndex
while (lo !== currentIndex) {
randomIndex = random.integer(lo, currentIndex-1);
currentIndex -= 1
temporaryValue = array[currentIndex]
array[currentIndex] = array[randomIndex]
array[randomIndex] = temporaryValue
}
return array
}
function _wait(t) {
return new Promise((resolve) => {
setTimeout(resolve, t);
});
}
function getProgramId(program) {
let s = program.serverKey;
if (typeof(s) === 'undefined') {
@ -68,78 +47,6 @@ function addProgramToShow(show, program) {
}
}
function getShowOrderer(show) {
if (typeof(show.orderer) === 'undefined') {
let sortedPrograms = JSON.parse( JSON.stringify(show.programs) );
sortedPrograms.sort((a, b) => {
let showA = getShowData(a);
let showB = getShowData(b);
return showA.order - showB.order;
});
let position = 0;
while (
(position + 1 < sortedPrograms.length )
&&
(
getShowData(show.founder).order
!==
getShowData(sortedPrograms[position]).order
)
) {
position++;
}
show.orderer = {
current : () => {
return sortedPrograms[position];
},
next: () => {
position = (position + 1) % sortedPrograms.length;
},
}
}
return show.orderer;
}
function getShowShuffler(show) {
if (typeof(show.shuffler) === 'undefined') {
if (typeof(show.programs) === 'undefined') {
throw Error(show.id + " has no programs?")
}
let randomPrograms = JSON.parse( JSON.stringify(show.programs) );
let n = randomPrograms.length;
shuffle( randomPrograms, 0, n);
let position = 0;
show.shuffler = {
current : () => {
return randomPrograms[position];
},
next: () => {
position++;
if (position == n) {
let a = Math.floor(n / 2);
shuffle(randomPrograms, 0, a );
shuffle(randomPrograms, a, n );
position = 0;
}
},
}
}
return show.shuffler;
}
module.exports = async( programs, schedule ) => {
if (! Array.isArray(programs) ) {
return { userError: 'Expected a programs array' };
@ -224,9 +131,9 @@ module.exports = async( programs, schedule ) => {
channel: show.channel,
}
} else if (slot.order === 'shuffle') {
return getShowShuffler(show).current();
return orderers.getShowShuffler(show).current();
} else if (slot.order === 'next') {
return getShowOrderer(show).current();
return orderers.getShowOrderer(show).current();
}
}
@ -236,9 +143,9 @@ module.exports = async( programs, schedule ) => {
}
let show = shows[ showsById[slot.showId] ];
if (slot.order === 'shuffle') {
return getShowShuffler(show).next();
return orderers.getShowShuffler(show).next();
} else if (slot.order === 'next') {
return getShowOrderer(show).next();
return orderers.getShowOrderer(show).next();
}
}

View File

@ -1,17 +1,17 @@
const events = require('events')
const constants = require("../constants");
const FALLBACK_ICON = "https://raw.githubusercontent.com/vexorain/dizquetv/main/resources/dizquetv.png";
const throttle = require('./throttle');
class TVGuideService
class TVGuideService extends events.EventEmitter
{
/****
*
**/
constructor(xmltv, db, cacheImageService, eventService) {
constructor(xmltv, db, cacheImageService, eventService, i18next) {
super();
this.cached = null;
this.lastUpdate = 0;
this.lastBackoff = 100;
this.updateTime = 0;
this.currentUpdate = -1;
this.currentLimit = -1;
@ -21,6 +21,7 @@ class TVGuideService
this.cacheImageService = cacheImageService;
this.eventService = eventService;
this._throttle = throttle;
this.i18next = i18next;
}
async get() {
@ -50,7 +51,8 @@ class TVGuideService
async refresh(t) {
while( this.lastUpdate < t) {
if (this.currentUpdate == -1) {
await _wait(5000);
if ( ( this.lastUpdate < t) && (this.currentUpdate == -1) ) {
this.currentUpdate = this.updateTime;
this.currentLimit = this.updateLimit;
this.currentChannels = this.updateChannels;
@ -69,7 +71,6 @@ class TVGuideService
await this.buildIt();
}
await _wait(100);
}
return await this.get();
}
@ -82,7 +83,17 @@ class TVGuideService
let arr = new Array( channel.programs.length + 1);
arr[0] = 0;
for (let i = 0; i < n; i++) {
arr[i+1] = arr[i] + channel.programs[i].duration;
let d = channel.programs[i].duration;
if (d == 0) {
console.log("Found program with duration 0, correcting it");
d = 1;
}
if (! Number.isInteger(d) ) {
console.log( `Found program in channel ${channel.number} with non-integer duration ${d}, correcting it`);
d = Math.ceil(d);
}
channel.programs[i].duration = d;
arr[i+1] = arr[i] + d;
await this._throttle();
}
return arr;
@ -90,6 +101,17 @@ class TVGuideService
async getCurrentPlayingIndex(channel, t) {
let s = (new Date(channel.startTime)).getTime();
if ( (typeof(channel.onDemand) !== 'undefined') && channel.onDemand.isOnDemand && channel.onDemand.paused ) {
// it's as flex
return {
index : -1,
start : t,
program : {
isOffline : true,
duration : 12*60*1000,
}
}
}
if (t < s) {
//it's flex time
return {
@ -105,6 +127,17 @@ class TVGuideService
if (typeof(accumulate) === 'undefined') {
throw Error(channel.number + " wasn't preprocesed correctly???!?");
}
if (accumulate[channel.programs.length] === 0) {
console.log("[tv-guide] for some reason the total channel length is 0");
return {
index : -1,
start: t,
program: {
isOffline: true,
duration: 15*60*1000,
}
}
}
let hi = channel.programs.length;
let lo = 0;
let d = (t - s) % (accumulate[channel.programs.length]);
@ -118,9 +151,18 @@ class TVGuideService
}
}
if (epoch + accumulate[lo+1] <= t) {
throw Error("General algorithm error, completely unexpected");
if ( (lo < 0) || (lo >= channel.programs.length) || (accumulate[lo+1] <= d) ) {
console.log("[tv-guide] The binary search algorithm is messed up. Replacing with flex...");
return {
index : -1,
start: t,
program: {
isOffline: true,
duration: 15*60*1000,
}
}
}
await this._throttle();
return {
index: lo,
@ -174,11 +216,24 @@ class TVGuideService
console.error("Redirrect to an unknown channel found! Involved channels = " + JSON.stringify(depth) );
} else {
let otherPlaying = await this.getChannelPlaying( channel2, undefined, t, depth );
let start = Math.max(playing.start, otherPlaying.start);
let duration = Math.min(
(playing.start + playing.program.duration) - start,
(otherPlaying.start + otherPlaying.program.duration) - start
);
let a1 = playing.start;
let b1 = a1 + playing.program.duration;
let a2 = otherPlaying.start;
let b2 = a2 + otherPlaying.program.duration;
if ( !(a1 <= t && t < b1) ) {
console.error("[tv-guide] algorithm error1 : " + a1 + ", " + t + ", " + b1 );
}
if ( !(a2 <= t && t < b2) ) {
console.error("[tv-guide] algorithm error2 : " + a2 + ", " + t + ", " + b2 );
}
let a = Math.max( a1, a2 );
let b = Math.min( b1, b2 );
let start = a;
let duration = b - a;
let program2 = clone( otherPlaying.program );
program2.duration = duration;
playing = {
@ -263,7 +318,12 @@ class TVGuideService
x.program.duration -= d;
}
if (x.program.duration == 0) {
console.error("There's a program with duration 0?");
console.error(channel.number + " There's a program with duration 0? " + JSON.stringify(x.program) + " ; " + t1 );
x.program.duration = 5 * 60 * 1000;
} else if ( ! Number.isInteger( x.program.duration ) ) {
console.error(channel.number + " There's a program with non-integer duration?? " + JSON.stringify(x.program) + " ; " + t1 );
x.program = JSON.parse( JSON.stringify(x.program) );
x.program.duration = Math.ceil(x.program.duration );
}
}
result.programs = [];
@ -331,9 +391,9 @@ class TVGuideService
program: {
duration: 24*60*60*1000,
icon: FALLBACK_ICON,
showTitle: "No channels configured",
showTitle: this.i18next.t("tvGuide.no_channels"),
date: formatDateYYYYMMDD(new Date()),
summary : "Use the dizqueTV web UI to configure channels."
summary : this.i18next.t("tvGuide.no_channels_summary")
}
} )
]
@ -349,18 +409,19 @@ class TVGuideService
return result;
}
async buildIt() {
async buildIt(lastRetry) {
try {
this.cached = await this.buildItManaged();
console.log("Internal TV Guide data refreshed at " + (new Date()).toLocaleString() );
await this.refreshXML();
this.lastBackoff = 100;
} catch(err) {
console.error("Unable to update internal guide data", err);
let w = Math.min(this.lastBackoff * 2, 300000);
let w = 100;
if (typeof(lastRetry) !== 'undefined') {
w = Math.min(w*2, 5 * 60 * 1000);
}
await _wait(w);
this.lastBackoff = w;
console.error(`Retrying TV guide after ${w} milliseconds wait...`);
console.error("Retrying TV guide...");
await this.buildIt();
} finally {
@ -374,10 +435,11 @@ class TVGuideService
let xmltvSettings = this.db['xmltv-settings'].find()[0];
await this.xmltv.WriteXMLTV(this.cached, xmltvSettings, async() => await this._throttle(), this.cacheImageService);
let t = "" + ( (new Date()) );
this.emit("xmltv-updated", { time: t } );
eventService.push(
"xmltv",
{
"message": `XMLTV updated at server time = ${t}`,
"message": this.i18next.t("tvGuide.xmltv_updated", {t}),
"module" : "xmltv",
"detail" : {
"time": new Date(),

View File

@ -7,37 +7,44 @@ function equalItems(a, b) {
if ( (typeof(a) === 'undefined') || a.isOffline || b.isOffline ) {
return false;
}
console.log("no idea how to compare this: " + JSON.stringify(a) );
console.log(" with this: " + JSON.stringify(b) );
return true;
return ( a.type === b.type);
}
function wereThereTooManyAttempts(sessionId, lineupItem) {
let obj = cache[sessionId];
let t1 = (new Date()).getTime();
if (typeof(obj) === 'undefined') {
let previous = cache[sessionId];
if (typeof(previous) === 'undefined') {
previous = cache[sessionId] = {
t0: t1 - constants.TOO_FREQUENT * 5
t0: t1 - constants.TOO_FREQUENT * 5,
lineupItem: null,
};
} else {
clearTimeout(obj.timer);
}
previous.timer = setTimeout( () => {
cache[sessionId].timer = null;
delete cache[sessionId];
}, constants.TOO_FREQUENT*5 );
let result = false;
if (previous.t0 + constants.TOO_FREQUENT >= t1) {
if (t1 - previous.t0 < constants.TOO_FREQUENT) {
//certainly too frequent
result = equalItems( previous.lineupItem, lineupItem );
}
cache[sessionId].t0 = t1;
cache[sessionId].lineupItem = lineupItem;
cache[sessionId] = {
t0: t1,
lineupItem : lineupItem,
};
setTimeout( () => {
if (
(typeof(cache[sessionId]) !== 'undefined')
&&
(cache[sessionId].t0 === t1)
) {
delete cache[sessionId];
}
}, constants.TOO_FREQUENT * 5 );
return result;
}

View File

@ -8,11 +8,17 @@ const ProgramPlayer = require('./program-player');
const channelCache = require('./channel-cache')
const wereThereTooManyAttempts = require('./throttler');
module.exports = { router: video }
module.exports = { router: video, shutdown: shutdown }
let StreamCount = 0;
function video( channelDB , fillerDB, db) {
let stopPlayback = false;
async function shutdown() {
stopPlayback = true;
}
function video( channelService, fillerDB, db, programmingService, activeChannelService ) {
var router = express.Router()
router.get('/setup', (req, res) => {
@ -46,18 +52,22 @@ function video( channelDB , fillerDB, db) {
})
// Continuously stream video to client. Leverage ffmpeg concat for piecing together videos
let concat = async (req, res, audioOnly) => {
if (stopPlayback) {
res.status(503).send("Server is shutting down.")
return;
}
// Check if channel queried is valid
if (typeof req.query.channel === 'undefined') {
res.status(500).send("No Channel Specified")
return
}
let number = parseInt(req.query.channel, 10);
let channel = await channelCache.getChannelConfig(channelDB, number);
if (channel.length === 0) {
let channel = await channelService.getChannel(number);
if (channel == null) {
res.status(500).send("Channel doesn't exist")
return
}
channel = channel[0]
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
@ -122,6 +132,11 @@ function video( channelDB , fillerDB, db) {
// Stream individual video to ffmpeg concat above. This is used by the server, NOT the client
let streamFunction = async (req, res, t0, allowSkip) => {
if (stopPlayback) {
res.status(503).send("Server is shutting down.")
return;
}
// Check if channel queried is valid
res.on("error", (e) => {
console.error("There was an unexpected error in stream.", e);
@ -136,9 +151,9 @@ function video( channelDB , fillerDB, db) {
let session = parseInt(req.query.session);
let m3u8 = (req.query.m3u8 === '1');
let number = parseInt(req.query.channel);
let channel = await channelCache.getChannelConfig(channelDB, number);
let channel = await channelService.getChannel( number);
if (channel.length === 0) {
if (channel == null) {
res.status(404).send("Channel doesn't exist")
return
}
@ -151,7 +166,6 @@ function video( channelDB , fillerDB, db) {
if ( (typeof req.query.first !== 'undefined') && (req.query.first=='1') ) {
isFirst = true;
}
channel = channel[0]
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
@ -179,11 +193,16 @@ function video( channelDB , fillerDB, db) {
duration: 40,
start: 0,
};
} else if (lineupItem == null) {
prog = helperFuncs.getCurrentProgramAndTimeElapsed(t0, channel);
} else if (lineupItem != null) {
redirectChannels = lineupItem.redirectChannels;
upperBounds = lineupItem.upperBounds;
brandChannel = redirectChannels[ redirectChannels.length -1];
} else {
prog = programmingService.getCurrentProgramAndTimeElapsed(t0, channel);
activeChannelService.peekChannel(t0, channel.number);
while (true) {
redirectChannels.push( brandChannel );
redirectChannels.push( helperFuncs.generateChannelContext(brandChannel) );
upperBounds.push( prog.program.duration - prog.timeElapsed );
if ( !(prog.program.isOffline) || (prog.program.type != 'redirect') ) {
@ -200,9 +219,9 @@ function video( channelDB , fillerDB, db) {
let newChannelNumber= prog.program.channel;
let newChannel = await channelCache.getChannelConfig(channelDB, newChannelNumber);
let newChannel = await channelService.getChannel(newChannelNumber);
if (newChannel.length == 0) {
if (newChannel == null) {
let err = Error("Invalid redirect to a channel that doesn't exist");
console.error("Invalid redirect to channel that doesn't exist.", err);
prog = {
@ -215,14 +234,14 @@ function video( channelDB , fillerDB, db) {
}
continue;
}
newChannel = newChannel[0];
brandChannel = newChannel;
lineupItem = channelCache.getCurrentLineupItem( newChannel.number, t0);
if (lineupItem != null) {
lineupItem = JSON.parse( JSON.stringify(lineupItem)) ;
break;
} else {
prog = helperFuncs.getCurrentProgramAndTimeElapsed(t0, newChannel);
prog = programmingService.getCurrentProgramAndTimeElapsed(t0, newChannel);
activeChannelService.peekChannel(t0, newChannel.number);
}
}
}
@ -268,6 +287,8 @@ function video( channelDB , fillerDB, db) {
//adjust upper bounds and record playbacks
for (let i = redirectChannels.length-1; i >= 0; i--) {
lineupItem = JSON.parse( JSON.stringify(lineupItem ));
lineupItem.redirectChannels = redirectChannels;
lineupItem.upperBounds = upperBounds;
let u = upperBounds[i] + beginningOffset;
if (typeof(u) !== 'undefined') {
let u2 = upperBound;
@ -300,6 +321,7 @@ function video( channelDB , fillerDB, db) {
channelCache.recordPlayback(channel.number, t0, lineupItem);
}
if (wereThereTooManyAttempts(session, lineupItem)) {
console.error("There are too many attempts to play the same item in a short period of time, playing the error stream instead.");
lineupItem = {
isOffline: true,
err: Error("Too many attempts, throttling.."),
@ -334,8 +356,14 @@ function video( channelDB , fillerDB, db) {
'Content-Type': 'video/mp2t'
});
shieldActiveChannels(redirectChannels, t0, constants.START_CHANNEL_GRACE_PERIOD);
let t1;
try {
playerObj = await player.play(res);
t1 = (new Date()).getTime();
console.log("Latency: (" + (t1- t0) );
} catch (err) {
console.log("Error when attempting to play video: " +err.stack);
try {
@ -347,7 +375,59 @@ function video( channelDB , fillerDB, db) {
return;
}
if (! isLoading) {
//setup end event to mark the channel as not playing anymore
let t0 = new Date().getTime();
let b = 0;
let stopDetected = false;
if (typeof(lineupItem.beginningOffset) !== 'undefined') {
b = lineupItem.beginningOffset;
t0 -= b;
}
// we have to do it for every single redirected channel...
for (let i = redirectChannels.length-1; i >= 0; i--) {
activeChannelService.registerChannelActive(t0, redirectChannels[i].number);
}
let listener = (data) => {
if (data.ignoreOnDemand) {
console.log("Ignore channel update because it is from on-demand service");
return;
}
let shouldStop = false;
try {
for (let i = 0; i < redirectChannels.length; i++) {
if (redirectChannels[i].number == data.channelNumber) {
shouldStop = true;
}
}
if (shouldStop) {
console.log("Playing channel has received an update.");
shieldActiveChannels( redirectChannels, t0, constants.CHANNEL_STOP_SHIELD )
setTimeout(stop, 100);
}
} catch (error) {
console.err("Unexpected error when processing channel change during playback", error);
}
};
channelService.on("channel-update", listener);
let oldStop = stop;
stop = () => {
channelService.removeListener("channel-update", listener);
if (!stopDetected) {
stopDetected = true;
let t1 = new Date().getTime();
t1 = Math.max( t0 + 1, t1 - constants.FORGETFULNESS_BUFFER - b );
for (let i = redirectChannels.length-1; i >= 0; i--) {
activeChannelService.registerChannelStopped(t1, redirectChannels[i].number);
}
}
oldStop();
};
}
let stream = playerObj;
@ -356,9 +436,13 @@ function video( channelDB , fillerDB, db) {
stream.on("end", () => {
let t2 = (new Date()).getTime();
console.log("Played video for: " + (t2 - t1) + " ms");
stop();
});
res.on("close", () => {
let t2 = (new Date()).getTime();
console.log("Played video for: " + (t2 - t1) + " ms");
console.log("Client Closed");
stop();
});
@ -371,6 +455,12 @@ function video( channelDB , fillerDB, db) {
router.get('/m3u8', async (req, res) => {
if (stopPlayback) {
res.status(503).send("Server is shutting down.")
return;
}
let sessionId = StreamCount++;
//res.type('application/vnd.apple.mpegurl')
@ -383,8 +473,8 @@ function video( channelDB , fillerDB, db) {
}
let channelNum = parseInt(req.query.channel, 10)
let channel = await channelCache.getChannelConfig(channelDB, channelNum );
if (channel.length === 0) {
let channel = await channelService.getChannel(channelNum );
if (channel == null) {
res.status(500).send("Channel doesn't exist")
return
}
@ -419,6 +509,12 @@ function video( channelDB , fillerDB, db) {
res.send(data)
})
router.get('/playlist', async (req, res) => {
if (stopPlayback) {
res.status(503).send("Server is shutting down.")
return;
}
res.type('text')
// Check if channel queried is valid
@ -428,8 +524,8 @@ function video( channelDB , fillerDB, db) {
}
let channelNum = parseInt(req.query.channel, 10)
let channel = await channelCache.getChannelConfig(channelDB, channelNum );
if (channel.length === 0) {
let channel = await channelService.getChannel(channelNum );
if (channel == null) {
res.status(500).send("Channel doesn't exist")
return
}
@ -464,10 +560,25 @@ function video( channelDB , fillerDB, db) {
res.send(data)
})
let shieldActiveChannels = (channelList, t0, timeout) => {
// because of channel redirects, it's possible that multiple channels
// are being played at once. Mark all of them as being played
// this is a grave period of 30
//mark all channels being played as active:
for (let i = channelList.length-1; i >= 0; i--) {
activeChannelService.registerChannelActive(t0, channelList[i].number);
}
setTimeout( () => {
for (let i = channelList.length-1; i >= 0; i--) {
activeChannelService.registerChannelStopped(t0, channelList[i].number);
}
}, timeout );
}
let mediaPlayer = async(channelNum, path, req, res) => {
let channel = await channelCache.getChannelConfig(channelDB, channelNum );
if (channel.length === 0) {
let channel = await channelService.getChannel(channelNum );
if (channel === null) {
res.status(404).send("Channel not found.");
return;
}

View File

@ -51,7 +51,7 @@ function writePromise(json, xmlSettings, throttle, cacheImageService) {
function _writeDocStart(xw) {
xw.startDocument()
xw.startElement('tv')
xw.writeAttribute('generator-info-name', 'psuedotv-plex')
xw.writeAttribute('generator-info-name', 'dizquetv')
}
function _writeDocEnd(xw, ws) {
xw.endElement()

View File

@ -4,8 +4,33 @@ require('./ext/lazyload')(angular)
require('./ext/dragdrop')
require('./ext/angularjs-scroll-glue')
require('angular-vs-repeat');
require('angular-sanitize');
const i18next = require('i18next');
const i18nextHttpBackend = require('i18next-http-backend');
window.i18next = i18next;
var app = angular.module('myApp', ['ngRoute', 'vs-repeat', 'angularLazyImg', 'dndLists', 'luegg.directives'])
window.i18next.use(i18nextHttpBackend);
window.i18next.init({
// debug: true,
lng: 'en',
fallbackLng: 'en',
preload: ['en'],
ns: [ 'main' ],
defaultNS: [ 'main' ],
initImmediate: false,
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json'
},
useCookie: false,
useLocalStorage: false,
}, function (err, t) {
console.log('resources loaded');
});
require('ng-i18next');
var app = angular.module('myApp', ['ngRoute', 'vs-repeat', 'angularLazyImg', 'dndLists', 'luegg.directives', 'jm.i18next'])
app.service('plex', require('./services/plex'))
app.service('dizquetv', require('./services/dizquetv'))

View File

@ -83,7 +83,6 @@ module.exports = function ($scope, $timeout, dizquetv) {
$scope.t1 = (new Date()).getTime();
$scope.t1 = ($scope.t1 - $scope.t1 % MINUTE );
$scope.t0 = $scope.t1 - $scope.before + $scope.offset;
$scope.title = "TV Guide";
$scope.times = [];
$scope.updateJustNow();

View File

@ -49,6 +49,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
scope.episodeMemory = {
saved : false,
};
scope.fixedOnDemand = false;
if (typeof scope.channel === 'undefined' || scope.channel == null) {
scope.channel = {}
scope.channel.programs = []
@ -86,6 +87,10 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
scope.channel.transcoding = {
targetResolution: "",
}
scope.channel.onDemand = {
isOnDemand : false,
modulo: 1,
}
} else {
scope.beforeEditChannelNumber = scope.channel.number
@ -142,6 +147,16 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
scope.channel.transcoding.targetResolution = "";
}
if (typeof(scope.channel.onDemand) === 'undefined') {
scope.channel.onDemand = {};
}
if (typeof(scope.channel.onDemand.isOnDemand) !== 'boolean') {
scope.channel.onDemand.isOnDemand = false;
}
if (typeof(scope.channel.onDemand.modulo) !== 'number') {
scope.channel.onDemand.modulo = 1;
}
adjustStartTimeToCurrentProgram();
updateChannelDuration();
@ -163,6 +178,26 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
let t = Date.now();
let originalStart = scope.channel.startTime.getTime();
let n = scope.channel.programs.length;
if (
(scope.channel.onDemand.isOnDemand === true)
&&
(scope.channel.onDemand.paused === true)
&&
! scope.fixedOnDemand
) {
//this should only happen once per channel
scope.fixedOnDemand = true;
originalStart = new Date().getTime();
originalStart -= scope.channel.onDemand.playedOffset;
let m = scope.channel.onDemand.firstProgramModulo;
let n = originalStart % scope.channel.onDemand.modulo;
if (n < m) {
originalStart += (m - n);
} else if (n > m) {
originalStart -= (n - m) - scope.channel.onDemand.modulo;
}
}
//scope.channel.totalDuration might not have been initialized
let totalDuration = 0;
for (let i = 0; i < n; i++) {
@ -220,6 +255,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
{ name: "Flex", id: "flex" },
{ name: "EPG", id: "epg" },
{ name: "FFmpeg", id: "ffmpeg" },
{ name: "On-demand", id: "ondemand" },
];
scope.setTab = (tab) => {
scope.tab = tab;
@ -1429,6 +1465,10 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
scope.videoRateDefault = "(Use global setting)";
scope.videoBufSizeDefault = "(Use global setting)";
scope.randomizeBlockShuffle = false;
scope.advancedTools = (localStorage.getItem("channel-programming-advanced-tools" ) === "show");
let refreshScreenResolution = async () => {
@ -1617,13 +1657,21 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
scope.onTimeSlotsDone = (slotsResult) => {
scope.channel.scheduleBackup = slotsResult.schedule;
readSlotsResult(slotsResult);
if (slotsResult === null) {
delete scope.channel.scheduleBackup;
} else {
scope.channel.scheduleBackup = slotsResult.schedule;
readSlotsResult(slotsResult);
}
}
scope.onRandomSlotsDone = (slotsResult) => {
scope.channel.randomScheduleBackup = slotsResult.schedule;
readSlotsResult(slotsResult);
if (slotsResult === null) {
delete scope.channel.randomScheduleBackup;
} else {
scope.channel.randomScheduleBackup = slotsResult.schedule;
readSlotsResult(slotsResult);
}
}
@ -1636,6 +1684,73 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
scope.randomSlots.startDialog(progs, scope.maxSize, scope.channel.randomScheduleBackup );
}
scope.rerollRandomSlots = () => {
let progs = commonProgramTools.removeDuplicates( scope.channel.programs );
scope.randomSlots.startDialog(
progs, scope.maxSize, scope.channel.randomScheduleBackup,
true
);
}
scope.hasNoRandomSlots = () => {
return (
(typeof(scope.channel.randomScheduleBackup) === 'undefined' )
||
(scope.channel.randomScheduleBackup == null)
);
}
scope.rerollTimeSlots = () => {
let progs = commonProgramTools.removeDuplicates( scope.channel.programs );
scope.timeSlots.startDialog(
progs, scope.maxSize, scope.channel.scheduleBackup,
true
);
}
scope.hasNoTimeSlots = () => {
return (
(typeof(scope.channel.scheduleBackup) === 'undefined' )
||
(scope.channel.scheduleBackup == null)
);
}
scope.toggleAdvanced = () => {
scope.advancedTools = ! scope.advancedTools;
localStorage.setItem("channel-programming-advanced-tools" , scope.advancedTools ? "show" : "hide");
}
scope.hasAdvancedTools = () => {
return scope.advancedTools;
}
scope.toolWide = () => {
if ( scope.hasAdvancedTools()) {
return {
"col-xl-6": true,
"col-md-12" : true
}
} else {
return {
"col-xl-12": true,
"col-lg-12" : true
}
}
}
scope.toolThin = () => {
if ( scope.hasAdvancedTools()) {
return {
"col-xl-3": true,
"col-lg-6" : true
}
} else {
return {
"col-xl-6": true,
"col-lg-6" : true
}
}
}
scope.logoOnChange = (event) => {
const formData = new FormData();
formData.append('image', event.target.files[0]);

View File

@ -1,4 +1,4 @@
module.exports = function ($timeout) {
module.exports = function ($timeout, commonProgramTools, getShowData) {
return {
restrict: 'E',
templateUrl: 'templates/filler-config.html',
@ -92,13 +92,26 @@ module.exports = function ($timeout) {
id: scope.id,
} );
}
scope.getText = (clip) => {
let show = getShowData(clip);
if (show.hasShow && show.showId !== "movie." ) {
return show.showDisplayName + " - " + clip.title;
} else {
return clip.title;
}
}
scope.showList = () => {
return ! scope.showPlexLibrary;
}
scope.sortFillers = () => {
scope.sortFillersByLength = () => {
scope.content.sort( (a,b) => { return a.duration - b.duration } );
refreshContentIndexes();
}
scope.sortFillersCorrectly = () => {
scope.content = commonProgramTools.sortShows(scope.content);
refreshContentIndexes();
}
scope.fillerRemoveAllFiller = () => {
scope.content = [];
refreshContentIndexes();

View File

@ -177,8 +177,22 @@ module.exports = function ($timeout, dizquetv, getShowData) {
{ id: "shuffle", description: "Shuffle" },
];
let doIt = async() => {
let doWait = (millis) => {
return new Promise( (resolve) => {
$timeout( resolve, millis );
} );
}
let doIt = async(fromInstant) => {
let t0 = new Date().getTime();
let res = await dizquetv.calculateRandomSlots(scope.programs, scope.schedule );
let t1 = new Date().getTime();
let w = Math.max(0, 250 - (t1 - t0) );
if (fromInstant && (w > 0) ) {
await doWait(w);
}
for (let i = 0; i < scope.schedule.slots.length; i++) {
delete scope.schedule.slots[i].weightPercentage;
}
@ -189,7 +203,7 @@ module.exports = function ($timeout, dizquetv, getShowData) {
let startDialog = (programs, limit, backup) => {
let startDialog = (programs, limit, backup, instant) => {
scope.limit = limit;
scope.programs = programs;
@ -213,11 +227,15 @@ module.exports = function ($timeout, dizquetv, getShowData) {
id: "flex.",
description: "Flex",
} );
if (typeof(backup) !== 'undefined') {
scope.hadBackup = (typeof(backup) !== 'undefined');
if (scope.hadBackup) {
loadBackup(backup);
}
scope.visible = true;
if (instant) {
scope.finished(false, true);
}
}
@ -225,13 +243,18 @@ module.exports = function ($timeout, dizquetv, getShowData) {
startDialog: startDialog,
} );
scope.finished = async (cancel) => {
scope.finished = async (cancel, fromInstant) => {
scope.error = null;
if (!cancel) {
if ( scope.schedule.slots.length === 0) {
scope.onDone(null);
scope.visible = false;
return;
}
try {
scope.loading = true;
$timeout();
scope.onDone( await doIt() );
scope.onDone( await doIt(fromInstant) );
scope.visible = false;
} catch(err) {
console.error("Unable to generate channel lineup", err);
@ -267,6 +290,20 @@ module.exports = function ($timeout, dizquetv, getShowData) {
return false;
}
scope.hideCreateLineup = () => {
return (
scope.disableCreateLineup()
&& (scope.schedule.slots.length == 0)
&& scope.hadBackup
);
}
scope.showResetSlots = () => {
return scope.hideCreateLineup();
}
scope.canShowSlot = (slot) => {
return (slot.showId != 'flex.') && !(slot.showId.startsWith('redirect.'));
}

View File

@ -203,9 +203,23 @@ module.exports = function ($timeout, dizquetv, getShowData ) {
{ id: "shuffle", description: "Shuffle" },
];
let doIt = async() => {
let doWait = (millis) => {
return new Promise( (resolve) => {
$timeout( resolve, millis );
} );
}
let doIt = async(fromInstant) => {
scope.schedule.timeZoneOffset = (new Date()).getTimezoneOffset();
let t0 = new Date().getTime();
let res = await dizquetv.calculateTimeSlots(scope.programs, scope.schedule );
let t1 = new Date().getTime();
let w = Math.max(0, 250 - (t1 - t0) );
if (fromInstant && (w > 0) ) {
await doWait(w);
}
res.schedule = scope.schedule;
delete res.schedule.fake;
return res;
@ -214,7 +228,7 @@ module.exports = function ($timeout, dizquetv, getShowData ) {
let startDialog = (programs, limit, backup) => {
let startDialog = (programs, limit, backup, instant) => {
scope.limit = limit;
scope.programs = programs;
@ -238,11 +252,15 @@ module.exports = function ($timeout, dizquetv, getShowData ) {
id: "flex.",
description: "Flex",
} );
if (typeof(backup) !== 'undefined') {
scope.hadBackup = (typeof(backup) !== 'undefined');
if (scope.hadBackup) {
loadBackup(backup);
}
scope.visible = true;
if (instant) {
scope.finished(false, true);
}
}
@ -250,13 +268,19 @@ module.exports = function ($timeout, dizquetv, getShowData ) {
startDialog: startDialog,
} );
scope.finished = async (cancel) => {
scope.finished = async (cancel, fromInstant) => {
scope.error = null;
if (!cancel) {
if ( scope.schedule.slots.length === 0) {
scope.onDone(null);
scope.visible = false;
return;
}
try {
scope.loading = true;
$timeout();
scope.onDone( await doIt() );
scope.onDone( await doIt(fromInstant) );
scope.visible = false;
} catch(err) {
console.error("Unable to generate channel lineup", err);
@ -292,6 +316,18 @@ module.exports = function ($timeout, dizquetv, getShowData ) {
return false;
}
scope.hideCreateLineup = () => {
return (
scope.disableCreateLineup()
&& (scope.schedule.slots.length == 0)
&& scope.hadBackup
);
}
scope.showResetSlots = () => {
return scope.hideCreateLineup();
}
scope.canShowSlot = (slot) => {
return (slot.showId != 'flex.') && !(slot.showId.startsWith('redirect.'));
}

View File

@ -33,7 +33,12 @@
</a>
</small>
</h1>
<a href="#!/guide">Guide</a> - <a href="#!/channels">Channels</a> - <a href="#!/library">Library</a> - <a href="#!/player">Player</a> - <a href="#!/settings">Settings</a> - <a href="#!/version">Version</a>
<a href="#!/guide">{{'topMenu.guide' | i18next}}</a> -
<a href="#!/channels">{{'topMenu.channels' | i18next}}</a> -
<a href="#!/library">{{'topMenu.library' | i18next}}</a> -
<a href="#!/player">{{'topMenu.player' | i18next}}</a> -
<a href="#!/settings">{{'topMenu.settings' | i18next}}</a> -
<a href="#!/version">{{'topMenu.version' | i18next}}</a>
<span class="pull-right">
<span style="margin-right: 15px;">
<a href="/api/xmltv.xml">XMLTV <span class="far fa-file-code"></span></a>

View File

@ -0,0 +1,77 @@
{
"topMenu": {
"guide": "Guide",
"channels": "Channels",
"library": "Library",
"player": "Player",
"settings": "Settings",
"version": "Version"
},
"guide": {
"title": "Tv Guide",
"attempt_to_play_channel": "Attempt to play channel: {{title}} in local media player"
},
"settings_server": {
"title": "Plex Settings",
"servers": "Plex Servers",
"sign_server": "Sign In/Add Servers",
"add_server": "Add a Plex Server",
"minutes_to_sign_plex": "You have 2 minutes to sign into your Plex Account.",
"name": "Name",
"uri": "URI",
"ui_route": "UI Route",
"backend_route": "Backend Route",
"ok": "ok",
"error": "error",
"ui_bad": "If a Plex server configuration has problems with the UI route, the channel editor won't be able to access its content.",
"backend_bad": "If a Plex server configuration has problems with the backend route, dizqueTV won't be able to play its content.",
"plex_transcoder_settings": "Plex Transcoder Settings",
"update": "Update",
"reset_options": "Reset Options",
"debug_logging": "Debug logging",
"paths": "Paths",
"send_status_plex": "Send play status to Plex",
"send_status_plex_note": "Note: This affects the \"on deck\" for your plex account.",
"no_plex_path": "If stream changes video codec, audio codec, or audio channels upon episode change, you will experience playback issues unless ffmpeg transcoding and normalization are also enabled.",
"video_options": "Video Options",
"supported_video_formats": "Supported Video Formats",
"max_playable_resolution": "Max Playable Resolution",
"max_transcode_resolution": "Max Transcode Resolution",
"audio_options": "Audio Options",
"supported_audio_formats": "Supported Audio Formats",
"supported_audio_formats_note": "Comma separated list. Some possible values are 'ac3,aac,mp3'.",
"max_audio_channels": "Maximum Audio Channels",
"max_audio_channels_note": "Note: 7.1 audio and on some clients, 6.1, is known to cause playback issues.",
"audio_boost": "Audio Boost",
"audio_boost_note": "Note: Only applies when downmixing to stereo.",
"miscellaneous_options": "Miscellaneous Options",
"max_direct_stream_bitrate": "Max Direct Stream Bitrate (Kbps)",
"max_transcode_bitrate": "Max Transcode Bitrate (Kbps)",
"max_transcode_bitrate_note": "Plex will decide to transcode or direct play based on these settings and if Plex transcodes, it will try to match the transcode bitrate.",
"direct_stream_media_buffer": "Direct Stream Media Buffer Size",
"transcode_media_buffer": "Transcode Media Buffer Size",
"stream_protocol": "Stream Protocol",
"force_direct_play": "Force Direct Play",
"subtitle_options": "Subtitle Options",
"subtitle_size": "Subtitle Size",
"enable_subtitle": "Enable Subtitles (Requires Transcoding)",
"path_replacements": "Path Replacements",
"original_plex_path": "Original Plex path to replace:",
"replace_plex_path": "Replace Plex path with:"
},
"settings_xmltv": {
"title": "XMLTV Settings",
"update": "Update",
"reset_options": "Reset Options",
"output_path": "Output Path",
"output_path_note": "You can edit this location in file xmltv-settings.json.",
"epg_hours": "EPG Hours",
"epg_hours_note": "How many hours of programming to include in the xmltv file.",
"refresh_timer": "Refresh Timer (hours)",
"refresh_timer_note": "How often should the xmltv file be updated.",
"image_cache": "Image Cache",
"image_cache_note": "If enabled the pictures used for Movie and TV Show posters will be cached in dizqueTV's .dizqueTV folder and will be delivered by dizqueTV's server instead of requiring calls to Plex. Note that using fixed xmltv location in Plex (as opposed to url) will not work correctly in this case."
}
}

View File

@ -83,7 +83,7 @@
<input id="channelEndTime" class="form-control form-control-sm col-md-auto" type="datetime-local" ng-model="endTime" ng-disabled="true" aria-describedby="endTimeHelp"></input>
</div>
<div class='col-md-auto'>
<small class="text-muted form-text" id='endTimeHelp'>Programming will restart from the beginning.</small>
<small class="text-muted form-text" id='endTimeHelp'>Programming will restart from the beginning. </small><small ng-show='channel.onDemand.isOnDemand' class="text-muted form-text" id='endTimeHelp'>For on-demand channels, the times in the schedule are tentative. </small>
</div>
</div>
@ -197,23 +197,29 @@
<small class='text-info'>There are no programs in the channel, use the <i class='fas fa-plus'></i> button to add programs from your media library or use the Tools to add Flex time or a Channel Redirect</small>
</div>
<div class='col-md-4 col-sm-12 col-xl-6 col-lg-5 programming-pane tools-pane' ng-show="showShuffleOptions"
<div ng-class='{
"col-md-4" : true,
"col-sm-12" : true,
"col-xl-6" : hasAdvancedTools(),
"col-lg-5" : hasAdvancedTools(),
"col-xl-4" : !hasAdvancedTools(),
"col-lg-3" : !hasAdvancedTools()
}' class='programming-pane tools-pane' ng-show="showShuffleOptions"
ng-style="{'max-height':programmingHeight()}"
>
<div class="row">
<div class="col-xl-6 col-md-12" style="padding: 5px;" ng-show="hasPrograms()">
<div ng-class="toolWide()" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group">
<div class="input-group-prepend">
<input type="number" class="form-control form-control-sm" placeholder="Desired number of consecutive TV shows." min="1" max="10" ng-model="blockCount" style="width:5em">
</input>
</div>
<div class="input-group-prepend">
<div class="input-group-text" style="padding: 0;">
<label class="small" for="randomizeBlockShuffle" style="margin-bottom: 2px;">&nbsp;Randomize&nbsp;&nbsp;</label>
<input id="randomizeBlockShuffle" type="checkbox" ng-model="randomizeBlockShuffle"></input>
&nbsp;
</div>
<select class="custom-select" ng-model="randomizeBlockShuffle">
<option ng-value="false" label="Fixed" />
<option ng-value="true" label="Random" />
</select>
</div>
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="blockShuffle(blockCount, randomizeBlockShuffle)">
<i class='fa fa-random' title='Block Shuffle' ></i> Block Shuffle
@ -223,7 +229,7 @@
</div>
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()">
<div ng-class="toolThin()" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="randomShuffle()" aria-describedby="randomShuffleHelp" title='Random Shuffle'>
<i class='fa fa-random'></i> Random Shuffle
@ -234,7 +240,7 @@
</p>
</div>
<div class='col-xl-3 col-lg-6' style="padding: 5px;" ng-show="hasPrograms()" >
<div ng-class="toolThin()" style="padding: 5px;" ng-show="hasPrograms()" >
<div class="input-group">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="cyclicShuffle()" title='Cyclic Shuffle' >
<i class='fa fa-random'></i> Cyclic Shuffle
@ -243,7 +249,7 @@
<p ng-show='showHelp.check'>Like Random Shuffle, but tries to preserve the sequence of episodes for each TV show. If a TV show has multiple instances of its episodes, they are also cycled appropriately.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div ng-class="toolWide()" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group">
<div class="input-group-prepend">
<input type="number" class="form-control form-control-sm" placeholder="Repeats" min="1" max="{{maxReplicas()}}" ng-model="replicaCount" style="width:5em">
@ -255,7 +261,7 @@
<p ng-show='showHelp.check'>Makes multiple copies of the schedule and plays them in sequence. Normally this isn&apos;t necessary, because dizqueTV will always play the schedule back from the beginning when it finishes. But creating replicas is a useful intermediary step sometimes before applying other transformations. Note that because very large channels can be problematic, the number of replicas will be limited to avoid creating really large channels.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div ng-class="toolWide()" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group">
<div class="input-group-prepend">
<input type="number" class="form-control form-control-sm" placeholder="Repeats" min="1" max="{{maxReplicas()}}" ng-model="randomReplicaCount" style="width:5em">
@ -267,7 +273,7 @@
<p ng-show='showHelp.check'>Like &quot;Replicate&quot;, it will make multiple copies of the programming. In addition it will shuffle the programs, but it will make sure not to have too small a distance between two identical programs.</p>
</div>
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()" >
<div ng-class="toolThin()" style="padding: 5px;" ng-show="hasPrograms()" >
<div class='input-group'>
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortShows()" title='Sort TV Shows' >
<i class='fa fa-sort-alpha-down'></i> Sort TV Shows
@ -278,7 +284,7 @@
</p>
</div>
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()" >
<div ng-class="toolThin()" style="padding: 5px;" ng-show="hasPrograms()" >
<div class="input-group">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortByDate()" title='Sort Release Dates' >
<i class='fa fa-sort-numeric-down'></i> Sort Release Dates
@ -287,7 +293,7 @@
<p ng-show='showHelp.check'>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>
</div>
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()" >
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()" >
<div class="input-group">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="equalizeShows()" title='Balance Shows' >
<i class='fa fa-balance-scale'></i> Balance Shows
@ -296,7 +302,7 @@
<p ng-show='showHelp.check'>Will replicate some TV shows or delete duplicates of other TV shows in an effort to make it so the total durations of all episodes of each episode are as similar as possible. It&apos;s usually impossible to make the shows perfectly balanced without creating a really high number of duplicates, but it will try to get close. Movies are treated as a single show.</p>
</div>
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()" >
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()" >
<div class='input-group'>
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="startFrequencyTweak()" title='Tweak Weights...'>
<i class='fa fa-balance-scale'></i> Tweak Weights...
@ -314,7 +320,7 @@
<p ng-show='showHelp.check'>Programs a Flex time slot. Normally you&apos;d use pad times, restrict times or add breaks to add a large quantity of Flex times at once, but this exists for more specific cases.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group">
<div class="input-group-prepend">
<select class="custom-select" ng-model="nightStart"
@ -330,7 +336,7 @@
<p ng-show='showHelp.check'>The channel's regular programming between the specified hours. Flex time will fill up the remaining hours.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group">
<div class="input-group-prepend">
<select class="custom-select" ng-model="paddingOption"
@ -344,7 +350,7 @@
<p ng-show='showHelp.check'>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. This button might be disabled if the channel is already too large.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group">
<div class="input-group-prepend">
<select class="custom-select" style="width:5em" ng-model="breakAfter"
@ -361,7 +367,7 @@
<p ng-show='showHelp.check'>Adds Flex breaks between programs, attempting to avoid groups of consecutive programs that exceed the specified number of minutes. This button might be disabled if the channel is already too large.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group">
<div class="input-group-prepend">
<select class="custom-select" ng-model="rerunStart"
@ -381,7 +387,7 @@
<p ng-show='showHelp.check'>Divides the programming in blocks of 6, 8 or 12 hours then repeats each of the blocks the specified number of times. For example, you can make a channel that plays exactly the same channels in the morning and in the afternoon. This button might be disabled if the channel is already too large.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group" >
<div class="input-group-prepend">
<button class="btn btn-sm btn-secondary form-control form-control-sm" type="button" ng-click="savePositions()">
@ -407,7 +413,7 @@
<p ng-show='showHelp.check'>Adds a channel redirect. During this period of time, the channel will redirect to another channel.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class="input-group">
<div class="input-group-prepend">
<div class='loader' ng-hide='channelsDownloaded'></div>
@ -425,7 +431,7 @@
<p ng-show='showHelp.check'>Will redirect to another channel while between the selected hours.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div ng-class="toolWide()" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group">
<div class="input-group-prepend">
<button class='btn btn-sm btn-warning form-control' ng-click="slideAllPrograms(-slide.value)"
@ -449,27 +455,51 @@
<p ng-show='showHelp.check'>Slides the whole schedule. The &quot;Fast-Forward&quot; button will advance the stream by the specified amount of time. The &quot;Rewind&quot; button does the opposite.</p>
</div>
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group">
<div ng-class="toolThin()" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group" >
<div class="input-group-prepend">
<button class="btn btn-sm btn-warning form-control"
type="button"
ng-click="rerollTimeSlots()"
ng-disabled="hasNoTimeSlots()"
title = "Regenerate time slots..."
>
<i class='fas fa-redo'></i>
</button>
</div>
<button class='btn btn-sm btn-warning form-control' ng-click="onTimeSlotsButtonClick()"
title="Time Slots..."
>
<i class='fas fa-blender'></i> Time Slots...
</button>
</div>
<p ng-show='showHelp.check'>This allows to schedul specific shows to run at specific time slots of the day or a week. It&apos;s recommended you first populate the channel with the episodes from the shows you want to play and/or other content like movies and redirects.</p>
<p ng-show='showHelp.check'>This allows to schedule specific shows to run at specific time slots of the day or a week. It&apos;s recommended you first populate the channel with the episodes from the shows you want to play and/or other content like movies and redirects.</p>
</div>
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group">
<div ng-class="toolThin()" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group" >
<div class="input-group-prepend">
<button class="btn btn-sm btn-warning form-control"
type="button"
ng-click="rerollRandomSlots()"
ng-disabled="hasNoRandomSlots()"
title = "Regenerate random slots..."
>
<i class='fas fa-redo'></i>
</button>
</div>
<button class='btn btn-sm btn-warning form-control' ng-click="onRandomSlotsButtonClick()"
title="Random Slots..."
>
<i class='fas fa-flask'></i> Random Slots...
</button>
</div>
<p ng-show='showHelp.check'>This is similar to Time Slots, but instead of time sections, you pick a probability to play each tv show and the length of the block.</p>
<p ng-show='showHelp.check'>This is similar to Time Slots, but instead of time sections, you pick a probability to play each tv show and the length of the block. Once a channel has been configured with random slots, the reload button can re-evaluate them again, with the saved settings.</p>
</div>
@ -493,7 +523,7 @@
<p ng-show='showHelp.check'>Removes any Flex periods from the schedule.</p>
</div>
<div class="col-md-auto" style="padding: 5px;" ng-show="hasPrograms()">
<div class="col-md-auto" style="padding: 5px;" ng-show="hasPrograms() && hasAdvancedTools()">
<div class='input-group'>
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="wipeSpecials()" title='Remove Specials' >
<i class='fa fa-trash-alt'></i> Specials
@ -519,6 +549,24 @@
</div>
<p ng-show='showHelp.check'>Wipes out the schedule so that you can start over.</p>
</div>
<br />
<div class="col-xl-6 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group" >
<button class='btn btn-sm btn-outline-secondary form-control' ng-click="toggleAdvanced()"
title="Toggle extra tools..."
>
<i class='fas fa-tools'></i> {{ hasAdvancedTools() ? "Less" : "More"}} Tools...
</button>
</div>
<p ng-show='showHelp.check'>Use this button to show or hide a bunch of additional tools that might be useful.</p>
</div>
</div>
</div>
@ -838,6 +886,38 @@
</div>
<!--
============= TAB: ON-DEMAND =========================
-->
<div class="modal-body" ng-if="tab == 'ondemand'">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="onDemand" aria-describedby="onDemandHelp" ng-model='channel.onDemand.isOnDemand'>
<label class="form-check-label" for="onDemand">On-Demand</label>
<span class='text-muted' id="stealthHelp">(The channel's programming will be paused when it is not being played. No programs will appear in the TV-guide while the channel is paused.)</span>
</div>
<br></br>
<div class='form-group' ng-show='channel.onDemand.isOnDemand'>
<label class='form-label' for="segmentLength" >Segment Length:</label>
<select class="form-control custom-select" id="segmentLength" ng-model="channel.onDemand.modulo" convert-to-number >
<option ng-value="1">Instant</option>
<option ng-value="300000">5 minutes</option>
<option ng-value="600000">10 minutes</option>
<option ng-value="900000">15 minutes</option>
<option ng-value="1800000">30 minutes</option>
<option ng-value="6000000">1 hour</option>
</select>
<small id='guideFlexHelp' class="text-muted" for='guideFlex'>Channel will be divided in segments. For example, if you use padding or time slots in your channel so that everything starts at 0:00 or 0:30 , you want a 30 minutes-segment. Use no segment if you want the channel to play exactly where you left it. Flex time will be added if necessary for padding.</small>
</div>
</div>
<div class="modal-footer">
<span class="pull-right text-danger" ng-show="error.any"> <i class='fa fa-exclamation-triangle'></i> There were errors. Please review the form.</span>
<span class="pull-right text-info" ng-show='! hasPrograms() && (tab != "programming")'> <i class='fas fa-info-circle'></i> Use the &quot;Programming&quot; tab to add programs to the channel.</span>

View File

@ -37,16 +37,23 @@
<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()">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortFillersCorrectly()">
<i class='fa fa-sort-alpha-down'></i> Sort Clips
</button>
</div>
<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="sortFillersByLength()">
<i class='fa fa-sort-amount-down-alt'></i> 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()">
<i class='fa fa-trash-alt'></i> Remove Duplicates
</button>
</div>
<div class="input-group col-md-6" style="padding: 5px;">
<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="fillerRemoveAllFiller()">
<i class='fa fa-trash-alt'></i> Remove All Filler
</button>
@ -79,7 +86,7 @@
</div>
<div ng-style="programSquareStyle(x, false)" ></div>
<div class="title" >
{{x.title}}
{{ getText(x) }}
</div>
<div class="flex-pull-right">
<button class="btn btn-sm btn-link" ng-click="contentSplice(x.$index,1)">

View File

@ -1,28 +1,28 @@
<div>
<h5>Plex Settings</h5>
<h6>Plex Servers
<h5>{{'settings_server.title' | i18next}}</h5>
<h6>{{'settings_server.servers' | i18next}}
<button class="pull-right btn btn-sm btn-success" style="margin-bottom:10px;" ng-disabled="isProcessing" ng-click="addPlexServer()">
Sign In/Add Servers
{{'settings_server.sign_server' | i18next}}
</button>
</h6>
<div ng-if="isProcessing">
<br>
<h6>
<span class="pull-right text-info">{{ isProcessing ? 'You have 2 minutes to sign into your Plex Account.' : ''}}</span>
<span class="pull-right text-info">{{ isProcessing ? 'settings_server.minutes_to_sign_plex' : '' | i18next}}</span>
</h6>
<br>
</div>
<table class="table">
<tr>
<th>Name</th>
<th>uri</th>
<th>UI Route</th>
<th>Backend Route</th>
<th>{{'settings_server.name' | i18next}}</th>
<th>{{'settings_server.uri' | i18next}}</th>
<th>{{'settings_server.ui_route' | i18next}}</th>
<th>{{'settings_server.backend_route' | i18next}}</th>
<th></th>
</tr>
<tr ng-if="servers.length === 0">
<td colspan="7">
<p class="text-center text-danger">Add a Plex Server</p>
<p class="text-center text-danger">{{'settings_server.add_server' | i18next}}</p>
</td>
</tr>
<tr ng-if="serversPending">
@ -33,13 +33,13 @@
<td><span class='text-secondary text-small'>{{ x.uri }}</span></td>
<td>
<div class='loader' ng-if="x.uiStatus == 0"></div>
<div class='text-success' ng-if="x.uiStatus == 1"><i class='fa fa-check'></i>ok</div>
<div class='text-danger' ng-if="x.uiStatus == -1"><i class='fa fa-exclamation-triangle'></i>error</div>
<div class='text-success' ng-if="x.uiStatus == 1"><i class='fa fa-check'></i>{{'settings_server.ok' | i18next}}</div>
<div class='text-danger' ng-if="x.uiStatus == -1"><i class='fa fa-exclamation-triangle'></i>{{'settings_server.error' | i18next}}</div>
</td>
<td>
<div class='loader' ng-if="x.backendStatus == 0"></div>
<div class='text-success' ng-if="x.backendStatus == 1"><i class='fa fa-check'></i>ok</div>
<div class='text-danger' ng-if="x.backendStatus == -1"><i class='fa fa-exclamation-triangle'></i>error</div>
<div class='text-success' ng-if="x.backendStatus == 1"><i class='fa fa-check'></i>{{'settings_server.ok' | i18next}}</div>
<div class='text-danger' ng-if="x.backendStatus == -1"><i class='fa fa-exclamation-triangle'></i>{{'settings_server.error' | i18next}}</div>
</td>
<td>
<button class="btn btn-sm btn-outline-secondary" ng-click="editPlexServer(x)">
@ -53,25 +53,25 @@
</tr>
<tr ng-if="isAnyUIBad()">
<td colspan="5">
<p class="text-center text-danger small">If a Plex server configuration has problems with the UI route, the channel editor won&apos;t be able to access its content.</p>
<p class="text-center text-danger small">{{'settings_server.ui_bad' | i18next}}</p>
</td>
</tr>
<tr ng-if="isAnyBackendBad()">
<td colspan="5">
<p class="text-center text-danger small">If a Plex server configuration has problems with the backend route, dizqueTV won&apos;t be able to play its content.</p>
<p class="text-center text-danger small">{{'settings_server.server_bad' | i18next}}</p>
</td>
</tr>
</table>
<hr>
<h6>Plex Transcoder Settings
<h6>{{'settings_server.plex_transcoder_settings' | i18next}}
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
{{'settings_server.update' | i18next}}
</button>
<button class="pull-right btn btn-sm btn-warning" ng-click="resetSettings(settings)">
Reset Options
{{'settings_server.reset_options' | i18next}}
</button>
</h6>
<hr>
@ -79,10 +79,10 @@
<div class="col-sm-3">
<div class="form-group">
<input id="debugLogging" type="checkbox" ng-model="settings.debugLogging"></input>
<label for="debugLogging">Debug logging</label>
<label for="debugLogging">{{'settings_server.debug_logging' | i18next}}</label>
</div>
<div class="form-group">
<label>Paths</label>
<label>{{'settings_server.paths' | i18next}}</label>
<select ng-model="settings.streamPath"
ng-options="o.id as o.description for o in pathOptions" ></select>
</div>
@ -90,110 +90,110 @@
<div class="col-sm-3">
<div class="form-group">
<input id="updatePlayStatus" type="checkbox" ng-model="settings.updatePlayStatus" ria-describedby="updatePlayStatusHelp"></input>
<label for="updatePlayStatus">Send play status to Plex</label>
<small id="updatePlayStatusHelp" class="form-text text-muted">Note: This affects the "on deck" for your plex account.</small>
<label for="updatePlayStatus">{{'settings_server.send_status_plex' | i18next}}</label>
<small id="updatePlayStatusHelp" class="form-text text-muted">{{'settings_server.send_status_plex_note' | i18next}}</small>
</div>
</div>
</div>
<div class="row" ng-hide="hideIfNotPlexPath()">
<div class="col-sm-12">
<p class="text-center text-info small">If stream changes video codec, audio codec, or audio channels upon episode change, you will experience playback issues unless ffmpeg transcoding and normalization are also enabled.</p>
<p class="text-center text-info small">{{'settings_server.no_plex_path' | i18next}}</p>
</div>
</div>
<div class="row" ng-hide="hideIfNotPlexPath()">
<div class="col-sm-6">
<h6 style="font-weight: bold">Video Options</h6>
<h6 style="font-weight: bold">{{'settings_server.video_options' | i18next}}</h6>
<div class="form-group">
<label>Supported Video Formats</label>
<label>{{'settings_server.supported_video_formats' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoCodecs" ria-describedby="videoCodecsHelp"></input>
</div>
<div class="form-group">
<label>Max Playable Resolution</label>
<label>{{'settings_server.max_playable_resolution' | i18next}}</label>
<select ng-model="settings.maxPlayableResolution"
ng-options="o.id as o.description for o in resolutionOptions" ></select>
</div>
<div class="form-group">
<label>Max Transcode Resolution</label>
<label>{{'settings_server.max_transcode_resolution' | i18next}}</label>
<select ng-model="settings.maxTranscodeResolution"
ng-options="o.id as o.description for o in resolutionOptions "></select>
</div>
</div>
<div class="col-sm-6">
<h6 style="font-weight: bold">Audio Options</h6>
<h6 style="font-weight: bold">{{'settings_server.audio_options' | i18next}}</h6>
<div class="form-group">
<label>Supported Audio Formats</label>
<label>{{'settings_server.supported_audio_formats' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioCodecs" ria-describedby="audioCodecsHelp" ></input>
<small id="audioCodecsHelp" class="form-text text-muted">Comma separated list. Some possible values are 'ac3,aac,mp3'.</small>
<small id="audioCodecsHelp" class="form-text text-muted">{{'settings_server.supported_audio_formats_note' | i18next}}</small>
</div>
<div class="form-group">
<label>Maximum Audio Channels</label>
<label>{{'settings_server.max_audio_channels' | i18next}}</label>
<select ng-model="settings.maxAudioChannels"
ng-options="o.id as o.description for o in maxAudioChannelsOptions" ria-describedby="maxAudioChannelsHelp"></select>
<small id="maxAudioChannelsHelp" class="form-text text-muted">Note: 7.1 audio and on some clients, 6.1, is known to cause playback issues.</small>
<small id="maxAudioChannelsHelp" class="form-text text-muted">{{'settings_server.max_audio_channels_note' | i18next}}</small>
</div>
<div class="form-group">
<label>Audio Boost</label>
<label>{{'settings_server.audio_boost' | i18next}}</label>
<select ng-model="settings.audioBoost"
ng-options="o.id as o.description for o in audioBoostOptions" ria-describedby="audioBoostHelp"></select>
<small id="audioBoostHelp" class="form-text text-muted">Note: Only applies when downmixing to stereo.</small>
<small id="audioBoostHelp" class="form-text text-muted">{{'settings_server.audio_boost_note' | i18next}}</small>
</div>
</div>
</div>
<div class="row" ng-hide="hideIfNotPlexPath()">
<div class="col-sm-6">
<h6 style="font-weight: bold">Miscellaneous Options</h6>
<h6 style="font-weight: bold">{{'settings_server.miscellaneous_options' | i18next}}</h6>
<div class="form-group">
<label>Max Direct Stream Bitrate (Kbps)</label>
<label>{{'settings_server.max_direct_stream_bitrate' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.directStreamBitrate" ></input>
</div>
<div class="form-group">
<label>Max Transcode Bitrate (Kbps)</label>
<label>{{'settings_server.max_transcode_bitrate' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.transcodeBitrate" aria-described-by="transcodebrhelp" ></input>
<small id="transcodebrhelp" class='text-muted form-text'>Plex will decide to transcode or direct play based on these settings and if Plex transcodes, it will try to match the transcode bitrate.</small>
<small id="transcodebrhelp" class='text-muted form-text'>{{'settings_server.max_transcode_bitrate_note' | i18next}}</small>
</div>
<div class="form-group">
<label>Direct Stream Media Buffer Size</label>
<label>{{'settings_server.direct_stream_media_buffer' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.mediaBufferSize" ></input>
</div>
<div class="form-group">
<label>Transcode Media Buffer Size</label>
<label>{{'settings_server.transcode_media_buffer' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.transcodeMediaBufferSize" ></input>
</div>
<div class="form-group">
<label>Stream Protocol</label>
<label>{{'settings_server.stream_protocol' | i18next}}</label>
<select ng-model="settings.streamProtocol"
ng-options="o.id as o.description for o in streamProtocols" ></select>
</div>
<div class="form-group">
<input id="forceDirectPlay" type="checkbox" ng-model="settings.forceDirectPlay" ></input>
<label for="forceDirectPlay">Force Direct Play</label>
<label for="forceDirectPlay">{{'settings_server.force_direct_play' | i18next}}</label>
</div>
</div>
<div class="col-sm-6">
<h6 style="font-weight: bold">Subtitle Options</h6>
<h6 style="font-weight: bold">{{'settings_server.subtitle_options' | i18next}}</h6>
<div class="form-group">
<label>Subtitle Size</label>
<label>{{'settings_server.subtitle_size' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.subtitleSize" ></input>
</div>
<div class="form-group">
<input class="form-check-input" id="enableSubtitles" type="checkbox" ng-model="settings.enableSubtitles" ng-disabled="shouldDisableSubtitles()" ></input>
<label class="form-check-label" for="enableSubtitles">Enable Subtitles (Requires Transcoding)</label>
<label class="form-check-label" for="enableSubtitles">{{'settings_server.enable_subtitle' | i18next}}</label>
</div>
</div>
</div>
<div class="row" ng-hide="hideIfNotDirectPath()">
<div class="col-sm-6">
<h6 style="font-weight: bold">Path Replacements</h6>
<h6 style="font-weight: bold">{{'settings_server.path_replacements' | i18next}}</h6>
<div class="form-group">
<label>Original Plex path to replace:</label>
<label>{{'settings_server.original_plex_path' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.pathReplace" ></input>
</div>
<div class="form-group">
<label>Replace Plex path with:</label>
<label>{{'settings_server.replace_plex_path' | i18next}}</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.pathReplaceWith" ></input>
</div>
</div>

View File

@ -177,7 +177,8 @@
<div class="modal-footer" ng-show='!loading'>
<div class='text-danger small'>{{error}}</div>
<button type="button" class="btn btn-sm btn-link" ng-click="finished(true)">Cancel</button>
<button ng-disabled='disableCreateLineup()' type="button" class="btn btn-sm btn-primary" ng-click="finished(false);">Create Lineup</button>
<button ng-show='! hideCreateLineup()' ng-disabled='disableCreateLineup()' type="button" class="btn btn-sm btn-primary" ng-click="finished(false);">Create Lineup</button>
<button ng-show='showResetSlots()' type="button" class="btn btn-sm btn-danger" ng-click="finished(false);">Reset Slots</button>
</div>
</div>
</div>

View File

@ -148,7 +148,8 @@
<div class="modal-footer" ng-show='!loading'>
<div class='text-danger small'>{{error}}</div>
<button type="button" class="btn btn-sm btn-link" ng-click="finished(true)">Cancel</button>
<button ng-disabled='disableCreateLineup()' type="button" class="btn btn-sm btn-primary" ng-click="finished(false);">Create Lineup</button>
<button ng-show='! hideCreateLineup()' ng-disabled='disableCreateLineup()' type="button" class="btn btn-sm btn-primary" ng-click="finished(false);">Create Lineup</button>
<button ng-show='showResetSlots()' type="button" class="btn btn-sm btn-danger" ng-click="finished(false);">Reset Slots</button>
</div>
</div>
</div>

View File

@ -1,35 +1,35 @@
<div>
<h5>XMLTV Settings
<h5>{{'settings_xmltv.title' | i18next}}
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
{{'settings_xmltv.update' | i18next}}
</button>
<button class="pull-right btn btn-sm btn-warning" ng-click="resetSettings(settings)">
Reset Options
{{'settings_xmltv.reset_options' | i18next}}
</button>
</h5>
<h6>Output Path</h6>
<h6>{{'settings_xmltv.output_path' | i18next}}</h6>
<input type="text" class="form-control form-control-sm" ng-model="settings.file" aria-describedby="pathhelp" readonly ></input>
<small id="pathhelp" class="form-text text-muted">You can edit this location in file xmltv-settings.json.</small>
<small id="pathhelp" class="form-text text-muted">{{'settings_xmltv.output_path_note' | i18next}}</small>
<br></br>
<div class="row">
<div class="col-sm-6">
<label>EPG Hours</label>
<label>{{'settings_xmltv.epg_hours' | i18next}}</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.cache" aria-describedby="cachehelp"></input>
<small id="cachehelp" class="form-text text-muted">How many hours of programming to include in the xmltv file.</small>
<small id="cachehelp" class="form-text text-muted">{{'settings_xmltv.epg_hours_note' | i18next}}</small>
</div>
<div class="col-sm-6">
<label>Refresh Timer (hours)</label>
<label>{{'settings_xmltv.refresh_timer' | i18next}}</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.refresh" aria-describedby="timerhelp"></input>
<small id="timerhelp" class="form-text text-muted">How often should the xmltv file be updated.</small>
<small id="timerhelp" class="form-text text-muted">{{'settings_xmltv.refresh_timer_note' | i18next}}</small>
</div>
</div>
<br ></br>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="imageCache" aria-describedby="imageCacheHelp" ng-model='settings.enableImageCache'>
<label class="form-check-label" for="stealth">Image Cache</label>
<label class="form-check-label" for="stealth">{{'settings_xmltv.image_cache' | i18next}}</label>
<div class='text-muted' id="imageCacheHelp">If enabled the pictures used for Movie and TV Show posters will be cached in dizqueTV's .dizqueTV folder and will be delivered by dizqueTV's server instead of requiring calls to Plex. Note that using fixed xmltv location in Plex (as opposed to url) will not work correctly in this case.</div>
<div class='text-muted' id="imageCacheHelp">{{'settings_xmltv.image_cache_note' | i18next}}</div>
</div>

View File

@ -1,7 +1,7 @@
<div class='container-fluid'>
<h5>
{{title}}
{{'guide.title' | i18next}}
</h5>
<div style='padding:0; position:relative'>
<table class="table tvguide" style="{'column-width': colspanPercent + '%' }">
@ -29,7 +29,7 @@
<tr ng-mouseover="channels[channelNumber].mouse=true" ng-mouseleave="channels[channelNumber].mouse=false" ng-repeat="channelNumber in channelNumbers track by $index" ng-Class="{'even' : ($index % 2==0), 'odd' : ($index % 2==1) }" >
<td title='{{channels[channelNumber].altTitle}}' class='even channel-number' colspan="{{channelNumberColspan}}" >
<div>
<a role="button" href='/media-player/{{channelNumber}}.m3u' title="Attempt to play channel: '{{channels[channelNumber].altTitle}}' in local media player" class='btn btn-sm btn-outline-primary play-channel' ng-show='channels[channelNumber].mouse'>
<a role="button" href='/media-player/{{channelNumber}}.m3u' title="{{'guide.attempt_to_play_channel' | i18next: {title: channels[channelNumber].altTitle} }}" class='btn btn-sm btn-outline-primary play-channel' ng-show='channels[channelNumber].mouse'>
<span class='fa fa-play'></span>
</a>
<span ng-hide='channels[channelNumber].mouse' >

View File

@ -30,6 +30,15 @@ module.exports = function (getShowData) {
})
newProgs = newProgs.concat(shows[keys[i]])
}
movies.sort( (a,b) => {
if (a.title === b.title) {
return 0;
} else if (a.title < b.title) {
return -1;
} else {
return 1;
}
} );
return newProgs.concat(movies);
}
@ -59,7 +68,9 @@ module.exports = function (getShowData) {
let data = getShowData(progs[i]);
if (data.hasShow) {
let key = data.showId + "|" + data.order;
tmpProgs[key] = progs[i];
if (typeof(tmpProgs[key]) === 'undefined') {
tmpProgs[key] = progs[i];
}
}
}
}