diff --git a/.dockerignore b/.dockerignore index d926ae0..811bb92 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ node_modules npm-debug.log -Dockerfile +*Dockerfile* .dockerignore .git .gitignore diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..fe4c17a --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no-install commitlint --edit "" diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 0000000..5c18e4b --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +exec < /dev/tty && node_modules/.bin/cz --hook || true diff --git a/Dockerfile-builder b/Dockerfile-builder index 1e4b626..1065c3b 100644 --- a/Dockerfile-builder +++ b/Dockerfile-builder @@ -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 diff --git a/Dockerfile-nvidia b/Dockerfile-nvidia index 2b22dc9..740d98a 100644 --- a/Dockerfile-nvidia +++ b/Dockerfile-nvidia @@ -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 diff --git a/README.md b/README.md index 230cd77..6ec4761 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..28fe5c5 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = {extends: ['@commitlint/config-conventional']} diff --git a/index.js b/index.js index 7b8b5a3..b97c8c7 100644 --- a/index.js +++ b/index.js @@ -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(); +} ); diff --git a/locales/server/en.json b/locales/server/en.json new file mode 100644 index 0000000..1ec933d --- /dev/null +++ b/locales/server/en.json @@ -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}}" + } +} \ No newline at end of file diff --git a/make_dist.sh b/make_dist.sh index 4ea26bb..1e8afa7 100644 --- a/make_dist.sh +++ b/make_dist.sh @@ -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 diff --git a/package.json b/package.json index 708d509..2bb8697 100644 --- a/package.json +++ b/package.json @@ -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" + } } } diff --git a/src/api.js b/src/api.js index f6a1f28..dfe10ef 100644 --- a/src/api.js +++ b/src/api.js @@ -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' diff --git a/src/channel-cache.js b/src/channel-cache.js index bc2dc6b..302510a 100644 --- a/src/channel-cache.js +++ b/src/channel-cache.js @@ -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, diff --git a/src/constants.js b/src/constants.js index 834c89a..49d5cf8 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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" } diff --git a/src/dao/filler-db.js b/src/dao/filler-db.js index fcd2d3e..262913b 100644 --- a/src/dao/filler-db.js +++ b/src/dao/filler-db.js @@ -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) { diff --git a/src/dao/plex-server-db.js b/src/dao/plex-server-db.js index 7d83af4..cd35d4c 100644 --- a/src/dao/plex-server-db.js +++ b/src/dao/plex-server-db.js @@ -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; } diff --git a/src/services/active-channel-service.js b/src/services/active-channel-service.js new file mode 100644 index 0000000..c64bd80 --- /dev/null +++ b/src/services/active-channel-service.js @@ -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 diff --git a/src/services/channel-service.js b/src/services/channel-service.js new file mode 100644 index 0000000..cffcf70 --- /dev/null +++ b/src/services/channel-service.js @@ -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 \ No newline at end of file diff --git a/src/services/get-show-data.js b/src/services/get-show-data.js index 99d44b1..dec2a33 100644 --- a/src/services/get-show-data.js +++ b/src/services/get-show-data.js @@ -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 { diff --git a/src/services/m3u-service.js b/src/services/m3u-service.js index 95f150c..97cbac1 100644 --- a/src/services/m3u-service.js +++ b/src/services/m3u-service.js @@ -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) => { diff --git a/src/services/on-demand-service.js b/src/services/on-demand-service.js new file mode 100644 index 0000000..8f9dec7 --- /dev/null +++ b/src/services/on-demand-service.js @@ -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 = 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 diff --git a/src/services/programming-service.js b/src/services/programming-service.js new file mode 100644 index 0000000..8386cb7 --- /dev/null +++ b/src/services/programming-service.js @@ -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 diff --git a/src/services/random-slots-service.js b/src/services/random-slots-service.js index d7c149b..8be4b72 100644 --- a/src/services/random-slots-service.js +++ b/src/services/random-slots-service.js @@ -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(); } } diff --git a/src/services/show-orderers.js b/src/services/show-orderers.js new file mode 100644 index 0000000..06af2e8 --- /dev/null +++ b/src/services/show-orderers.js @@ -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, +} \ No newline at end of file diff --git a/src/services/time-slots-service.js b/src/services/time-slots-service.js index fe6b6a8..1fb8fe6 100644 --- a/src/services/time-slots-service.js +++ b/src/services/time-slots-service.js @@ -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(); } } diff --git a/src/services/tv-guide-service.js b/src/services/tv-guide-service.js index 6768b2f..ead286b 100644 --- a/src/services/tv-guide-service.js +++ b/src/services/tv-guide-service.js @@ -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(), diff --git a/src/throttler.js b/src/throttler.js index 543cbe9..ac42a11 100644 --- a/src/throttler.js +++ b/src/throttler.js @@ -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; } diff --git a/src/video.js b/src/video.js index 5f7e347..fb6fefa 100644 --- a/src/video.js +++ b/src/video.js @@ -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; } diff --git a/src/xmltv.js b/src/xmltv.js index 5ca97cf..4b02f5a 100644 --- a/src/xmltv.js +++ b/src/xmltv.js @@ -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() diff --git a/web/app.js b/web/app.js index ca21a73..eab458c 100644 --- a/web/app.js +++ b/web/app.js @@ -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')) diff --git a/web/controllers/guide.js b/web/controllers/guide.js index 7098b73..1922e8b 100644 --- a/web/controllers/guide.js +++ b/web/controllers/guide.js @@ -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(); diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index e0ce3ca..41a0f35 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -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]); diff --git a/web/directives/filler-config.js b/web/directives/filler-config.js index aea88bc..56cf2e3 100644 --- a/web/directives/filler-config.js +++ b/web/directives/filler-config.js @@ -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(); diff --git a/web/directives/random-slots-schedule-editor.js b/web/directives/random-slots-schedule-editor.js index 0f88017..24fbc98 100644 --- a/web/directives/random-slots-schedule-editor.js +++ b/web/directives/random-slots-schedule-editor.js @@ -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.')); } diff --git a/web/directives/time-slots-schedule-editor.js b/web/directives/time-slots-schedule-editor.js index 45b2522..90375f7 100644 --- a/web/directives/time-slots-schedule-editor.js +++ b/web/directives/time-slots-schedule-editor.js @@ -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.')); } diff --git a/web/public/index.html b/web/public/index.html index 201bd08..e9f8d7b 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -33,7 +33,12 @@ - Guide - Channels - Library - Player - Settings - Version + {{'topMenu.guide' | i18next}} - + {{'topMenu.channels' | i18next}} - + {{'topMenu.library' | i18next}} - + {{'topMenu.player' | i18next}} - + {{'topMenu.settings' | i18next}} - + {{'topMenu.version' | i18next}} XMLTV diff --git a/web/public/locales/en/main.json b/web/public/locales/en/main.json new file mode 100644 index 0000000..3bf946d --- /dev/null +++ b/web/public/locales/en/main.json @@ -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." + } +} \ No newline at end of file diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index baf082d..97dcddb 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -83,7 +83,7 @@
- Programming will restart from the beginning. + Programming will restart from the beginning. For on-demand channels, the times in the schedule are tentative.
@@ -197,23 +197,29 @@ There are no programs in the channel, use the button to add programs from your media library or use the Tools to add Flex time or a Channel Redirect -
-
+
-
- - -   -
+
-
+
-
+
-
+
@@ -255,7 +261,7 @@

Makes multiple copies of the schedule and plays them in sequence. Normally this isn'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.

-
+
@@ -267,7 +273,7 @@

Like "Replicate", 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.

-
+
-
+
-
+
-
+
-
+
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.

-
+
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.

-
+
-
+
@@ -425,7 +431,7 @@

Will redirect to another channel while between the selected hours.

-
+
-
-
+
+
+
+ +
+
-

This allows to schedul specific shows to run at specific time slots of the day or a week. It'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.

+

This allows to schedule specific shows to run at specific time slots of the day or a week. It'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.

-
-
+
+ + +
+
+ +
+
-

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.

+

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.

@@ -493,7 +523,7 @@

Removes any Flex periods from the schedule.

-
+

Wipes out the schedule so that you can start over.

+ + +
+
+ + +
+ + +
+

Use this button to show or hide a bunch of additional tools that might be useful.

+ + +
@@ -838,6 +886,38 @@
+ + + + +