diff --git a/Dockerfile b/Dockerfile index 651f823..23435cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/ COPY . . RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly -FROM jrottenberg/ffmpeg:4.2-ubuntu1804 +FROM jrottenberg/ffmpeg:4.3-ubuntu1804 EXPOSE 8000 WORKDIR /home/node/app ENTRYPOINT [ "./dizquetv" ] diff --git a/Dockerfile-nvidia b/Dockerfile-nvidia index 608d7f5..f21f8b1 100644 --- a/Dockerfile-nvidia +++ b/Dockerfile-nvidia @@ -6,7 +6,7 @@ COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/ COPY . . RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly -FROM jrottenberg/ffmpeg:4.2-nvidia +FROM jrottenberg/ffmpeg:4.3-nvidia EXPOSE 8000 WORKDIR /home/node/app ENTRYPOINT [ "./dizquetv" ] diff --git a/README.md b/README.md index c089f3c..3fd9b6a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# dizqueTV 1.2.5 +# dizqueTV 1.4.2 ![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. **dizqueTV** ( *dis·keˈtiːˈvi* ) is a fork of the project previously-known as [pseudotv-plex](https://gitlab.com/DEFENDORe/pseudotv-plex) or [pseudotv](https://github.com/DEFENDORe/pseudotv). New repository because of lack of activity from the main repository and the name change is because projects with the old name already existed and were created long before this approach and it was causing confusion. You can migrate from pseudoTV 0.0.51 to dizqueTV by renaming the .pseudotv folder to .dizquetv and running the new executable (or doing a similar trick with the volumes used by the docker containers). - + Configure your channels, programs, commercials and settings using the dizqueTV web UI. @@ -43,13 +43,13 @@ EPG (Guide Information) data is stored to `.dizquetv/xmltv.xml` ## App Preview - +
- +
- +
- + ## Development Building/Packaging Binaries: (uses `browserify`, `babel` and `pkg`) diff --git a/index.js b/index.js index 13af0b0..484e691 100644 --- a/index.js +++ b/index.js @@ -4,19 +4,25 @@ const fs = require('fs') const path = require('path') const express = require('express') const bodyParser = require('body-parser') +const fileUpload = require('express-fileupload'); const api = require('./src/api') const dbMigration = require('./src/database-migration'); 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 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"); const FillerDB = require("./src/dao/filler-db"); +const CustomShowDB = require("./src/dao/custom-show-db"); const TVGuideService = require("./src/tv-guide-service"); +const EventService = require("./src/services/event-service"); const onShutdown = require("node-graceful-shutdown").onShutdown; console.log( @@ -37,11 +43,11 @@ for (let i = 0, l = process.argv.length; i < l; i++) { process.env.DATABASE = process.argv[i + 1] } -process.env.DATABASE = process.env.DATABASE || './.dizquetv' +process.env.DATABASE = process.env.DATABASE || path.join(".", ".dizquetv") process.env.PORT = process.env.PORT || 8000 if (!fs.existsSync(process.env.DATABASE)) { - if (fs.existsSync("./.pseudotv")) { + if (fs.existsSync( path.join(".", ".pseudotv") )) { throw Error(process.env.DATABASE + " folder not found but ./.pseudotv has been found. Please rename this folder or create an empty " + process.env.DATABASE + " folder so that the program is not confused about."); } fs.mkdirSync(process.env.DATABASE) @@ -50,23 +56,39 @@ if (!fs.existsSync(process.env.DATABASE)) { if(!fs.existsSync(path.join(process.env.DATABASE, 'images'))) { fs.mkdirSync(path.join(process.env.DATABASE, 'images')) } - if(!fs.existsSync(path.join(process.env.DATABASE, 'channels'))) { fs.mkdirSync(path.join(process.env.DATABASE, 'channels')) } if(!fs.existsSync(path.join(process.env.DATABASE, 'filler'))) { fs.mkdirSync(path.join(process.env.DATABASE, 'filler')) } +if(!fs.existsSync(path.join(process.env.DATABASE, 'custom-shows'))) { + fs.mkdirSync(path.join(process.env.DATABASE, 'custom-shows')) +} +if(!fs.existsSync(path.join(process.env.DATABASE, 'cache'))) { + fs.mkdirSync(path.join(process.env.DATABASE, 'cache')) +} +if(!fs.existsSync(path.join(process.env.DATABASE, 'cache','images'))) { + fs.mkdirSync(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 ); -db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings', 'db-version', 'client-id']) +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']) + +fileCache = new FileCacheService( path.join(process.env.DATABASE, 'cache') ); +cacheImageService = new CacheImageService(db, fileCache); +m3uService = new M3uService(channelDB, fileCache, channelCache) +eventService = new EventService(); initDB(db, channelDB) -const guideService = new TVGuideService(xmltv, db); + +const guideService = new TVGuideService(xmltv, db, cacheImageService); @@ -151,7 +173,13 @@ xmltvInterval.startInterval() let hdhr = HDHR(db, channelDB) let app = express() +eventService.setup(app); + +app.use(fileUpload({ + createParentPath: true +})); app.use(bodyParser.json({limit: '50mb'})) + app.get('/version.js', (req, res) => { res.writeHead(200, { 'Content-Type': 'application/javascript' @@ -170,13 +198,19 @@ app.get('/version.js', (req, res) => { res.end(); }); app.use('/images', express.static(path.join(process.env.DATABASE, 'images'))) -app.use(express.static(path.join(__dirname, 'web/public'))) +app.use(express.static(path.join(__dirname, 'web','public'))) app.use('/images', express.static(path.join(process.env.DATABASE, 'images'))) +app.use('/cache/images', cacheImageService.routerInterceptor()) +app.use('/cache/images', express.static(path.join(process.env.DATABASE, 'cache','images'))) app.use('/favicon.svg', express.static( - path.join(__dirname, 'resources/favicon.svg') + path.join(__dirname, 'resources','favicon.svg') ) ); +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/cache/images', cacheImageService.apiRouters()) -app.use(api.router(db, channelDB, fillerDB, xmltvInterval, guideService )) app.use(video.router( channelDB, fillerDB, db)) app.use(hdhr.router) app.listen(process.env.PORT, () => { @@ -208,15 +242,64 @@ function initDB(db, channelDB) { let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-offline-screen.png'))) fs.writeFileSync(process.env.DATABASE + '/images/generic-offline-screen.png', data) } + if (!fs.existsSync(process.env.DATABASE + '/images/generic-music-screen.png')) { + let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-music-screen.png'))) + fs.writeFileSync(process.env.DATABASE + '/images/generic-music-screen.png', data) + } if (!fs.existsSync(process.env.DATABASE + '/images/loading-screen.png')) { let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/loading-screen.png'))) fs.writeFileSync(process.env.DATABASE + '/images/loading-screen.png', data) } + if (!fs.existsSync( path.join(process.env.DATABASE, 'custom.css') )) { + let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources', 'default-custom.css'))) + fs.writeFileSync( path.join(process.env.DATABASE, 'custom.css'), data) + } } + +function _wait(t) { + return new Promise((resolve) => { + setTimeout(resolve, t); + }); +} + + +async function sendEventAfterTime() { + let t = (new Date()).getTime(); + await _wait(20000); + eventService.push( + "lifecycle", + { + "message": `Server Started`, + "detail" : { + "time": t, + }, + "level" : "success" + } + ); + +} +sendEventAfterTime(); + + + + onShutdown("log" , [], async() => { + let t = (new Date()).getTime(); + eventService.push( + "lifecycle", + { + "message": `Initiated Server Shutdown`, + "detail" : { + "time": t, + }, + "level" : "warning" + } + ); + console.log("Received exit signal, attempting graceful shutdonw..."); + await _wait(2000); }); onShutdown("xmltv-writer" , [], async() => { await xmltv.shutdown(); diff --git a/package-lock.json b/package-lock.json index 1c71da7..895b66e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1005,6 +1005,28 @@ "to-fast-properties": "^2.0.0" } }, + "@calebboyd/async": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@calebboyd/async/-/async-1.4.0.tgz", + "integrity": "sha512-Q5tSWP28OF1nGd9KD6qspJselIfrHqk5+1gLGNNuQiUy80LE4g4ZAb+r+P8MYe1/FS5ICbm+OXiIalC7xhde3A==", + "dev": true + }, + "@calebboyd/semaphore": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@calebboyd/semaphore/-/semaphore-1.3.1.tgz", + "integrity": "sha512-17z9me12RgAEcMhIgR7f+BiXKbzwF9p1VraI69OxrUUSWGuSMOyOTEHQNVtMKuVrkEDVD0/Av5uiGZPBMYZljw==", + "dev": true + }, + "@mrmlnc/readdir-enhanced": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", + "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==", + "dev": true, + "requires": { + "call-me-maybe": "^1.0.1", + "glob-to-regexp": "^0.3.0" + } + }, "@nodelib/fs.scandir": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", @@ -1160,6 +1182,11 @@ "resolved": "https://registry.npmjs.org/angular-router-browserify/-/angular-router-browserify-0.0.2.tgz", "integrity": "sha1-euL98uLowGxYz8aXrz56XohkJBg=" }, + "angular-vs-repeat": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/angular-vs-repeat/-/angular-vs-repeat-2.0.13.tgz", + "integrity": "sha512-Jb0DOt4jU5/xZx7wjKvgZJtgAaMA4ZFLq5aeQHU8U2IZz8ixWP0ML6gFWQ1yefMapvg5LV+ZfA81ZeeN/NtJ7A==" + }, "ansi-align": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", @@ -1223,6 +1250,29 @@ } } }, + "app-builder": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/app-builder/-/app-builder-5.2.0.tgz", + "integrity": "sha512-RRj/vu8WhmMM71G9BxMLRvcwpr1QUJZ9NXURGGo1v3fPiauzkQfNi31kM7irRNqR87NV+lJ/qI62iTzcAc+V0Q==", + "dev": true + }, + "archive-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/archive-type/-/archive-type-4.0.0.tgz", + "integrity": "sha1-+S5yIzBW38aWlHJ0nCZ72wRrHXA=", + "dev": true, + "requires": { + "file-type": "^4.2.0" + }, + "dependencies": { + "file-type": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz", + "integrity": "sha1-G2AOX8ofvcboDApwxxyNul95BsU=", + "dev": true + } + } + }, "arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", @@ -1258,6 +1308,12 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, "array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", @@ -1470,6 +1526,16 @@ "file-uri-to-path": "1.0.0" } }, + "bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -1799,6 +1865,34 @@ "ieee754": "^1.1.4" } }, + "buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "requires": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, + "buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", + "dev": true + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -1817,11 +1911,13 @@ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", "dev": true }, - "byline": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", - "integrity": "sha1-dBxSFkaOrcRXsDQQEYrXfejB3bE=", - "dev": true + "busboy": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz", + "integrity": "sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw==", + "requires": { + "dicer": "0.3.0" + } }, "bytes": { "version": "3.1.0", @@ -1883,6 +1979,12 @@ "integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==", "dev": true }, + "call-me-maybe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", + "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=", + "dev": true + }, "camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -1919,6 +2021,18 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, + "caw": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz", + "integrity": "sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==", + "dev": true, + "requires": { + "get-proxy": "^2.0.0", + "isurl": "^1.0.0-alpha5", + "tunnel-agent": "^0.6.0", + "url-to-options": "^1.0.1" + } + }, "chalk": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.4.0.tgz", @@ -1929,6 +2043,12 @@ "strip-ansi": "~0.1.0" } }, + "cherow": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/cherow/-/cherow-1.6.9.tgz", + "integrity": "sha512-pmmkpIQRcnDA7EawKcg9+ncSZNTYfXqDx+K3oqqYvpZlqVBChjTomTfw+hePnkqYR3Y013818c0R1Q5P/7PGrQ==", + "dev": true + }, "chokidar": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", @@ -2000,6 +2120,27 @@ "integrity": "sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==", "dev": true }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-spinners": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.5.0.tgz", + "integrity": "sha512-PC+AmIuK04E6aeSs/pUccSujsTzBhu4HzC2dL+CfJB/Jcc2qTRbEwZQDfIUpt2Xl8BodYBEq8w4fc0kU2I9DjQ==", + "dev": true + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + }, "clone-response": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", @@ -2092,6 +2233,16 @@ "typedarray": "^0.0.6" } }, + "config-chain": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz", + "integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==", + "dev": true, + "requires": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "configstore": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", @@ -2505,6 +2656,47 @@ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "dev": true }, + "decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "requires": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, "decompress-response": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", @@ -2514,17 +2706,115 @@ "mimic-response": "^1.0.0" } }, + "decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "requires": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "dependencies": { + "file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=", + "dev": true + } + } + }, + "decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "dependencies": { + "file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true + } + } + }, + "decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "dependencies": { + "file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=", + "dev": true + } + } + }, + "decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha1-3qrM39FK6vhVePczroIQ+bSEj2k=", + "dev": true, + "requires": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "dependencies": { + "file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=", + "dev": true + }, + "get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", + "dev": true, + "requires": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true }, - "deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", - "dev": true + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dev": true, + "requires": { + "clone": "^1.0.2" + } }, "defer-to-connect": { "version": "1.1.3", @@ -2670,6 +2960,14 @@ "minimist": "^1.1.1" } }, + "dicer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", + "integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==", + "requires": { + "streamsearch": "0.1.2" + } + }, "diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", @@ -2723,6 +3021,144 @@ "is-obj": "^2.0.0" } }, + "download": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/download/-/download-7.1.0.tgz", + "integrity": "sha512-xqnBTVd/E+GxJVrX5/eUJiLYjCGPwMpdL+jGhGU57BvtcA7wwhtHVbXBeUk51kOpW3S7Jn3BQbN9Q1R1Km2qDQ==", + "dev": true, + "requires": { + "archive-type": "^4.0.0", + "caw": "^2.0.1", + "content-disposition": "^0.5.2", + "decompress": "^4.2.0", + "ext-name": "^5.0.0", + "file-type": "^8.1.0", + "filenamify": "^2.0.0", + "get-stream": "^3.0.0", + "got": "^8.3.1", + "make-dir": "^1.2.0", + "p-event": "^2.1.0", + "pify": "^3.0.0" + }, + "dependencies": { + "@sindresorhus/is": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz", + "integrity": "sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==", + "dev": true + }, + "cacheable-request": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-2.1.4.tgz", + "integrity": "sha1-DYCIAbY0KtM8kd+dC0TcCbkeXD0=", + "dev": true, + "requires": { + "clone-response": "1.0.2", + "get-stream": "3.0.0", + "http-cache-semantics": "3.8.1", + "keyv": "3.0.0", + "lowercase-keys": "1.0.0", + "normalize-url": "2.0.1", + "responselike": "1.0.2" + }, + "dependencies": { + "lowercase-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.0.tgz", + "integrity": "sha1-TjNms55/VFfjXxMkvfb4jQv8cwY=", + "dev": true + } + } + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "got": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/got/-/got-8.3.2.tgz", + "integrity": "sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw==", + "dev": true, + "requires": { + "@sindresorhus/is": "^0.7.0", + "cacheable-request": "^2.1.1", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^3.0.0", + "into-stream": "^3.1.0", + "is-retry-allowed": "^1.1.0", + "isurl": "^1.0.0-alpha5", + "lowercase-keys": "^1.0.0", + "mimic-response": "^1.0.0", + "p-cancelable": "^0.4.0", + "p-timeout": "^2.0.1", + "pify": "^3.0.0", + "safe-buffer": "^5.1.1", + "timed-out": "^4.0.1", + "url-parse-lax": "^3.0.0", + "url-to-options": "^1.0.1" + } + }, + "http-cache-semantics": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz", + "integrity": "sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w==", + "dev": true + }, + "keyv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.0.0.tgz", + "integrity": "sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA==", + "dev": true, + "requires": { + "json-buffer": "3.0.0" + } + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "normalize-url": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-2.0.1.tgz", + "integrity": "sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw==", + "dev": true, + "requires": { + "prepend-http": "^2.0.0", + "query-string": "^5.0.1", + "sort-keys": "^2.0.0" + } + }, + "p-cancelable": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", + "integrity": "sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ==", + "dev": true + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "sort-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", + "integrity": "sha1-ZYU1WEhh7JfXMNbPQYIuH1ZoQSg=", + "dev": true, + "requires": { + "is-plain-obj": "^1.0.0" + } + } + } + }, "duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -2793,6 +3229,26 @@ "once": "^1.4.0" } }, + "enhanced-resolve": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz", + "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.5.0", + "tapable": "^1.0.0" + } + }, + "errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "requires": { + "prr": "~1.0.1" + } + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -2819,40 +3275,6 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true }, - "escodegen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.1.tgz", - "integrity": "sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ==", - "dev": true, - "requires": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true - } - } - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2915,12 +3337,6 @@ } } }, - "expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true - }, "express": { "version": "4.17.1", "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", @@ -2958,6 +3374,33 @@ "vary": "~1.1.2" } }, + "express-fileupload": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.2.1.tgz", + "integrity": "sha512-fWPNAkBj+Azt9Itmcz/Reqdg3LeBfaXptDEev2JM8bCC0yDptglCnlizhf0YZauyU5X/g6v7v4Xxqhg8tmEfEA==", + "requires": { + "busboy": "^0.3.1" + } + }, + "ext-list": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", + "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", + "dev": true, + "requires": { + "mime-db": "^1.28.0" + } + }, + "ext-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", + "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", + "dev": true, + "requires": { + "ext-list": "^2.0.0", + "sort-keys-length": "^1.0.0" + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3132,12 +3575,6 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, "fast-safe-stringify": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", @@ -3153,6 +3590,21 @@ "reusify": "^1.0.4" } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, + "file-type": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-8.1.0.tgz", + "integrity": "sha512-qyQ0pzAy78gVoJsmYeNgl8uH8yKhr1lVhW7JbzJmnlRi0I4R2eEDEJZVKG8agpDnLpacwNbDhLNG/LMdxHD2YQ==", + "dev": true + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -3160,6 +3612,23 @@ "dev": true, "optional": true }, + "filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik=", + "dev": true + }, + "filenamify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz", + "integrity": "sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA==", + "dev": true, + "requires": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.0", + "trim-repeated": "^1.0.0" + } + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -3265,16 +3734,11 @@ "readable-stream": "^2.0.0" } }, - "fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true }, "fs-readdir-recursive": { "version": "1.1.0", @@ -3857,6 +4321,15 @@ "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==", "dev": true }, + "get-proxy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz", + "integrity": "sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw==", + "dev": true, + "requires": { + "npm-conf": "^1.1.0" + } + }, "get-stream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", @@ -3915,6 +4388,12 @@ } } }, + "glob-to-regexp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz", + "integrity": "sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=", + "dev": true + }, "global-dirs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz", @@ -4013,12 +4492,27 @@ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", "dev": true }, + "has-symbol-support-x": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz", + "integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==", + "dev": true + }, "has-symbols": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", "dev": true }, + "has-to-string-tag-x": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz", + "integrity": "sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==", + "dev": true, + "requires": { + "has-symbol-support-x": "^1.4.1" + } + }, "has-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", @@ -4227,13 +4721,13 @@ } }, "into-stream": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-5.1.1.tgz", - "integrity": "sha512-krrAJ7McQxGGmvaYbB7Q1mcA+cRwg9Ij2RfWIeVesNBgVDZmzY/Fa4IpZUT3bmdRzMzdf/mzltCG2Dq99IZGBA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", + "integrity": "sha1-lvsKk2wSur1v8XUqF9BWFqvQlMY=", "dev": true, "requires": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" + "from2": "^2.1.1", + "p-is-promise": "^1.1.0" } }, "invariant": { @@ -4381,6 +4875,12 @@ "is-path-inside": "^3.0.1" } }, + "is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha1-q5124dtM7VHjXeDHLr7PCfc0zeg=", + "dev": true + }, "is-npm": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", @@ -4413,6 +4913,12 @@ "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true }, + "is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true + }, "is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -4440,6 +4946,18 @@ "isobject": "^3.0.1" } }, + "is-retry-allowed": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", + "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -4474,6 +4992,16 @@ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, + "isurl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz", + "integrity": "sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==", + "dev": true, + "requires": { + "has-to-string-tag-x": "^1.2.0", + "is-object": "^1.0.1" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4536,15 +5064,6 @@ "minimist": "^1.2.5" } }, - "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6" - } - }, "jsonify": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", @@ -4617,16 +5136,6 @@ "leven": "^3.1.0" } }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, "locate-path": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", @@ -4656,6 +5165,37 @@ "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=", "dev": true }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4736,6 +5276,16 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, + "memory-fs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", + "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + }, "meow": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-5.0.0.tgz", @@ -4859,6 +5409,12 @@ "integrity": "sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw==", "dev": true }, + "meriyah": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-1.9.15.tgz", + "integrity": "sha512-D4rT6XIYGqZab0EI/xbihUpwh0WbNRVQ35l2J/5QC2atxaI8h3KvA55DEJLBB/FRdaji7JwkNehfCRjCyjCjqw==", + "dev": true + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -4913,6 +5469,12 @@ "mime-db": "1.43.0" } }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, "mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -5061,6 +5623,125 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "nexe": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nexe/-/nexe-3.3.7.tgz", + "integrity": "sha512-guAZKlY6UZcovzvuYjo7Hy7mx52Uq5f284aPeDVN2TjR2/jkhEHgnKMxBc9ECmknEvHoABgk6rYDG92q+J30sQ==", + "dev": true, + "requires": { + "@calebboyd/semaphore": "^1.3.1", + "app-builder": "^5.2.0", + "caw": "^2.0.1", + "chalk": "^2.4.2", + "cherow": "1.6.9", + "download": "^7.1.0", + "globby": "^9.2.0", + "got": "^9.6.0", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "multistream": "^2.1.1", + "ora": "^3.4.0", + "pify": "^4.0.1", + "resolve-dependencies": "^4.2.5", + "rimraf": "^2.6.3" + }, + "dependencies": { + "@nodelib/fs.stat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", + "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "dir-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", + "dev": true, + "requires": { + "path-type": "^3.0.0" + } + }, + "fast-glob": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.7.tgz", + "integrity": "sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==", + "dev": true, + "requires": { + "@mrmlnc/readdir-enhanced": "^2.2.1", + "@nodelib/fs.stat": "^1.1.2", + "glob-parent": "^3.1.0", + "is-glob": "^4.0.0", + "merge2": "^1.2.3", + "micromatch": "^3.1.10" + } + }, + "globby": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz", + "integrity": "sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "array-union": "^1.0.2", + "dir-glob": "^2.2.2", + "fast-glob": "^2.2.6", + "glob": "^7.1.3", + "ignore": "^4.0.3", + "pify": "^4.0.1", + "slash": "^2.0.0" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "node-graceful-shutdown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-graceful-shutdown/-/node-graceful-shutdown-1.1.0.tgz", + "integrity": "sha512-g1tq/R8ie/At5xRHGfF+chTge1jVPxf1NEClLpZIPxOPi6PJ9II81T35ms1u+s4N/mqOCp60CFd+ps+DIWRigQ==" + }, "node-releases": { "version": "1.1.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.53.tgz", @@ -5302,6 +5983,24 @@ "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", "dev": true }, + "npm-conf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz", + "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==", + "dev": true, + "requires": { + "config-chain": "^1.1.11", + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -5397,18 +6096,58 @@ "wrappy": "1" } }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", "dev": true, "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" + "mimic-fn": "^1.0.0" + } + }, + "ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } } }, "os-browserify": { @@ -5417,12 +6156,6 @@ "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", "dev": true }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true - }, "outpipe": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/outpipe/-/outpipe-1.1.1.tgz", @@ -5438,10 +6171,25 @@ "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", "dev": true }, + "p-event": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-2.3.1.tgz", + "integrity": "sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA==", + "dev": true, + "requires": { + "p-timeout": "^2.0.1" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, "p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "integrity": "sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4=", "dev": true }, "p-limit": { @@ -5471,6 +6219,15 @@ "aggregate-error": "^3.0.0" } }, + "p-timeout": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", + "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", + "dev": true, + "requires": { + "p-finally": "^1.0.0" + } + }, "p-try": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", @@ -5572,6 +6329,23 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, "pbkdf2": { "version": "3.0.17", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", @@ -5585,6 +6359,12 @@ "sha.js": "^2.4.8" } }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -5596,174 +6376,25 @@ "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", "dev": true }, - "pkg": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/pkg/-/pkg-4.4.7.tgz", - "integrity": "sha512-yDGEg2k09AOxV3KfJpKoEQkhckVN2woV/4Cm2iNnRUgJeSHcodxylertz49ePcJyknUyUFjTYDkogfK/188mag==", - "dev": true, - "requires": { - "@babel/parser": "^7.9.4", - "@babel/runtime": "^7.9.2", - "chalk": "^3.0.0", - "escodegen": "^1.14.1", - "fs-extra": "^8.1.0", - "globby": "^11.0.0", - "into-stream": "^5.1.1", - "minimist": "^1.2.5", - "multistream": "^2.1.1", - "pkg-fetch": "^2.6.6", - "progress": "^2.0.3", - "resolve": "^1.15.1", - "stream-meter": "^1.0.4" - }, - "dependencies": { - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "globby": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.0.tgz", - "integrity": "sha512-iuehFnR3xu5wBBtm4xi0dMe92Ob87ufyu/dHwpDYfbcpYpIbrO5OnS8M1vWvrBhSGEJ3/Ecj7gnX76P8YxpPEg==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", - "slash": "^3.0.0" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true }, - "pkg-fetch": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/pkg-fetch/-/pkg-fetch-2.6.6.tgz", - "integrity": "sha512-PdL6lpoSryzP6rMZD1voZQX0LHx6q4pOaD1djaFphmBfYPoQzLalF2+St+wdYxbZ37xRNHACTeQIKNEKA0xdbA==", + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", "dev": true, "requires": { - "@babel/runtime": "^7.9.2", - "byline": "^5.0.0", - "chalk": "^3.0.0", - "expand-template": "^2.0.3", - "fs-extra": "^8.1.0", - "minimist": "^1.2.5", - "progress": "^2.0.3", - "request": "^2.88.0", - "request-progress": "^3.0.0", - "semver": "^6.3.0", - "unique-temp-dir": "^1.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } + "pinkie": "^2.0.0" } }, "pkg-up": { @@ -5792,12 +6423,6 @@ "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", "dev": true }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", - "dev": true - }, "prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", @@ -5822,10 +6447,10 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, - "progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", "dev": true }, "proxy-addr": { @@ -5837,6 +6462,12 @@ "ipaddr.js": "1.9.1" } }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -5891,6 +6522,17 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, + "query-string": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", + "dev": true, + "requires": { + "decode-uri-component": "^0.2.0", + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -5909,6 +6551,11 @@ "integrity": "sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g=", "dev": true }, + "random-js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/random-js/-/random-js-2.1.0.tgz", + "integrity": "sha512-CRUyWmnzmZBA7RZSVGq0xMqmgCyPPxbiKNLFA5ud7KenojVX2s7Gv+V7eB52beKTPGxWRnVZ7D/tCIgYJJ8vNQ==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -6162,15 +6809,6 @@ } } }, - "request-progress": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", - "integrity": "sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=", - "dev": true, - "requires": { - "throttleit": "^1.0.0" - } - }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6186,6 +6824,40 @@ "path-parse": "^1.0.6" } }, + "resolve-dependencies": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/resolve-dependencies/-/resolve-dependencies-4.2.5.tgz", + "integrity": "sha512-swlXn30tAgdJZZwNuZfHmzGxQouHEhFWzkl8sJ8b65NjkjcIwHDEt5tuCze1zTtgMdg3jGQaa/RfSWV1b/9kqA==", + "dev": true, + "requires": { + "@calebboyd/async": "^1.4.0", + "enhanced-resolve": "^4.2.0", + "globby": "^11.0.1", + "meriyah": "^1.9.15" + }, + "dependencies": { + "globby": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.2.tgz", + "integrity": "sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.1.1", + "ignore": "^5.1.4", + "merge2": "^1.3.0", + "slash": "^3.0.0" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + } + } + }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -6201,6 +6873,16 @@ "lowercase-keys": "^1.0.0" } }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, "ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", @@ -6257,6 +6939,23 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "requires": { + "commander": "^2.8.1" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + } + } + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -6512,6 +7211,24 @@ } } }, + "sort-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", + "integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=", + "dev": true, + "requires": { + "is-plain-obj": "^1.0.0" + } + }, + "sort-keys-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", + "integrity": "sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=", + "dev": true, + "requires": { + "sort-keys": "^1.0.0" + } + }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -6665,15 +7382,6 @@ } } }, - "stream-meter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", - "integrity": "sha1-Uq+Vql6nYKJJFxZwTb/5D3Ov3R0=", - "dev": true, - "requires": { - "readable-stream": "^2.1.4" - } - }, "stream-splicer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.1.tgz", @@ -6684,6 +7392,17 @@ "readable-stream": "^2.0.2" } }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, + "strict-uri-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=", + "dev": true + }, "string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", @@ -6738,6 +7457,15 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz", "integrity": "sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=" }, + "strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "requires": { + "is-natural-number": "^4.0.1" + } + }, "strip-indent": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", @@ -6750,6 +7478,15 @@ "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true }, + "strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, "subarg": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", @@ -6777,18 +7514,33 @@ "acorn-node": "^1.2.0" } }, + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true + }, + "tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "requires": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + } + }, "term-size": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz", "integrity": "sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==", "dev": true }, - "throttleit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz", - "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=", - "dev": true - }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -6805,6 +7557,12 @@ "xtend": "~4.0.1" } }, + "timed-out": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", + "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=", + "dev": true + }, "timers-browserify": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", @@ -6814,6 +7572,12 @@ "process": "~0.11.0" } }, + "to-buffer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", + "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", + "dev": true + }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -6897,6 +7661,15 @@ "integrity": "sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA=", "dev": true }, + "trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, "tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", @@ -6916,15 +7689,6 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2" - } - }, "type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -6955,18 +7719,22 @@ "is-typedarray": "^1.0.0" } }, - "uid2": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", - "integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I=", - "dev": true - }, "umd": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==", "dev": true }, + "unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "requires": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "undeclared-identifiers": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.3.tgz", @@ -7038,23 +7806,6 @@ "crypto-random-string": "^2.0.0" } }, - "unique-temp-dir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unique-temp-dir/-/unique-temp-dir-1.0.0.tgz", - "integrity": "sha1-bc6VsmgcoAPuv7MEpBX5y6vMU4U=", - "dev": true, - "requires": { - "mkdirp": "^0.5.1", - "os-tmpdir": "^1.0.1", - "uid2": "0.0.3" - } - }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true - }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7220,6 +7971,12 @@ "prepend-http": "^2.0.0" } }, + "url-to-options": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", + "integrity": "sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=", + "dev": true + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -7297,6 +8054,15 @@ "xtend": "^4.0.0" } }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dev": true, + "requires": { + "defaults": "^1.0.3" + } + }, "widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -7306,12 +8072,6 @@ "string-width": "^4.0.0" } }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true - }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -7346,6 +8106,16 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true + }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } } } } diff --git a/package.json b/package.json index a5498d0..5990b96 100644 --- a/package.json +++ b/package.json @@ -16,18 +16,20 @@ "author": "Dan Ferguson", "license": "ISC", "dependencies": { + "JSONStream": "1.0.5", "angular": "^1.7.9", "angular-router-browserify": "0.0.2", "angular-vs-repeat": "2.0.13", - "random-js" : "2.1.0", "axios": "^0.19.2", "body-parser": "^1.19.0", "diskdb": "^0.1.17", "express": "^4.17.1", + "express-fileupload": "^1.2.1", + "node-graceful-shutdown": "1.1.0", "node-ssdp": "^4.0.0", + "random-js": "2.1.0", "request": "^2.88.2", "uuid": "^8.0.0", - "node-graceful-shutdown" : "1.1.0", "xml-writer": "^1.7.0" }, "bin": "dist/index.js", @@ -41,7 +43,7 @@ "del-cli": "^3.0.0", "nodemon": "^2.0.3", "watchify": "^3.11.1", - "nexe" : "^3.3.7" + "nexe": "^3.3.7" }, "babel": { "plugins": [ diff --git a/resources/default-custom.css b/resources/default-custom.css new file mode 100644 index 0000000..2293389 --- /dev/null +++ b/resources/default-custom.css @@ -0,0 +1,14 @@ +/** For example : */ + + + +:root { + --guide-text : #F0F0f0; + --guide-header-even: #423cd4ff; + --guide-header-odd: #262198ff; + --guide-color-a: #212121; + --guide-color-b: #515151; + --guide-color-c: #313131; + --guide-color-d: #414141; +} + diff --git a/resources/generic-music-screen.png b/resources/generic-music-screen.png new file mode 100644 index 0000000..4efa71f Binary files /dev/null and b/resources/generic-music-screen.png differ diff --git a/src/api.js b/src/api.js index 9365120..a00501a 100644 --- a/src/api.js +++ b/src/api.js @@ -1,19 +1,34 @@ 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'); const PlexServerDB = require('./dao/plex-server-db'); const Plex = require("./plex.js"); -const FillerDB = require('./dao/filler-db'); + const timeSlotsService = require('./services/time-slots-service'); +const randomSlotsService = require('./services/random-slots-service'); + +function safeString(object) { + let o = object; + for(let i = 1; i < arguments.length; i++) { + o = o[arguments[i]]; + if (typeof(o) === 'undefined') { + return "missing"; + } + } + return String(o); +} module.exports = { router: api } -function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { - let router = express.Router() - let plexServerDB = new PlexServerDB(channelDB, channelCache, db); +function api(db, channelDB, fillerDB, customShowDB, xmltvInterval, guideService, _m3uService, eventService ) { + let m3uService = _m3uService; + const router = express.Router() + const plexServerDB = new PlexServerDB(channelDB, channelCache, fillerDB, customShowDB, db); router.get('/api/version', async (req, res) => { try { @@ -86,34 +101,119 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { } }) router.delete('/api/plex-servers', async (req, res) => { + let name = "unknown"; try { - let name = req.body.name; + name = req.body.name; if (typeof(name) === 'undefined') { return res.status(400).send("Missing name"); } let report = await plexServerDB.deleteServer(name); res.send(report) + eventService.push( + "settings-update", + { + "message": `Plex server ${name} removed.`, + "module" : "plex-server", + "detail" : { + "serverName" : name, + "action" : "delete" + }, + "level" : "warn" + } + ); + } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error deleting plex server.", + "module" : "plex-server", + "detail" : { + "action": "delete", + "serverName" : name, + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); } }) router.post('/api/plex-servers', async (req, res) => { try { - await plexServerDB.updateServer(req.body); + let report = await plexServerDB.updateServer(req.body); + let modifiedPrograms = 0; + let destroyedPrograms = 0; + report.forEach( (r) => { + modifiedPrograms += r.modifiedPrograms; + destroyedPrograms += r.destroyedPrograms; + } ); res.status(204).send("Plex server updated.");; + eventService.push( + "settings-update", + { + "message": `Plex server ${req.body.name} updated. ${modifiedPrograms} programs modified, ${destroyedPrograms} programs deleted`, + "module" : "plex-server", + "detail" : { + "serverName" : req.body.name, + "action" : "update" + }, + "level" : "warning" + } + ); + } catch (err) { - console.error("Could not add plex server.", err); + console.error("Could not update plex server.", err); res.status(400).send("Could not add plex server."); + eventService.push( + "settings-update", + { + "message": "Error updating plex server.", + "module" : "plex-server", + "detail" : { + "action": "update", + "serverName" : safeString(req, "body", "name"), + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); } }) router.put('/api/plex-servers', async (req, res) => { try { await plexServerDB.addServer(req.body); res.status(201).send("Plex server added.");; + eventService.push( + "settings-update", + { + "message": `Plex server ${req.body.name} added.`, + "module" : "plex-server", + "detail" : { + "serverName" : req.body.name, + "action" : "add" + }, + "level" : "info" + } + ); + } catch (err) { console.error("Could not add plex server.", err); res.status(400).send("Could not add plex server."); + eventService.push( + "settings-update", + { + "message": "Error adding plex server.", + "module" : "plex-server", + "detail" : { + "action": "add", + "serverName" : safeString(req, "body", "name"), + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); } }) @@ -133,9 +233,10 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { try { let number = parseInt(req.params.number, 10); let channel = await channelCache.getChannelConfig(channelDB, number); + if (channel.length == 1) { - channel = channel[0]; - res.send( channel ); + channel = channel[0]; + res.send(channel); } else { return res.status(404).send("Channel not found"); } @@ -144,13 +245,68 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { res.status(500).send("error"); } }) + router.get('/api/channel/programless/: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 copy = {}; + Object.keys(channel).forEach( (key) => { + if (key != 'programs') { + copy[key] = channel[key]; + } + } ); + res.send(copy); + } else { + return res.status(404).send("Channel not found"); + } + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) + + router.get('/api/channel/programs/: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 programs = channel.programs; + if (typeof(programs) === 'undefined') { + return res.status(404).send("Channel doesn't have programs?"); + } + res.writeHead(200, { + 'Content-Type': 'application.json' + }); + + let transformStream = JSONStream.stringify(); //false makes it not add 'separators' + transformStream.pipe(res); + + for (let i = 0; i < programs.length; i++) { + transformStream.write( programs[i] ); + await throttle(); + } + transformStream.end(); + + } else { + return res.status(404).send("Channel not found"); + } + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) 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]; - res.send( { + res.send({ number: channel.number, icon: channel.icon, name: channel.name, @@ -176,6 +332,7 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { }) router.post('/api/channel', async (req, res) => { try { + await m3uService.clearCache(); cleanUpChannel(req.body); await channelDB.saveChannel( req.body.number, req.body ); channelCache.clear(); @@ -188,6 +345,7 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { }) router.put('/api/channel', async (req, res) => { try { + await m3uService.clearCache(); cleanUpChannel(req.body); await channelDB.saveChannel( req.body.number, req.body ); channelCache.clear(); @@ -200,6 +358,7 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { }) router.delete('/api/channel', async (req, res) => { try { + await m3uService.clearCache(); await channelDB.deleteChannel( req.body.number ); channelCache.clear(); res.send( { number: req.body.number} ) @@ -210,6 +369,33 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { } }) + router.post('/api/upload/image', async (req, res) => { + try { + if(!req.files) { + res.send({ + status: false, + message: 'No file uploaded' + }); + } else { + const logo = req.files.image; + logo.mv(path.join(process.env.DATABASE, '/images/uploads/', logo.name)); + + res.send({ + status: true, + message: 'File is uploaded', + data: { + name: logo.name, + mimetype: logo.mimetype, + size: logo.size, + fileUrl: `${req.protocol}://${req.get('host')}/images/uploads/${logo.name}` + } + }); + } + } catch (err) { + res.status(500).send(err); + } + }) + // Filler router.get('/api/fillers', async (req, res) => { try { @@ -290,6 +476,68 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { } ); + // Custom Shows + router.get('/api/shows', async (req, res) => { + try { + let fillers = await customShowDB.getAllShowsInfo(); + res.send(fillers); + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) + router.get('/api/show/:id', async (req, res) => { + try { + let id = req.params.id; + if (typeof(id) === 'undefined') { + return res.status(400).send("Missing id"); + } + let filler = await customShowDB.getShow(id); + if (filler == null) { + return res.status(404).send("Custom show not found"); + } + res.send(filler); + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) + router.post('/api/show/:id', async (req, res) => { + try { + let id = req.params.id; + if (typeof(id) === 'undefined') { + return res.status(400).send("Missing id"); + } + await customShowDB.saveShow(id, req.body ); + return res.status(204).send({}); + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) + router.put('/api/show', async (req, res) => { + try { + let uuid = await customShowDB.createShow(req.body ); + return res.status(201).send({id: uuid}); + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }) + router.delete('/api/show/:id', async (req, res) => { + try { + let id = req.params.id; + if (typeof(id) === 'undefined') { + return res.status(400).send("Missing id"); + } + await customShowDB.deleteShow(id); + return res.status(204).send({}); + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }); + // FFMPEG SETTINGS router.get('/api/ffmpeg-settings', (req, res) => { try { @@ -308,10 +556,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { if (typeof(err) !== 'undefined') { return res.status(400).send(err); } + eventService.push( + "settings-update", + { + "message": "FFMPEG configuration updated.", + "module" : "ffmpeg", + "detail" : { + "action" : "update" + }, + "level" : "info" + } + ); res.send(ffmpeg) } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error updating FFMPEG configuration.", + "module" : "ffmpeg", + "detail" : { + "action": "update", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) router.post('/api/ffmpeg-settings', (req, res) => { // RESET @@ -320,10 +592,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { ffmpeg.ffmpegPath = req.body.ffmpegPath; db['ffmpeg-settings'].update({ _id: req.body._id }, ffmpeg) ffmpeg = db['ffmpeg-settings'].find()[0] + eventService.push( + "settings-update", + { + "message": "FFMPEG configuration reset.", + "module" : "ffmpeg", + "detail" : { + "action" : "reset" + }, + "level" : "warning" + } + ); res.send(ffmpeg) } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error reseting FFMPEG configuration.", + "module" : "ffmpeg", + "detail" : { + "action": "reset", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -351,9 +647,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { db['plex-settings'].update({ _id: req.body._id }, req.body) let plex = db['plex-settings'].find()[0] res.send(plex) + eventService.push( + "settings-update", + { + "message": "Plex configuration updated.", + "module" : "plex", + "detail" : { + "action" : "update" + }, + "level" : "info" + } + ); + } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error updating Plex configuration", + "module" : "plex", + "detail" : { + "action": "update", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -382,9 +703,35 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { }) let plex = db['plex-settings'].find()[0] res.send(plex) + eventService.push( + "settings-update", + { + "message": "Plex configuration reset.", + "module" : "plex", + "detail" : { + "action" : "reset" + }, + "level" : "warning" + } + ); } catch(err) { console.error(err); res.status(500).send("error"); + + eventService.push( + "settings-update", + { + "message": "Error reseting Plex configuration", + "module" : "plex", + "detail" : { + "action": "reset", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + + } }) @@ -419,15 +766,41 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { _id: req.body._id, cache: req.body.cache, refresh: req.body.refresh, + enableImageCache: (req.body.enableImageCache === true), file: xmltv.file, } ); xmltv = db['xmltv-settings'].find()[0] res.send(xmltv) + eventService.push( + "settings-update", + { + "message": "xmltv settings updated.", + "module" : "xmltv", + "detail" : { + "action" : "update" + }, + "level" : "info" + } + ); updateXmltv() } catch(err) { console.error(err); res.status(500).send("error"); + + eventService.push( + "settings-update", + { + "message": "Error updating xmltv configuration", + "module" : "xmltv", + "detail" : { + "action": "update", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -441,10 +814,35 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { }) var xmltv = db['xmltv-settings'].find()[0] res.send(xmltv) + eventService.push( + "settings-update", + { + "message": "xmltv settings reset.", + "module" : "xmltv", + "detail" : { + "action" : "reset" + }, + "level" : "warning" + } + ); + updateXmltv() } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error reseting xmltv configuration", + "module" : "xmltv", + "detail" : { + "action": "reset", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -503,9 +901,34 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { db['hdhr-settings'].update({ _id: req.body._id }, req.body) let hdhr = db['hdhr-settings'].find()[0] res.send(hdhr) + eventService.push( + "settings-update", + { + "message": "HDHR configuration updated.", + "module" : "hdhr", + "detail" : { + "action" : "update" + }, + "level" : "info" + } + ); + } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error updating HDHR configuration", + "module" : "hdhr", + "detail" : { + "action": "action", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) @@ -518,22 +941,52 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { }) var hdhr = db['hdhr-settings'].find()[0] res.send(hdhr) + eventService.push( + "settings-update", + { + "message": "HDHR configuration reset.", + "module" : "hdhr", + "detail" : { + "action" : "reset" + }, + "level" : "warning" + } + ); + } catch(err) { console.error(err); res.status(500).send("error"); + eventService.push( + "settings-update", + { + "message": "Error reseting HDHR configuration", + "module" : "hdhr", + "detail" : { + "action": "reset", + "error" : safeString(err, "message"), + }, + "level" : "danger" + } + ); + } }) // XMLTV.XML Download - router.get('/api/xmltv.xml', (req, res) => { + router.get('/api/xmltv.xml', async (req, res) => { try { + const host = `${req.protocol}://${req.get('host')}`; + res.set('Cache-Control', 'no-store') - res.type('text') - let xmltvSettings = db['xmltv-settings'].find()[0] - let f = path.resolve(xmltvSettings.file); - res.sendFile(f) + res.type('application/xml'); + + + let xmltvSettings = db['xmltv-settings'].find()[0]; + const fileContent = await fs.readFileSync(xmltvSettings.file, 'utf8'); + const fileFinal = fileContent.replace(/\{\{host\}\}/g, host); + res.send(fileFinal); } catch(err) { console.error(err); res.status(500).send("error"); @@ -546,6 +999,21 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { try { let toolRes = await timeSlotsService(req.body.programs, req.body.schedule); if ( typeof(toolRes.userError) !=='undefined') { + console.error("time slots error: " + toolRes.userError); + return res.status(400).send(toolRes.userError); + } + res.status(200).send(toolRes); + } catch(err) { + console.error(err); + res.status(500).send("Internal error"); + } + }); + + router.post('/api/channel-tools/random-slots', async (req, res) => { + try { + let toolRes = await randomSlotsService(req.body.programs, req.body.schedule); + if ( typeof(toolRes.userError) !=='undefined') { + console.error("random slots error: " + toolRes.userError); return res.status(400).send(toolRes.userError); } res.status(200).send(toolRes); @@ -558,22 +1026,13 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { // CHANNELS.M3U Download router.get('/api/channels.m3u', async (req, res) => { try { - res.type('text') - let channels = await channelDB.getAllChannels(); - channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) - let tvg = `${req.protocol}://${req.get('host')}/api/xmltv.xml` - var data = `#EXTM3U url-tvg="${tvg}" x-tvg-url="${tvg}"\n`; - for (var i = 0; i < channels.length; i++) { - if (channels[i].stealth!==true) { - data += `#EXTINF:0 tvg-id="${channels[i].number}" CUID="${channels[i].number}" tvg-chno="${channels[i].number}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}" group-title="dizqueTV",${channels[i].name}\n` - data += `${req.protocol}://${req.get('host')}/video?channel=${channels[i].number}\n` - } - } - if (channels.length === 0) { - data += `#EXTINF:0 tvg-id="1" tvg-chno="1" tvg-name="dizqueTV" tvg-logo="https://raw.githubusercontent.com/vexorian/dizquetv/main/resources/dizquetv.png" group-title="dizqueTV",dizqueTV\n` - data += `${req.protocol}://${req.get('host')}/setup\n` - } - res.send(data) + res.type('text'); + + const host = `${req.protocol}://${req.get('host')}`; + const data = await m3uService.getChannelList(host); + + res.send(data); + } catch(err) { console.error(err); res.status(500).send("error"); @@ -581,30 +1040,6 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { }) - // hls.m3u Download is not really working correctly right now - router.get('/api/hls.m3u', async (req, res) => { - try { - res.type('text') - let channels = await channelDB.getAllChannels(); - channels.sort((a, b) => { return a.number < b.number ? -1 : 1 }) - var data = "#EXTM3U\n" - for (var i = 0; i < channels.length; i++) { - data += `#EXTINF:0 tvg-id="${channels[i].number}" tvg-chno="${channels[i].number}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}" group-title="dizqueTV",${channels[i].name}\n` - data += `${req.protocol}://${req.get('host')}/m3u8?channel=${channels[i].number}\n` - } - if (channels.length === 0) { - data += `#EXTINF:0 tvg-id="1" tvg-chno="1" tvg-name="dizqueTV" tvg-logo="https://raw.githubusercontent.com/vexorian/dizquetv/main/resources/dizquetv.png" group-title="dizqueTV",dizqueTV\n` - data += `${req.protocol}://${req.get('host')}/setup\n` - } - res.send(data) - } catch(err) { - console.error(err); - res.status(500).send("error"); - } - - }) - - function updateXmltv() { xmltvInterval.updateXML() @@ -620,6 +1055,13 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { } function cleanUpChannel(channel) { + if ( + (typeof(channel.groupTitle) === 'undefined') + || + (channel.groupTitle === '') + ) { + channel.groupTitle = "dizqueTV"; + } channel.programs.forEach( cleanUpProgram ); delete channel.fillerContent; delete channel.filler; @@ -629,3 +1071,10 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) { return router } + + +async function throttle() { + return new Promise((resolve) => { + setImmediate(() => resolve()); + }); +} diff --git a/src/channel-cache.js b/src/channel-cache.js index 2a9fbdd..3588c96 100644 --- a/src/channel-cache.js +++ b/src/channel-cache.js @@ -4,6 +4,7 @@ let cache = {}; let programPlayTimeCache = {}; let fillerPlayTimeCache = {}; let configCache = {}; +let numbers = null; async function getChannelConfig(channelDB, channelId) { //with lazy-loading @@ -21,6 +22,22 @@ async function getChannelConfig(channelDB, channelId) { return configCache[channelId]; } +async function getAllNumbers(channelDB) { + if (numbers === null) { + let n = channelDB.getAllChannelNumbers(); + numbers = n; + } + return numbers; +} + +async function getAllChannels(channelDB) { + let channelNumbers = await getAllNumbers(channelDB); + return await Promise.all( channelNumbers.map( async (x) => { + return (await getChannelConfig(channelDB, x))[0]; + }) ); +} + + function saveChannelConfig(number, channel ) { configCache[number] = [channel]; } @@ -127,6 +144,7 @@ function clear() { //it's not necessary to clear the playback cache and it may be undesirable configCache = {}; cache = {}; + numbers = null; } module.exports = { @@ -134,6 +152,7 @@ module.exports = { recordPlayback: recordPlayback, clear: clear, getProgramLastPlayTime: getProgramLastPlayTime, + getAllChannels: getAllChannels, getChannelConfig: getChannelConfig, saveChannelConfig: saveChannelConfig, getFillerLastPlayTime: getFillerLastPlayTime, diff --git a/src/constants.js b/src/constants.js index 4fdb0fe..f1b3bb0 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,5 +5,5 @@ module.exports = { TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000, TOO_FREQUENT: 100, - VERSION_NAME: "1.2.5" + VERSION_NAME: "1.4.2" } diff --git a/src/dao/custom-show-db.js b/src/dao/custom-show-db.js new file mode 100644 index 0000000..d1ec450 --- /dev/null +++ b/src/dao/custom-show-db.js @@ -0,0 +1,131 @@ +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +let fs = require('fs'); + +class CustomShowDB { + + constructor(folder) { + this.folder = folder; + } + + async $loadShow(id) { + let f = path.join(this.folder, `${id}.json` ); + try { + return await new Promise( (resolve, reject) => { + fs.readFile(f, (err, data) => { + if (err) { + return reject(err); + } + try { + let j = JSON.parse(data); + j.id = id; + resolve(j); + } catch (err) { + reject(err); + } + }) + }); + } catch (err) { + console.error(err); + return null; + } + } + + async getShow(id) { + return await this.$loadShow(id); + } + + async saveShow(id, json) { + if (typeof(id) === 'undefined') { + throw Error("Mising custom show id"); + } + let f = path.join(this.folder, `${id}.json` ); + + await new Promise( (resolve, reject) => { + let data = undefined; + try { + //id is determined by the file name, not the contents + fixup(json); + delete json.id; + data = JSON.stringify(json); + } catch (err) { + return reject(err); + } + fs.writeFile(f, data, (err) => { + if (err) { + return reject(err); + } + resolve(); + }); + }); + } + + async createShow(json) { + let id = uuidv4(); + fixup(json); + await this.saveShow(id, json); + return id; + } + + async deleteShow(id) { + let f = path.join(this.folder, `${id}.json` ); + await new Promise( (resolve, reject) => { + fs.unlink(f, function (err) { + if (err) { + return reject(err); + } + resolve(); + }); + }); + } + + + async getAllShowIds() { + return await new Promise( (resolve, reject) => { + fs.readdir(this.folder, function(err, items) { + if (err) { + return reject(err); + } + let fillerIds = []; + for (let i = 0; i < items.length; i++) { + let name = path.basename( items[i] ); + if (path.extname(name) === '.json') { + let id = name.slice(0, -5); + fillerIds.push(id); + } + } + resolve (fillerIds); + }); + }); + } + + async getAllShows() { + let ids = await this.getAllShowIds(); + return await Promise.all( ids.map( async (c) => this.getShow(c) ) ); + } + + async getAllShowsInfo() { + //returns just name and id + let shows = await this.getAllShows(); + return shows.map( (f) => { + return { + 'id' : f.id, + 'name': f.name, + 'count': f.content.length, + } + } ); + } + + +} + +function fixup(json) { + if (typeof(json.content) === 'undefined') { + json.content = []; + } + if (typeof(json.name) === 'undefined') { + json.name = "Unnamed Show"; + } +} + +module.exports = CustomShowDB; \ No newline at end of file diff --git a/src/dao/filler-db.js b/src/dao/filler-db.js index b9d7f9b..fcd2d3e 100644 --- a/src/dao/filler-db.js +++ b/src/dao/filler-db.js @@ -192,8 +192,8 @@ class FillerDB { } function fixup(json) { - if (typeof(json.fillerContent) === 'undefined') { - json.fillerContent = []; + if (typeof(json.content) === 'undefined') { + json.content = []; } if (typeof(json.name) === 'undefined') { json.name = "Unnamed Filler"; diff --git a/src/dao/plex-server-db.js b/src/dao/plex-server-db.js index ebbf09e..02d19ed 100644 --- a/src/dao/plex-server-db.js +++ b/src/dao/plex-server-db.js @@ -1,14 +1,20 @@ //hmnn this is more of a "PlexServerService"... +const ICON_REGEX = /https?:\/\/.*(\/library\/metadata\/\d+\/thumb\/\d+).X-Plex-Token=.*/; + +const ICON_FIELDS = ["icon", "showIcon", "seasonIcon", "episodeIcon"]; + class PlexServerDB { - constructor(channelDB, channelCache, db) { + constructor(channelDB, channelCache, fillerDB, showDB, db) { this.channelDB = channelDB; this.db = db; this.channelCache = channelCache; + this.fillerDB = fillerDB; + this.showDB = showDB; } - async deleteServer(name) { + async fixupAllChannels(name, newServer) { let channelNumbers = await this.channelDB.getAllChannelNumbers(); let report = await Promise.all( channelNumbers.map( async (i) => { let channel = await this.channelDB.getChannel(i); @@ -16,17 +22,10 @@ class PlexServerDB channelNumber : channel.number, channelName : channel.name, destroyedPrograms: 0, + modifiedPrograms: 0, }; - this.fixupProgramArray(channel.programs, name, channelReport); - this.fixupProgramArray(channel.fillerContent, name, channelReport); - this.fixupProgramArray(channel.fallback, name, channelReport); - if (typeof(channel.fillerContent) !== 'undefined') { - channel.fillerContent = channel.fillerContent.filter( - (p) => { - return (true !== p.isOffline); - } - ); - } + this.fixupProgramArray(channel.programs, name,newServer, channelReport); + //if fallback became offline, remove it if ( (typeof(channel.fallback) !=='undefined') && (channel.fallback.length > 0) @@ -38,15 +37,87 @@ class PlexServerDB channel.offlinePicture = `http://localhost:${process.env.PORT}/images/generic-offline-screen.png`; } } - this.fixupProgramArray(channel.fallback, name, channelReport); + this.fixupProgramArray(channel.fallback, name,newServer, channelReport); await this.channelDB.saveChannel(i, channel); - this.db['plex-servers'].remove( { name: name } ); return channelReport; }) ); this.channelCache.clear(); return report; } + async fixupAllFillers(name, newServer) { + let fillers = await this.fillerDB.getAllFillers(); + let report = await Promise.all( fillers.map( async (filler) => { + let fillerReport = { + channelNumber : "--", + channelName : filler.name + " (filler)", + destroyedPrograms: 0, + modifiedPrograms: 0, + }; + this.fixupProgramArray( filler.content, name,newServer, fillerReport ); + filler.content = this.removeOffline(filler.content); + + await this.fillerDB.saveFiller( filler.id, filler ); + + return fillerReport; + } ) ); + return report; + + } + + async fixupAllShows(name, newServer) { + let shows = await this.showDB.getAllShows(); + let report = await Promise.all( shows.map( async (show) => { + let showReport = { + channelNumber : "--", + channelName : show.name + " (custom show)", + destroyedPrograms: 0, + modifiedPrograms: 0, + }; + this.fixupProgramArray( show.content, name,newServer, showReport ); + show.content = this.removeOffline(show.content); + + await this.showDB.saveShow( show.id, show ); + + return showReport; + } ) ); + return report; + + } + + + removeOffline( progs ) { + if (typeof(progs) === 'undefined') { + return progs; + } + return progs.filter( + (p) => { + return (true !== p.isOffline); + } + ); + } + + async fixupEveryProgramHolders(serverName, newServer) { + let reports = await Promise.all( [ + this.fixupAllChannels( serverName, newServer ), + this.fixupAllFillers(serverName, newServer), + this.fixupAllShows(serverName, newServer), + ] ); + let report = []; + reports.forEach( + (r) => r.forEach( (r2) => { + report.push(r2) + } ) + ); + return report; + } + + async deleteServer(name) { + let report = await this.fixupEveryProgramHolders(name, null); + this.db['plex-servers'].remove( { name: name } ); + return report; + } + doesNameExist(name) { return this.db['plex-servers'].find( { name: name} ).length > 0; } @@ -65,7 +136,7 @@ class PlexServerDB if (typeof(arGuide) === 'undefined') { arGuide = true; } - let arChannels = server.arGuide; + let arChannels = server.arChannels; if (typeof(arChannels) === 'undefined') { arChannels = false; } @@ -77,11 +148,15 @@ class PlexServerDB arChannels: arChannels, index: s.index, } + this.normalizeServer(newServer); + + let report = await this.fixupEveryProgramHolders(name, newServer); this.db['plex-servers'].update( { _id: s._id }, newServer ); + return report; } @@ -117,26 +192,56 @@ class PlexServerDB arChannels: arChannels, index: index, }; + this.normalizeServer(newServer); this.db['plex-servers'].save(newServer); } - fixupProgramArray(arr, serverName, channelReport) { + fixupProgramArray(arr, serverName,newServer, channelReport) { if (typeof(arr) !== 'undefined') { for(let i = 0; i < arr.length; i++) { - arr[i] = this.fixupProgram( arr[i], serverName, channelReport ); + arr[i] = this.fixupProgram( arr[i], serverName,newServer, channelReport ); } } } - fixupProgram(program, serverName, channelReport) { - if (program.serverKey === serverName) { + fixupProgram(program, serverName,newServer, channelReport) { + if ( (program.serverKey === serverName) && (newServer == null) ) { channelReport.destroyedPrograms += 1; return { isOffline: true, duration: program.duration, } + } else if (program.serverKey === serverName) { + let modified = false; + ICON_FIELDS.forEach( (field) => { + if ( + (typeof(program[field] ) === 'string') + && + program[field].includes("/library/metadata") + && + program[field].includes("X-Plex-Token") + ) { + let m = program[field].match(ICON_REGEX); + if (m.length == 2) { + let lib = m[1]; + let newUri = `${newServer.uri}${lib}?X-Plex-Token=${newServer.accessToken}` + program[field] = newUri; + modified = true; + } + } + + } ); + if (modified) { + channelReport.modifiedPrograms += 1; + } } return program; } + + normalizeServer(server) { + while (server.uri.endsWith("/")) { + server.uri = server.uri.slice(0,-1); + } + } } module.exports = PlexServerDB \ No newline at end of file diff --git a/src/database-migration.js b/src/database-migration.js index 6564678..4cf6321 100644 --- a/src/database-migration.js +++ b/src/database-migration.js @@ -20,7 +20,7 @@ const path = require('path'); var fs = require('fs'); -const TARGET_VERSION = 703; +const TARGET_VERSION = 802; const STEPS = [ // [v, v2, x] : if the current version is v, call x(db), and version becomes v2 @@ -35,6 +35,13 @@ const STEPS = [ [ 601, 700, (db) => migrateWatermark(db) ], [ 700, 701, (db) => addScalingAlgorithm(db) ], [ 701, 703, (db,channels,dir) => reAddIcon(dir) ], + [ 703, 800, (db) => addDeinterlaceFilter(db) ], + // there was a bit of thing in which for a while 1.3.x migrated 701 to 702 using + // the addDeinterlaceFilter step. This 702 step no longer exists as a target + // but we have to migrate it to 800 using the reAddIcon. + [ 702, 800, (db,channels,dir) => reAddIcon(dir) ], + [ 800, 801, (db) => addImageCache(db) ], + [ 801, 802, () => addGroupTitle() ], ] const { v4: uuidv4 } = require('uuid'); @@ -398,6 +405,7 @@ function ffmpeg() { normalizeAudio: true, maxFPS: 60, scalingAlgorithm: "bicubic", + deinterlaceFilter: "none", } } @@ -734,7 +742,7 @@ function migrateWatermark(db, channelDB) { return channel; } - console.log("Extracting fillers from channels..."); + console.log("Migrating watermarks..."); let channels = path.join(process.env.DATABASE, 'channels'); let channelFiles = fs.readdirSync(channels); for (let i = 0; i < channelFiles.length; i++) { @@ -790,6 +798,44 @@ function reAddIcon(dir) { fs.writeFileSync(process.env.DATABASE + '/images/loading-screen.png', data) } +function addDeinterlaceFilter(db) { + let ffmpegSettings = db['ffmpeg-settings'].find()[0]; + let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json'); + ffmpegSettings.deinterlaceFilter = "none"; + fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) ); +} + +function addImageCache(db) { + let xmltvSettings = db['xmltv-settings'].find()[0]; + let f = path.join(process.env.DATABASE, 'xmltv-settings.json'); + xmltvSettings.enableImageCache = false; + fs.writeFileSync( f, JSON.stringify( [xmltvSettings] ) ); +} + +function addGroupTitle() { + + function migrateChannel(channel) { + channel.groupTitle= "dizqueTV"; + return channel; + } + + console.log("Adding group title to channels..."); + let channels = path.join(process.env.DATABASE, 'channels'); + let channelFiles = fs.readdirSync(channels); + for (let i = 0; i < channelFiles.length; i++) { + if (path.extname( channelFiles[i] ) === '.json') { + console.log("Adding group title to channel : " + channelFiles[i] +"..." ); + let channelPath = path.join(channels, channelFiles[i]); + let channel = JSON.parse(fs.readFileSync(channelPath, 'utf-8')); + channel = migrateChannel(channel); + fs.writeFileSync( channelPath, JSON.stringify(channel), 'utf-8'); + } + } + console.log("Done migrating group titles in channels."); +} + + + module.exports = { initDB: initDB, diff --git a/src/ffmpeg.js b/src/ffmpeg.js index b05238c..00ace6c 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -62,6 +62,10 @@ class FFMPEG extends events.EventEmitter { this.ensureResolution = this.opts.normalizeResolution; this.volumePercent = this.opts.audioVolumePercent; this.hasBeenKilled = false; + this.audioOnly = false; + } + setAudioOnly(audioOnly) { + this.audioOnly = audioOnly; } async spawnConcat(streamUrl) { return await this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true) @@ -107,11 +111,21 @@ class FFMPEG extends events.EventEmitter { let ffmpegArgs = [ `-threads`, isConcatPlaylist? 1 : this.opts.threads, `-fflags`, `+genpts+discardcorrupt+igndts`]; + let stillImage = false; - if (limitRead === true) - ffmpegArgs.push(`-re`) - + if ( + (limitRead === true) + && + ( + (this.audioOnly !== true) + || + ( typeof(streamUrl.errorTitle) === 'undefined') + ) + ) { + ffmpegArgs.push(`-re`); + } + if (typeof startTime !== 'undefined') ffmpegArgs.push(`-ss`, startTime) @@ -165,28 +179,62 @@ class FFMPEG extends events.EventEmitter { currentVideo ="[fpchange]"; } + // deinterlace if desired + if (streamStats.videoScanType == 'interlaced' && this.opts.deinterlaceFilter != 'none') { + videoComplex += `;${currentVideo}${this.opts.deinterlaceFilter}[deinterlaced]`; + currentVideo = "[deinterlaced]"; + } + // prepare input streams - if ( typeof(streamUrl.errorTitle) !== 'undefined') { + if ( ( typeof(streamUrl.errorTitle) !== 'undefined') || (streamStats.audioOnly) ) { doOverlay = false; //never show icon in the error screen // for error stream, we have to generate the input as well this.apad = false; //all of these generate audio correctly-aligned to video so there is no need for apad this.audioChannelsSampleRate = true; //we'll need these - if (this.ensureResolution) { - //all of the error strings already choose the resolution to - //match iW x iH , so with this we save ourselves a second - // scale filter - iW = this.wantedW; - iH = this.wantedH; + //all of the error strings already choose the resolution to + //match iW x iH , so with this we save ourselves a second + // scale filter + iW = this.wantedW; + iH = this.wantedH; + + if (this.audioOnly !== true) { + ffmpegArgs.push("-r" , "24"); + let pic = null; + + //does an image to play exist? + if ( + (typeof(streamUrl.errorTitle) === 'undefined') + && + (streamStats.audioOnly) + ) { + pic = streamStats.placeholderImage; + } else if ( streamUrl.errorTitle == 'offline') { + pic = `${this.channel.offlinePicture}`; + } else if ( this.opts.errorScreen == 'pic' ) { + pic = `${this.errorPicturePath}`; } - ffmpegArgs.push("-r" , "24"); - if ( streamUrl.errorTitle == 'offline' ) { + if (pic != null) { ffmpegArgs.push( - '-loop', '1', - '-i', `${this.channel.offlinePicture}`, + '-i', pic, ); - videoComplex = `;[${inputFiles++}:0]loop=loop=-1:size=1:start=0[looped];[looped]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; + if ( + (typeof duration === 'undefined') + && + (typeof(streamStats.duration) !== 'undefined' ) + ) { + //add 150 milliseconds just in case, exact duration seems to cut out the last bits of music some times. + duration = `${streamStats.duration + 150}ms`; + } + videoComplex = `;[${inputFiles++}:0]format=yuv420p[formatted]`; + videoComplex +=`;[formatted]scale=w=${iW}:h=${iH}:force_original_aspect_ratio=1[scaled]`; + videoComplex += `;[scaled]pad=${iW}:${iH}:(ow-iw)/2:(oh-ih)/2[padded]`; + videoComplex += `;[padded]loop=loop=-1:size=1:start=0[looped]`; + videoComplex +=`;[looped]realtime[videox]`; + //this tune apparently makes the video compress better + // when it is the same image + stillImage = true; } else if (this.opts.errorScreen == 'static') { ffmpegArgs.push( '-f', 'lavfi', @@ -212,22 +260,17 @@ class FFMPEG extends events.EventEmitter { inputFiles++; videoComplex = `;drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=${sz1}:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:text='${streamUrl.errorTitle}',drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=${sz2}:fontcolor=white:x=(w-text_w)/2:y=(h+text_h+${sz3})/2:text='${streamUrl.subtitle}'[videoy];[videoy]realtime[videox]`; - } else if (this.opts.errorScreen == 'blank') { + } else { //blank ffmpegArgs.push( '-f', 'lavfi', '-i', `color=c=black:s=${iW}x${iH}` ); inputFiles++; videoComplex = `;realtime[videox]`; - } else {//'pic' - ffmpegArgs.push( - '-loop', '1', - '-i', `${this.errorPicturePath}`, - ); - inputFiles++; - videoComplex = `;[${videoFile+1}:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; } + } let durstr = `duration=${streamStats.duration}ms`; + if (typeof(streamUrl.errorTitle) !== 'undefined') { //silent audioComplex = `;aevalsrc=0:${durstr}[audioy]`; if ( streamUrl.errorTitle == 'offline' ) { @@ -240,17 +283,28 @@ class FFMPEG extends events.EventEmitter { // 'size' in order to make the soundtrack actually loop audioComplex = `;[${inputFiles++}:a]aloop=loop=-1:size=2147483647[audioy]`; } - } else if (this.opts.errorAudio == 'whitenoise') { + } else if ( + (this.opts.errorAudio == 'whitenoise') + || + ( + !(this.opts.errorAudio == 'sine') + && + (this.audioOnly === true) //when it's in audio-only mode, silent stream is confusing for errors. + ) + ) { audioComplex = `;aevalsrc=random(0):${durstr}[audioy]`; this.volumePercent = Math.min(70, this.volumePercent); } else if (this.opts.errorAudio == 'sine') { audioComplex = `;sine=f=440:${durstr}[audioy]`; this.volumePercent = Math.min(70, this.volumePercent); } - ffmpegArgs.push('-pix_fmt' , 'yuv420p' ); + if ( this.audioOnly !== true ) { + ffmpegArgs.push('-pix_fmt' , 'yuv420p' ); + } audioComplex += ';[audioy]arealtime[audiox]'; - currentVideo = "[videox]"; currentAudio = "[audiox]"; + } + currentVideo = "[videox]"; } if (doOverlay) { if (watermark.animated === true) { @@ -266,9 +320,13 @@ class FFMPEG extends events.EventEmitter { let algo = this.opts.scalingAlgorithm; let resizeMsg = ""; if ( + (!streamStats.audioOnly) + && + ( (this.ensureResolution && ( streamStats.anamorphic || (iW != this.wantedW || iH != this.wantedH) ) ) || isLargerResolution(iW, iH, this.wantedW, this.wantedH) + ) ) { //scaler stuff, need to change the size of the video and also add bars // calculate wanted aspect ratio @@ -320,7 +378,7 @@ class FFMPEG extends events.EventEmitter { } // Channel watermark: - if (doOverlay) { + if (doOverlay && (this.audioOnly !== true) ) { var pW =watermark.width; var w = Math.round( pW * iW / 100.0 ); var mpHorz = watermark.horizontalMargin; @@ -362,7 +420,8 @@ class FFMPEG extends events.EventEmitter { currentAudio = '[boosted]'; } // Align audio is just the apad filter applied to audio stream - if (this.apad) { + if (this.apad && (this.audioOnly !== true) ) { + //it doesn't make much sense to pad audio when there is no video audioComplex += `;${currentAudio}apad=whole_dur=${streamStats.duration}ms[padded]`; currentAudio = '[padded]'; } else if (this.audioChannelsSampleRate) { @@ -383,11 +442,13 @@ class FFMPEG extends events.EventEmitter { } else { console.log(resizeMsg) } - if (currentVideo != '[video]') { - transcodeVideo = true; //this is useful so that it adds some lines below - filterComplex += videoComplex; - } else { - currentVideo = `${videoFile}:${videoIndex}`; + if (this.audioOnly !== true) { + if (currentVideo != '[video]') { + transcodeVideo = true; //this is useful so that it adds some lines below + filterComplex += videoComplex; + } else { + currentVideo = `${videoFile}:${videoIndex}`; + } } // same with audio: if (currentAudio != '[audio]') { @@ -404,15 +465,21 @@ class FFMPEG extends events.EventEmitter { ffmpegArgs.push('-shortest'); } } - + if (this.audioOnly !== true) { + ffmpegArgs.push( + '-map', currentVideo, + `-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'), + `-sc_threshold`, `1000000000`, + ); + if (stillImage) { + ffmpegArgs.push('-tune', 'stillimage'); + } + } ffmpegArgs.push( - '-map', currentVideo, - '-map', currentAudio, - `-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'), - `-flags`, `cgop+ilme`, - `-sc_threshold`, `1000000000` + '-map', currentAudio, + `-flags`, `cgop+ilme`, ); - if ( transcodeVideo ) { + if ( transcodeVideo && (this.audioOnly !== true) ) { // add the video encoder flags ffmpegArgs.push( `-b:v`, `${this.opts.videoBitrate}k`, @@ -454,8 +521,11 @@ class FFMPEG extends events.EventEmitter { //Concat stream is simpler and should always copy the codec ffmpegArgs.push( `-probesize`, 32 /*`100000000`*/, - `-i`, streamUrl, - `-map`, `0:v`, + `-i`, streamUrl ); + if (this.audioOnly !== true) { + ffmpegArgs.push( `-map`, `0:v` ); + } + ffmpegArgs.push( `-map`, `0:${audioIndex}`, `-c`, `copy`, `-muxdelay`, this.opts.concatMuxDelay, @@ -466,14 +536,14 @@ class FFMPEG extends events.EventEmitter { `service_provider="dizqueTV"`, `-metadata`, `service_name="${this.channel.name}"`, - `-f`, `mpegts`); + ); - //t should be before output + //t should be before -f if (typeof duration !== 'undefined') { - ffmpegArgs.push(`-t`, duration) + ffmpegArgs.push(`-t`, `${duration}`); } - ffmpegArgs.push(`pipe:1`) + ffmpegArgs.push(`-f`, `mpegts`, `pipe:1`) let doLogs = this.opts.logFfmpeg && !isConcatPlaylist; if (this.hasBeenKilled) { @@ -481,6 +551,7 @@ class FFMPEG extends events.EventEmitter { } this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } ); if (this.hasBeenKilled) { + console.log("Send SIGKILL to ffmpeg"); this.ffmpeg.kill("SIGKILL"); return; } diff --git a/src/offline-player.js b/src/offline-player.js index 802dd1f..38d846e 100644 --- a/src/offline-player.js +++ b/src/offline-player.js @@ -19,6 +19,7 @@ class OfflinePlayer { context.channel.offlineSoundtrack = undefined; } this.ffmpeg = new FFMPEG(context.ffmpegSettings, context.channel); + this.ffmpeg.setAudioOnly(this.context.audioOnly); } cleanUp() { @@ -55,6 +56,7 @@ class OfflinePlayer { ffmpeg.removeAllListeners('error'); ffmpeg.removeAllListeners('close'); ffmpeg = new FFMPEG(this.context.ffmpegSettings, this.context.channel); // Set the transcoder options + ffmpeg.setAudioOnly(this.context.audioOnly); ffmpeg.on('close', () => { emitter.emit('close'); }); diff --git a/src/plex-player.js b/src/plex-player.js index a6bce79..9a75f84 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -49,7 +49,7 @@ class PlexPlayer { let channel = this.context.channel; let server = db['plex-servers'].find( { 'name': lineupItem.serverKey } ); if (server.length == 0) { - throw Error(`Unable to find server "${lineupItem.serverKey}" specied by program.`); + throw Error(`Unable to find server "${lineupItem.serverKey}" specified by program.`); } server = server[0]; if (server.uri.endsWith("/")) { @@ -62,6 +62,7 @@ class PlexPlayer { this.plexTranscoder = plexTranscoder; let watermark = this.context.watermark; let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + ffmpeg.setAudioOnly( this.context.audioOnly ); this.ffmpeg = ffmpeg; let streamDuration; if (typeof(lineupItem.streamDuration)!=='undefined') { @@ -104,6 +105,7 @@ class PlexPlayer { ffmpeg.removeAllListeners('error'); ffmpeg.removeAllListeners('close'); ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + ffmpeg.setAudioOnly(this.context.audioOnly); ffmpeg.on('close', () => { emitter.emit('close'); }); @@ -122,11 +124,7 @@ class PlexPlayer { return emitter; } catch(err) { - if (err instanceof Error) { - throw err; - } else { - return Error("Error when playing plex program: " + JSON.stringify(err) ); - } + return Error("Error when playing plex program: " + JSON.stringify(err) ); } } } diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index e7ca8c7..b4f8524 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -35,6 +35,11 @@ class PlexTranscoder { this.updateInterval = 30000 this.updatingPlex = undefined this.playState = "stopped" + this.mediaHasNoVideo = false; + this.albumArt = { + attempted : false, + path: null, + } } async getStream(deinterlace) { @@ -44,23 +49,26 @@ class PlexTranscoder { this.log(` deinterlace: ${deinterlace}`) this.log(` streamPath: ${this.settings.streamPath}`) + this.setTranscodingArgs(stream.directPlay, true, false, false); + await this.tryToDetectAudioOnly(); + if (this.settings.streamPath === 'direct' || this.settings.forceDirectPlay) { if (this.settings.enableSubtitles) { - console.log("Direct play is forced, so subtitles are forcibly disabled."); + this.log("Direct play is forced, so subtitles are forcibly disabled."); this.settings.enableSubtitles = false; } stream = {directPlay: true} } else { try { this.log("Setting transcoding parameters") - this.setTranscodingArgs(stream.directPlay, true, deinterlace) + this.setTranscodingArgs(stream.directPlay, true, deinterlace, this.mediaHasNoVideo) await this.getDecision(stream.directPlay); if (this.isDirectPlay()) { stream.directPlay = true; stream.streamUrl = this.plexFile; } } catch (err) { - this.log("Error when getting decision. 1. Check Plex connection. 2. This might also be a sign that plex direct play and transcode settings are too strict and it can't find any allowed action for the selected video.") + console.error("Error when getting decision. 1. Check Plex connection. 2. This might also be a sign that plex direct play and transcode settings are too strict and it can't find any allowed action for the selected video.", err) stream.directPlay = true; } } @@ -70,7 +78,7 @@ class PlexTranscoder { } this.log("Direct play forced or native paths enabled") stream.directPlay = true - this.setTranscodingArgs(stream.directPlay, true, false) + this.setTranscodingArgs(stream.directPlay, true, false, this.mediaHasNoVideo ) // Update transcode decision for session await this.getDecision(stream.directPlay); stream.streamUrl = (this.settings.streamPath === 'direct') ? this.file : this.plexFile; @@ -88,7 +96,7 @@ class PlexTranscoder { } else if (this.isVideoDirectStream() === false) { this.log("Decision: Should transcode") // Change transcoding arguments to be the user chosen transcode parameters - this.setTranscodingArgs(stream.directPlay, false, deinterlace) + this.setTranscodingArgs(stream.directPlay, false, deinterlace, this.mediaHasNoVideo) // Update transcode decision for session await this.getDecision(stream.directPlay); stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}` @@ -110,13 +118,14 @@ class PlexTranscoder { return stream } - setTranscodingArgs(directPlay, directStream, deinterlace) { + setTranscodingArgs(directPlay, directStream, deinterlace, audioOnly) { let resolution = (directStream) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution let bitrate = (directStream) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate let mediaBufferSize = (directStream) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize let subtitles = (this.settings.enableSubtitles) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar let streamContainer = "mpegts" // Other option is mkv, mkv has the option of copying it's subs for later processing - let isDirectPlay = (directPlay) ? '1' : '0' + let isDirectPlay = (directPlay) ? '1' : '0'; + let hasMDE = '1'; let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra @@ -132,12 +141,17 @@ class PlexTranscoder { vc = "av1"; } - let clientProfile=`add-transcode-target(type=videoProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&videoCodec=${vc}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\ + let clientProfile =""; + if (! audioOnly ) { + clientProfile=`add-transcode-target(type=videoProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&videoCodec=${vc}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\ add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&CopyMatroskaAttachments=true)+\ add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&BreakNonKeyframes=true)+\ add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.width&value=${resolutionArr[0]})+\ add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.height&value=${resolutionArr[1]})` - + } else { + clientProfile=`add-transcode-target(type=musicProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)` + + } // Set transcode settings per audio codec this.settings.audioCodecs.split(",").forEach(function (codec) { clientProfile+=`+add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&audioCodec=${codec})` @@ -166,7 +180,7 @@ X-Plex-Token=${this.server.accessToken}&\ X-Plex-Client-Profile-Extra=${clientProfile_enc}&\ protocol=${this.settings.streamProtocol}&\ Connection=keep-alive&\ -hasMDE=1&\ +hasMDE=${hasMDE}&\ path=${this.key}&\ mediaIndex=0&\ partIndex=0&\ @@ -191,7 +205,7 @@ lang=en` try { return this.getVideoStats().videoDecision === "copy"; } catch (e) { - console.log("Error at decision:", e); + console.error("Error at decision:", e); return false; } } @@ -206,9 +220,12 @@ lang=en` isDirectPlay() { try { + if (this.getVideoStats().audioOnly) { + return this.getVideoStats().audioDecision === "copy"; + } return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy"; } catch (e) { - console.log("Error at decision:" , e); + console.error("Error at decision:" , e); return false; } } @@ -217,7 +234,6 @@ lang=en` let ret = {} try { let streams = this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].Stream - ret.duration = parseFloat( this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].duration ); streams.forEach(function (_stream, $index) { // Video @@ -245,6 +261,7 @@ lang=en` // Rounding framerate avoids scenarios where // 29.9999999 & 30 don't match. ret.videoDecision = (typeof stream.decision === 'undefined') ? 'copy' : stream.decision; + ret.videoScanType = stream.scanType; } // Audio. Only look at stream being used if (stream["streamType"] == "2" && stream["selected"] == "1") { @@ -254,7 +271,15 @@ lang=en` } }.bind(this) ) } catch (e) { - console.log("Error at decision:" , e); + console.error("Error at decision:" , e); + } + if (typeof(ret.videoCodec) === 'undefined') { + ret.audioOnly = true; + ret.placeholderImage = (this.albumArt.path != null) ? + ret.placeholderImage = this.albumArt.path + : + ret.placeholderImage = `http://localhost:${process.env.PORT}/images/generic-music-screen.png` + ; } this.log("Current video stats:") @@ -281,11 +306,11 @@ lang=en` } }) } catch (e) { - console.log("Error at get media info:" + e); + console.error("Error at get media info:" + e); } }) .catch((err) => { - console.log(err); + console.error("Error getting audio index",err); }); this.log(`Found audio index: ${index}`) @@ -299,21 +324,60 @@ lang=en` } async getDecisionUnmanaged(directPlay) { - let res = await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, { + let url = `${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`; + let res = await axios.get(url, { headers: { Accept: 'application/json' } }) this.decisionJson = res.data; - this.log("Recieved transcode decision:") + this.log("Received transcode decision:"); this.log(res.data) // Print error message if transcode not possible // TODO: handle failure better - let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode - if (!(directPlay || transcodeDecisionCode == "1001")) { - console.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`) - console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`) + if (res.data.MediaContainer.mdeDecisionCode === 1000) { + this.log("mde decision code 1000, so it's all right?"); + return; } + + let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode; + if ( + ( typeof(transcodeDecisionCode) === 'undefined' ) + ) { + this.decisionJson.MediaContainer.transcodeDecisionCode = 'novideo'; + this.log("Strange case, attempt direct play"); + } else if (!(directPlay || transcodeDecisionCode == "1001")) { + this.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`) + this.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`) + } + } + + async tryToDetectAudioOnly() { + try { + this.log("Try to detect audio only:"); + let url = `${this.server.uri}${this.key}?${this.transcodingArgs}`; + let res = await axios.get(url, { + headers: { Accept: 'application/json' } + }); + + let mediaContainer = res.data.MediaContainer; + let metadata = getOneOrUndefined( mediaContainer, "Metadata"); + if (typeof(metadata) !== 'undefined') { + this.albumArt.path = `${this.server.uri}${metadata.thumb}?X-Plex-Token=${this.server.accessToken}`; + + let media = getOneOrUndefined( metadata, "Media"); + if (typeof(media) !== 'undefined') { + if (typeof(media.videoCodec)==='undefined') { + this.log("Audio-only file detected"); + this.mediaHasNoVideo = true; + } + } + } + } catch (err) { + console.error("Error when getting album art", err); + } + + } async getDecision(directPlay) { @@ -367,8 +431,15 @@ X-Plex-Token=${this.server.accessToken}`; } updatePlex() { - this.log("Updating plex status") - axios.post(this.getStatusUrl()); + this.log("Updating plex status"); + const statusUrl = this.getStatusUrl(); + try { + axios.post(statusUrl); + } catch (error) { + this.log(`Problem updating Plex status using status URL ${statusUrl}:`); + this.log(error); + return false; + } this.currTimeMs += this.updateInterval; if (this.currTimeMs > this.duration) { this.currTimeMs = this.duration; @@ -391,4 +462,19 @@ function parsePixelAspectRatio(s) { q: parseInt(x[1], 10), } } + +function getOneOrUndefined(object, field) { + if (typeof(object) === 'undefined') { + return undefined; + } + if ( typeof(object[field]) === "undefined") { + return undefined; + } + let x = object[field]; + if (x.length < 1) { + return undefined; + } + return x[0]; +} + module.exports = PlexTranscoder diff --git a/src/services/cache-image-service.js b/src/services/cache-image-service.js new file mode 100644 index 0000000..8c1f7b6 --- /dev/null +++ b/src/services/cache-image-service.js @@ -0,0 +1,156 @@ +const fs = require('fs'); +const express = require('express'); +const request = require('request'); + +/** + * Manager a cache in disk for external images. + * + * @class CacheImageService + */ +class CacheImageService { + constructor( db, fileCacheService ) { + this.cacheService = fileCacheService; + this.imageCacheFolder = 'images'; + this.db = db['cache-images']; + } + + /** + * Router interceptor to download image and update cache before pass to express.static return this cached image. + * + * GET /:hash - Hash is a full external URL encoded in base64. + * eg.: http://{host}/cache/images/aHR0cHM6Ly8xO...cXVUbmFVNDZQWS1LWQ== + * + * @returns + * @memberof CacheImageService + */ + routerInterceptor() { + const router = express.Router(); + + router.get('/:hash', async (req, res, next) => { + try { + const hash = req.params.hash; + const imgItem = this.db.find({url: hash})[0]; + if(imgItem) { + const file = await this.getImageFromCache(imgItem.url); + if(!file.length) { + const fileMimeType = await this.requestImageAndStore(Buffer.from(imgItem.url, 'base64').toString('ascii'), imgItem); + res.set('content-type', fileMimeType); + next(); + } else { + res.set('content-type', imgItem.mimeType); + next(); + } + } + } catch(err) { + console.error(err); + res.status(500).send("error"); + } + }); + return router; + } + + /** + * Routers exported to use on express.use() function. + * Use on api routers, like `{host}/api/cache/images` + * + * `DELETE /` - Clear all files on .dizquetv/cache/images + * + * @returns {Router} + * @memberof CacheImageService + */ + apiRouters() { + const router = express.Router(); + + router.delete('/', async (req, res, next) => { + try { + await this.clearCache(); + res.status(200).send({msg: 'Cache Image are Cleared'}); + } catch (error) { + console.error(error); + res.status(500).send("error"); + } + }); + + return router; + } + + /** + * + * + * @param {*} url External URL to get file/image + * @param {*} dbFile register of file from db + * @returns {promise} `Resolve` when can download imagem and store on cache folder, `Reject` when file are inaccessible over network or can't write on directory + * @memberof CacheImageService + */ + async requestImageAndStore(url, dbFile) { + return new Promise( async(resolve, reject) => { + const requestConfiguration = { + method: 'get', + url + }; + + request(requestConfiguration, (err, res) => { + if (err) { + reject(err); + } else { + const mimeType = res.headers['content-type']; + this.db.update({_id: dbFile._id}, {url: dbFile.url, mimeType}); + request(requestConfiguration) + .pipe(fs.createWriteStream(`${this.cacheService.cachePath}/${this.imageCacheFolder}/${dbFile.url}`)) + .on('close', () =>{ + resolve(mimeType); + }); + } + }); + + }); + } + + /** + * Get image from cache using an filename + * + * @param {*} fileName + * @returns {promise} `Resolve` with file content + * @memberof CacheImageService + */ + getImageFromCache(fileName) { + return new Promise(async(resolve, reject) => { + try { + const file = await this.cacheService.getCache(`${this.imageCacheFolder}/${fileName}`); + resolve(file); + } catch (error) { + reject(error); + } + }); + } + + /** + * Clear all files on .dizquetv/cache/images + * + * @returns {promise} + * @memberof CacheImageService + */ + async clearCache() { + return new Promise( async(resolve, reject) => { + const cachePath = `${this.cacheService.cachePath}/${this.imageCacheFolder}`; + fs.rmdir(cachePath, { recursive: true }, (err) => { + if(err) { + reject(); + } + fs.mkdirSync(cachePath); + resolve(); + }); + }); + } + + registerImageOnDatabase(imageUrl) { + const url = Buffer.from(imageUrl).toString('base64'); + const dbQuery = {url}; + if(!this.db.find(dbQuery)[0]) { + this.db.save(dbQuery); + } + return url; + } +} + +module.exports = CacheImageService; \ No newline at end of file diff --git a/src/services/event-service.js b/src/services/event-service.js new file mode 100644 index 0000000..1ac9893 --- /dev/null +++ b/src/services/event-service.js @@ -0,0 +1,47 @@ +const EventEmitter = require("events"); + +class EventsService { + constructor() { + this.stream = new EventEmitter(); + let that = this; + let fun = () => { + that.push( "heartbeat", "{}"); + setTimeout(fun, 5000) + }; + fun(); + + } + + setup(app) { + app.get("/api/events", (request, response) => { + console.log("Open event channel."); + response.writeHead(200, { + "Content-Type" : "text/event-stream", + "Cache-Control" : "no-cache", + "connection" : "keep-alive", + } ); + let listener = (event,data) => { + //console.log( String(event) + " " + JSON.stringify(data) ); + response.write("event: " + String(event) + "\ndata: " + + JSON.stringify(data) + "\nretry: 5000\n\n" ); + }; + + this.stream.on("push", listener ); + response.on( "close", () => { + console.log("Remove event channel."); + this.stream.removeListener("push", listener); + } ); + } ); + } + + push(event, data) { + if (typeof(data.message) !== 'undefined') { + console.log("Push event: " + data.message ); + } + this.stream.emit("push", event, data ); + } + + +} + +module.exports = EventsService; \ No newline at end of file diff --git a/src/services/file-cache-service.js b/src/services/file-cache-service.js new file mode 100644 index 0000000..1e0c6c7 --- /dev/null +++ b/src/services/file-cache-service.js @@ -0,0 +1,97 @@ +const path = require('path'); +const fs = require('fs'); + +/** + * Store files in cache + * + * @class FileCacheService + */ +class FileCacheService { + constructor(cachePath) { + this.cachePath = cachePath; + this.cache = {}; + } + + /** + * `save` a file on cache folder + * + * @param {string} fullFilePath + * @param {*} data + * @returns {promise} + * @memberof CacheService + */ + setCache(fullFilePath, data) { + return new Promise((resolve, reject) => { + try { + const file = fs.createWriteStream(path.join(this.cachePath, fullFilePath)); + file.write(data, (err) => { + if(err) { + throw Error("Can't save file: ", err); + } else { + this.cache[fullFilePath] = data; + resolve(true); + } + }); + } catch (err) { + reject(err); + } + }); + } + + /** + * `get` a File from cache folder + * + * @param {string} fullFilePath + * @returns {promise} `Resolve` with file content, `Reject` with false + * @memberof CacheService + */ + getCache(fullFilePath) { + return new Promise((resolve, reject) => { + try { + if(fullFilePath in this.cache) { + resolve(this.cache[fullFilePath]); + } else { + fs.readFile(path.join(this.cachePath, fullFilePath), 'utf8', function (err,data) { + if (err) { + resolve(false); + } + resolve(data); + }); + } + } catch (error) { + resolve(false); + throw Error("Can't get file", error) + } + }); + } + + /** + * `delete` a File from cache folder + * + * @param {string} fullFilePath + * @returns {promise} + * @memberof CacheService + */ + deleteCache(fullFilePath) { + return new Promise((resolve, reject) => { + try { + let thePath = path.join(this.cachePath, fullFilePath); + if (! fs.existsSync(thePath)) { + return resolve(true); + } + fs.unlinkSync(thePath, (err) => { + if(err) { + throw Error("Can't save file: ", err); + } else { + delete this.cache[fullFilePath]; + resolve(true); + } + }); + } catch (err) { + reject(err); + } + }); + } +} + +module.exports = FileCacheService; \ No newline at end of file diff --git a/src/services/get-show-data.js b/src/services/get-show-data.js new file mode 100644 index 0000000..99d44b1 --- /dev/null +++ b/src/services/get-show-data.js @@ -0,0 +1,65 @@ +//This is an exact copy of the file with the same now in the web project +//one of these days, we'll figure out how to share the code. +module.exports = function () { + + let movieTitleOrder = {}; + let movieTitleOrderNumber = 0; + + return (program) => { + if ( typeof(program.customShowId) !== 'undefined' ) { + return { + hasShow : true, + showId : "custom." + program.customShowId, + showDisplayName : program.customShowName, + order : program.customOrder, + } + } else if (program.isOffline && program.type === 'redirect') { + return { + hasShow : true, + showId : "redirect." + program.channel, + order : program.duration, + showDisplayName : `Redirect to channel ${program.channel}`, + channel: program.channel, + } + } else if (program.isOffline) { + return { + hasShow : false + } + } else if (program.type === 'movie') { + let key = program.serverKey + "|" + program.key; + if (typeof(movieTitleOrder[key]) === 'undefined') { + movieTitleOrder[key] = movieTitleOrderNumber++; + } + return { + hasShow : true, + showId : "movie.", + showDisplayName : "Movies", + order : movieTitleOrder[key], + } + } else if ( (program.type === 'episode') || (program.type === 'track') ) { + let s = 0; + let e = 0; + if ( typeof(program.season) !== 'undefined') { + s = program.season; + } + if ( typeof(program.episode) !== 'undefined') { + e = program.episode; + } + let prefix = "tv."; + if (program.type === 'track') { + prefix = "audio."; + } + return { + hasShow: true, + showId : prefix + program.showTitle, + showDisplayName : program.showTitle, + order : s * 1000000 + e, + } + } else { + return { + hasShow : false, + } + } + } + +} \ No newline at end of file diff --git a/src/services/m3u-service.js b/src/services/m3u-service.js new file mode 100644 index 0000000..f3563b5 --- /dev/null +++ b/src/services/m3u-service.js @@ -0,0 +1,95 @@ +/** + * Manager and Generate M3U content + * + * @class M3uService + */ +class M3uService { + constructor(dataBase, fileCacheService, channelCache) { + this.dataBase = dataBase; + this.cacheService = fileCacheService; + this.channelCache = channelCache; + this.cacheReady = false; + } + + /** + * Get the channel list in HLS or M3U + * + * @param {string} [type='m3u'] List type + * @returns {promise} Return a Promise with HLS or M3U file content + * @memberof M3uService + */ + getChannelList(host) { + return this.buildM3uList(host); + } + + /** + * Build M3U with cache + * + * @param {string} host + * @returns {promise} M3U file content + * @memberof M3uService + */ + + async buildM3uList(host) { + if (this.cacheReady) { + const cachedM3U = await this.cacheService.getCache('channels.m3u'); + if (cachedM3U) { + return this.replaceHostOnM3u(host, cachedM3U); + } + } + let channels = await this.channelCache.getAllChannels(this.dataBase); + + + channels.sort((a, b) => { + return a.number < b.number ? -1 : 1 + }); + + const tvg = `{{host}}/api/xmltv.xml`; + + let data = `#EXTM3U url-tvg="${tvg}" x-tvg-url="${tvg}"\n`; + + for (var i = 0; i < channels.length; i++) { + if (channels[i].stealth !== true) { + data += `#EXTINF:0 tvg-id="${channels[i].number}" CUID="${channels[i].number}" tvg-chno="${channels[i].number}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}" group-title="${channels[i].groupTitle}",${channels[i].name}\n` + data += `{{host}}/video?channel=${channels[i].number}\n` + } + } + if (channels.length === 0) { + data += `#EXTINF:0 tvg-id="1" tvg-chno="1" tvg-name="dizqueTV" tvg-logo="{{host}}/resources/dizquetv.png" group-title="dizqueTV",dizqueTV\n` + data += `{{host}}/setup\n` + } + let saveCacheThread = async() => { + try { + await this.cacheService.setCache('channels.m3u', data); + this.cacheReady = true; + } catch(err) { + console.error(err); + } + }; + saveCacheThread(); + return this.replaceHostOnM3u(host, data); + } + + /** + * Replace {{host}} string with a URL on file contents. + * + * @param {*} host + * @param {*} data + * @returns + * @memberof M3uService + */ + replaceHostOnM3u(host, data) { + return data.replace(/\{\{host\}\}/g, host); + } + + /** + * Clear channels.m3u file from cache folder. + * + * @memberof M3uService + */ + async clearCache() { + this.cacheReady = false; + } +} + +module.exports = M3uService; \ No newline at end of file diff --git a/src/services/random-slots-service.js b/src/services/random-slots-service.js new file mode 100644 index 0000000..2cc6064 --- /dev/null +++ b/src/services/random-slots-service.js @@ -0,0 +1,447 @@ +const constants = require("../constants"); +const getShowData = require("./get-show-data")(); +const random = require('../helperFuncs').random; + +const MINUTE = 60*1000; +const DAY = 24*60*MINUTE; +const LIMIT = 40000; + + + +function getShow(program) { + + let d = getShowData(program); + if (! d.hasShow) { + return null; + } else { + d.description = d.showDisplayName; + d.id = d.showId; + return d; + } +} + + +function shuffle(array, lo, hi ) { + if (typeof(lo) === 'undefined') { + lo = 0; + hi = array.length; + } + let currentIndex = hi, temporaryValue, randomIndex + while (lo !== currentIndex) { + randomIndex = random.integer(lo, currentIndex-1); + currentIndex -= 1 + temporaryValue = array[currentIndex] + array[currentIndex] = array[randomIndex] + array[randomIndex] = temporaryValue + } + return array +} + +function _wait(t) { + return new Promise((resolve) => { + setTimeout(resolve, t); + }); +} + +function getProgramId(program) { + let s = program.serverKey; + if (typeof(s) === 'undefined') { + s = 'unknown'; + } + let p = program.key; + if (typeof(p) === 'undefined') { + p = 'unknown'; + } + return s + "|" + p; +} + +function addProgramToShow(show, program) { + if ( (show.id == 'flex.') || show.id.startsWith("redirect.") ) { + //nothing to do + return; + } + let id = getProgramId(program) + if(show.programs[id] !== true) { + show.programs.push(program); + show.programs[id] = true + } +} + +function getShowOrderer(show) { + if (typeof(show.orderer) === 'undefined') { + + let sortedPrograms = JSON.parse( JSON.stringify(show.programs) ); + sortedPrograms.sort((a, b) => { + 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' }; + } + if (typeof(schedule) === 'undefined') { + return { userError: 'Expected a schedule' }; + } + //verify that the schedule is in the correct format + if (! Array.isArray(schedule.slots) ) { + return { userError: 'Expected a "slots" array in schedule' }; + } + if (typeof(schedule).period === 'undefined') { + schedule.period = DAY; + } + for (let i = 0; i < schedule.slots.length; i++) { + if (typeof(schedule.slots[i].duration) === 'undefined') { + return { userError: "Each slot should have a duration" }; + } + if (typeof(schedule.slots[i].showId) === 'undefined') { + return { userError: "Each slot should have a showId" }; + } + if ( + (schedule.slots[i].duration <= 0) + || (Math.floor(schedule.slots[i].duration) != schedule.slots[i].duration) + ) { + return { userError: "Slot duration should be a integer number of milliseconds greater than 0" }; + } + if ( isNaN(schedule.slots[i].cooldown) ) { + schedule.slots[i].cooldown = 0; + } + if ( isNaN(schedule.slots[i].weight) ) { + schedule.slots[i].weight = 1; + } + } + if (typeof(schedule.pad) === 'undefined') { + return { userError: "Expected schedule.pad" }; + } + if (typeof(schedule.maxDays) == 'undefined') { + return { userError: "schedule.maxDays must be defined." }; + } + if (typeof(schedule.flexPreference) === 'undefined') { + schedule.flexPreference = "distribute"; + } + if (typeof(schedule.padStyle) === 'undefined') { + schedule.padStyle = "slot"; + } + if (schedule.padStyle !== "slot" && schedule.padStyle !== "episode") { + return { userError: `Invalid schedule.padStyle value: "${schedule.padStyle}"` }; + } + let flexBetween = ( schedule.flexPreference !== "end" ); + + // throttle so that the stream is not affected negatively + let steps = 0; + let throttle = async() => { + if (steps++ == 10) { + steps = 0; + await _wait(1); + } + } + + let showsById = {}; + let shows = []; + + function getNextForSlot(slot, remaining) { + //remaining doesn't restrict what next show is picked. It is only used + //for shows with flexible length (flex and redirects) + if (slot.showId === "flex.") { + return { + isOffline: true, + duration: remaining, + } + } + let show = shows[ showsById[slot.showId] ]; + if (slot.showId.startsWith("redirect.")) { + return { + isOffline: true, + type: "redirect", + duration: remaining, + channel: show.channel, + } + } else if (slot.order === 'shuffle') { + return getShowShuffler(show).current(); + } else if (slot.order === 'next') { + return getShowOrderer(show).current(); + } + } + + function advanceSlot(slot) { + if ( (slot.showId === "flex.") || (slot.showId.startsWith("redirect") ) ) { + return; + } + let show = shows[ showsById[slot.showId] ]; + if (slot.order === 'shuffle') { + return getShowShuffler(show).next(); + } else if (slot.order === 'next') { + return getShowOrderer(show).next(); + } + } + + function makePadded(item) { + let padOption = schedule.pad; + if (schedule.padStyle === "slot") { + padOption = 1; + } + let x = item.duration; + let m = x % padOption; + let f = 0; + if ( (m > constants.SLACK) && (padOption - m > constants.SLACK) ) { + f = padOption - m; + } + return { + item: item, + pad: f, + totalDuration: item.duration + f, + } + + } + + // load the programs + for (let i = 0; i < programs.length; i++) { + let p = programs[i]; + let show = getShow(p); + if (show != null) { + if (typeof(showsById[show.id] ) === 'undefined') { + showsById[show.id] = shows.length; + shows.push( show ); + show.founder = p; + show.programs = []; + } else { + show = shows[ showsById[show.id] ]; + } + addProgramToShow( show, p ); + } + } + + let s = schedule.slots; + let ts = (new Date() ).getTime(); + + let t0 = ts; + let p = []; + let t = t0; + + let hardLimit = t0 + schedule.maxDays * DAY; + + let pushFlex = (d) => { + if (d > 0) { + t += d; + if ( (p.length > 0) && p[p.length-1].isOffline && (p[p.length-1].type != 'redirect') ) { + p[p.length-1].duration += d; + } else { + p.push( { + duration: d, + isOffline : true, + } ); + } + } + } + + let pushProgram = (item) => { + if ( item.isOffline && (item.type !== 'redirect') ) { + pushFlex(item.duration); + } else { + p.push(item); + t += item.duration; + } + }; + + let slotLastPlayed = {}; + + while ( (t < hardLimit) && (p.length < LIMIT) ) { + await throttle(); + //ensure t is padded + let m = t % schedule.pad; + if ( (t % schedule.pad > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) { + pushFlex( schedule.pad - m ); + continue; + } + + let slot = null; + let slotIndex = null; + let remaining = null; + + let n = 0; + let minNextTime = t + 24*DAY; + for (let i = 0; i < s.length; i++) { + if ( typeof( slotLastPlayed[i] ) !== undefined ) { + let lastt = slotLastPlayed[i]; + minNextTime = Math.min( minNextTime, lastt + s[i].cooldown ); + if (t - lastt < s[i].cooldown - constants.SLACK ) { + continue; + } + } + n += s[i].weight; + if ( random.bool(s[i].weight,n) ) { + slot = s[i]; + slotIndex = i; + remaining = s[i].duration; + } + } + if (slot == null) { + //Nothing to play, likely due to cooldown + pushFlex( minNextTime - t); + continue; + } + let item = getNextForSlot(slot, remaining); + + if (item.isOffline) { + //flex or redirect. We can just use the whole duration + item.duration = remaining; + pushProgram(item); + slotLastPlayed[ slotIndex ] = t; + continue; + } + if (item.duration > remaining) { + // Slide + pushProgram(item); + slotLastPlayed[ slotIndex ] = t; + advanceSlot(slot); + continue; + } + + let padded = makePadded(item); + let total = padded.totalDuration; + advanceSlot(slot); + let pads = [ padded ]; + + while(true) { + let item2 = getNextForSlot(slot); + if (total + item2.duration > remaining) { + break; + } + let padded2 = makePadded(item2); + pads.push(padded2); + advanceSlot(slot); + total += padded2.totalDuration; + } + let temt = t + total; + let rem = 0; + if ( + (temt % schedule.pad >= constants.SLACK) + && (temt % schedule.pad < schedule.pad - constants.SLACK) + ) { + rem = schedule.pad - temt % schedule.pad; + } + + + if (flexBetween && (schedule.padStyle === 'episode') ) { + let div = Math.floor(rem / schedule.pad ); + let mod = rem % schedule.pad; + // add mod to the latest item + pads[ pads.length - 1].pad += mod; + pads[ pads.length - 1].totalDuration += mod; + + let sortedPads = pads.map( (p, $index) => { + return { + pad: p.pad, + index : $index, + } + }); + sortedPads.sort( (a,b) => { return a.pad - b.pad; } ); + for (let i = 0; i < pads.length; i++) { + let q = Math.floor( div / pads.length ); + if (i < div % pads.length) { + q++; + } + let j = sortedPads[i].index; + pads[j].pad += q * schedule.pad; + } + } else if (flexBetween) { + //just distribute it equitatively + let div = rem / pads.length; + for (let i = 0; i < pads.length; i++) { + pads[i].pad += div; + } + } else { + //also add div to the latest item + pads[ pads.length - 1].pad += rem; + pads[ pads.length - 1].totalDuration += rem; + } + // now unroll them all + for (let i = 0; i < pads.length; i++) { + pushProgram( pads[i].item ); + slotLastPlayed[ slotIndex ] = t; + pushFlex( pads[i].pad ); + } + } + while ( (t > hardLimit) || (p.length >= LIMIT) ) { + t -= p.pop().duration; + } + let m = (t - t0) % schedule.period; + if (m != 0) { + //ensure the schedule is a multiple of period + pushFlex( schedule.period - m); + } + + + return { + programs: p, + startTime: (new Date(t0)).toISOString(), + } + +} + + + + diff --git a/src/services/time-slots-service.js b/src/services/time-slots-service.js index 9868ea2..64b6115 100644 --- a/src/services/time-slots-service.js +++ b/src/services/time-slots-service.js @@ -1,5 +1,7 @@ const constants = require("../constants"); + +const getShowData = require("./get-show-data")(); const random = require('../helperFuncs').random; const MINUTE = 60*1000; @@ -7,34 +9,18 @@ const DAY = 24*60*MINUTE; const LIMIT = 40000; - -//This is a triplicate code, but maybe it doesn't have to be? function getShow(program) { - //used for equalize and frequency tweak - if (program.isOffline) { - if (program.type == 'redirect') { - return { - description : `Redirect to channel ${program.channel}`, - id: "redirect." + program.channel, - channel: program.channel, - } - } else { - return null; - } - } else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) { - return { - description: program.showTitle, - id: "tv." + program.showTitle, - } + + let d = getShowData(program); + if (! d.hasShow) { + return null; } else { - return { - description: "Movies", - id: "movie.", - } + d.description = d.showDisplayName; + d.id = d.showId; + return d; } } - function shuffle(array, lo, hi ) { if (typeof(lo) === 'undefined') { lo = 0; @@ -86,19 +72,9 @@ function getShowOrderer(show) { let sortedPrograms = JSON.parse( JSON.stringify(show.programs) ); sortedPrograms.sort((a, b) => { - if (a.season === b.season) { - if (a.episode > b.episode) { - return 1 - } else { - return -1 - } - } else if (a.season > b.season) { - return 1; - } else if (b.season > a.season) { - return -1; - } else { - return 0 - } + let showA = getShowData(a); + let showB = getShowData(b); + return showA.order - showB.order; }); let position = 0; @@ -106,9 +82,9 @@ function getShowOrderer(show) { (position + 1 < sortedPrograms.length ) && ( - show.founder.season !== sortedPrograms[position].season - || - show.founder.episode !== sortedPrograms[position].episode + getShowData(show.founder).order + !== + getShowData(sortedPrograms[position]).order ) ) { position++; @@ -177,6 +153,9 @@ module.exports = async( programs, schedule ) => { if (! Array.isArray(schedule.slots) ) { return { userError: 'Expected a "slots" array in schedule' }; } + if (typeof(schedule).period === 'undefined') { + schedule.period = DAY; + } for (let i = 0; i < schedule.slots.length; i++) { if (typeof(schedule.slots[i].time) === 'undefined') { return { userError: "Each slot should have a time" }; @@ -186,12 +165,12 @@ module.exports = async( programs, schedule ) => { } if ( (schedule.slots[i].time < 0) - || (schedule.slots[i].time >= DAY) + || (schedule.slots[i].time >= schedule.period) || (Math.floor(schedule.slots[i].time) != schedule.slots[i].time) ) { - return { userError: "Slot times should be a integer number of milliseconds since the start of the day." }; + return { userError: "Slot times should be a integer number of milliseconds between 0 and period-1, inclusive" }; } - schedule.slots[i].time = ( schedule.slots[i].time + 10*DAY + schedule.timeZoneOffset*MINUTE) % DAY; + schedule.slots[i].time = ( schedule.slots[i].time + 10*schedule.period + schedule.timeZoneOffset*MINUTE) % schedule.period; } schedule.slots.sort( (a,b) => { return (a.time - b.time); @@ -241,6 +220,7 @@ module.exports = async( programs, schedule ) => { } } let show = shows[ showsById[slot.showId] ]; + if (slot.showId.startsWith("redirect.")) { return { isOffline: true, @@ -256,7 +236,7 @@ module.exports = async( programs, schedule ) => { } function advanceSlot(slot) { - if ( (slot.showId === "flex.") || (slot.showId.startsWith("redirect") ) ) { + if ( (slot.showId === "flex.") || (slot.showId.startsWith("redirect.") ) ) { return; } let show = shows[ showsById[slot.showId] ]; @@ -300,16 +280,12 @@ module.exports = async( programs, schedule ) => { } let s = schedule.slots; - let d = (new Date() ); - d.setUTCMilliseconds(0); - d.setUTCSeconds(0); - d.setUTCMinutes(0); - d.setUTCHours(0); - d.setUTCMilliseconds( s[0].time ); - let t0 = d.getTime(); + let ts = (new Date() ).getTime(); + let curr = ts - ts % (schedule.period); + let t0 = curr + s[0].time; let p = []; let t = t0; - let wantedFinish = t % DAY; + let wantedFinish = t % schedule.period; let hardLimit = t0 + schedule.maxDays * DAY; let pushFlex = (d) => { @@ -326,6 +302,18 @@ module.exports = async( programs, schedule ) => { } } + let pushProgram = (item) => { + if ( item.isOffline && (item.type !== 'redirect') ) { + pushFlex(item.duration); + } else { + p.push(item); + t += item.duration; + } + }; + + if (ts > t0) { + pushFlex( ts - t0 ); + } while ( (t < hardLimit) && (p.length < LIMIT) ) { await throttle(); //ensure t is padded @@ -335,14 +323,14 @@ module.exports = async( programs, schedule ) => { continue; } - let dayTime = t % DAY; + let dayTime = t % schedule.period; let slot = null; let remaining = null; let late = null; for (let i = 0; i < s.length; i++) { let endTime; if (i == s.length - 1) { - endTime = s[0].time + DAY; + endTime = s[0].time + schedule.period; } else { endTime = s[i+1].time; } @@ -353,11 +341,11 @@ module.exports = async( programs, schedule ) => { late = dayTime - s[i].time; break; } - if ((s[i].time <= dayTime + DAY) && (dayTime + DAY < endTime)) { + if ((s[i].time <= dayTime + schedule.period) && (dayTime + schedule.period < endTime)) { slot = s[i]; - dayTime += DAY; + dayTime += schedule.period; remaining = endTime - dayTime; - late = dayTime + DAY - s[i].time; + late = dayTime + schedule.period - s[i].time; break; } } @@ -376,14 +364,13 @@ module.exports = async( programs, schedule ) => { if (item.isOffline) { //flex or redirect. We can just use the whole duration - p.push(item); - t += remaining; + item.duration = remaining; + pushProgram(item); continue; } if (item.duration > remaining) { // Slide - p.push(item); - t += item.duration; + pushProgram(item); advanceSlot(slot); continue; } @@ -394,7 +381,7 @@ module.exports = async( programs, schedule ) => { let pads = [ padded ]; while(true) { - let item2 = getNextForSlot(slot); + let item2 = getNextForSlot(slot, remaining); if (total + item2.duration > remaining) { break; } @@ -434,23 +421,17 @@ module.exports = async( programs, schedule ) => { } // now unroll them all for (let i = 0; i < pads.length; i++) { - p.push( pads[i].item ); - t += pads[i].item.duration; + pushProgram( pads[i].item ); pushFlex( pads[i].pad ); } } while ( (t > hardLimit) || (p.length >= LIMIT) ) { t -= p.pop().duration; } - let m = t % DAY; - let rem = 0; - if (m > wantedFinish) { - rem = DAY + wantedFinish - m; - } else if (m < wantedFinish) { - rem = wantedFinish - m; - } - if (rem > constants.SLACK) { - pushFlex(rem); + let m = (t - t0) % schedule.period; + if (m > 0) { + //ensure the schedule is a multiple of period + pushFlex( schedule.period - m); } diff --git a/src/svg/generic-music-screen.svg b/src/svg/generic-music-screen.svg new file mode 100644 index 0000000..8ba273b --- /dev/null +++ b/src/svg/generic-music-screen.svg @@ -0,0 +1,115 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/src/tv-guide-service.js b/src/tv-guide-service.js index 9227827..84027be 100644 --- a/src/tv-guide-service.js +++ b/src/tv-guide-service.js @@ -7,24 +7,24 @@ class TVGuideService /**** * **/ - constructor(xmltv, db) { + constructor(xmltv, db, cacheImageService, eventService) { this.cached = null; this.lastUpdate = 0; this.updateTime = 0; this.currentUpdate = -1; this.currentLimit = -1; this.currentChannels = null; - this.throttleX = 0; - this.doThrottle = false; this.xmltv = xmltv; this.db = db; + this.cacheImageService = cacheImageService; + this.eventService = eventService; } async get() { while (this.cached == null) { await _wait(100); } - this.doThrottle = true; + return this.cached; } @@ -43,6 +43,19 @@ class TVGuideService this.currentUpdate = this.updateTime; this.currentLimit = this.updateLimit; this.currentChannels = this.updateChannels; + let t = "" + ( (new Date()) ); + eventService.push( + "xmltv", + { + "message": `Started building tv-guide at = ${t}`, + "module" : "xmltv", + "detail" : { + "time": new Date(), + }, + "level" : "info" + } + ); + await this.buildIt(); } await _wait(100); @@ -342,16 +355,28 @@ class TVGuideService } } - async _throttle() { - //this.doThrottle = true; - if ( this.doThrottle && (this.throttleX++)%10 == 0) { - await _wait(0); - } + _throttle() { + return new Promise((resolve) => { + setImmediate(() => resolve()); + }); } async refreshXML() { let xmltvSettings = this.db['xmltv-settings'].find()[0]; - await this.xmltv.WriteXMLTV(this.cached, xmltvSettings, async() => await this._throttle() ); + await this.xmltv.WriteXMLTV(this.cached, xmltvSettings, async() => await this._throttle(), this.cacheImageService); + let t = "" + ( (new Date()) ); + eventService.push( + "xmltv", + { + "message": `XMLTV updated at server time = ${t}`, + "module" : "xmltv", + "detail" : { + "time": new Date(), + }, + "level" : "info" + } + ); + } async getStatus() { diff --git a/src/video.js b/src/video.js index d82073d..6cf31f6 100644 --- a/src/video.js +++ b/src/video.js @@ -45,7 +45,7 @@ function video( channelDB , fillerDB, db) { }) }) // Continuously stream video to client. Leverage ffmpeg concat for piecing together videos - router.get('/video', async (req, res) => { + let concat = async (req, res, audioOnly) => { // Check if channel queried is valid if (typeof req.query.channel === 'undefined') { res.status(500).send("No Channel Specified") @@ -75,6 +75,7 @@ function video( channelDB , fillerDB, db) { console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`) let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + ffmpeg.setAudioOnly(audioOnly); let stopped = false; function stop() { @@ -109,16 +110,29 @@ function video( channelDB , fillerDB, db) { }) let channelNum = parseInt(req.query.channel, 10) - let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}`); + let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}&audioOnly=${audioOnly}`); ff.pipe(res ); - }) + }; + router.get('/video', async(req, res) => { + return await concat(req, res, false); + } ); + router.get('/radio', async(req, res) => { + return await concat(req, res, true); + } ); + // Stream individual video to ffmpeg concat above. This is used by the server, NOT the client router.get('/stream', async (req, res) => { // Check if channel queried is valid + res.on("error", (e) => { + console.error("There was an unexpected error in stream.", e); + } ); if (typeof req.query.channel === 'undefined') { res.status(400).send("No Channel Specified") return } + + let audioOnly = ("true" == req.query.audioOnly); + console.log(`/stream audioOnly=${audioOnly}`); let session = parseInt(req.query.session); let m3u8 = (req.query.m3u8 === '1'); let number = parseInt(req.query.channel); @@ -296,6 +310,7 @@ function video( channelDB , fillerDB, db) { channel: combinedChannel, db: db, m3u8: m3u8, + audioOnly : audioOnly, } let player = new ProgramPlayer(playerContext); @@ -312,6 +327,7 @@ function video( channelDB , fillerDB, db) { res.writeHead(200, { 'Content-Type': 'video/mp2t' }); + try { playerObj = await player.play(res); } catch (err) { @@ -416,6 +432,7 @@ function video( channelDB , fillerDB, db) { let ffmpegSettings = db['ffmpeg-settings'].find()[0] let sessionId = StreamCount++; + let audioOnly = ("true" == req.query.audioOnly); if ( (ffmpegSettings.enableFFMPEGTranscoding === true) @@ -423,36 +440,68 @@ function video( channelDB , fillerDB, db) { && (ffmpegSettings.normalizeAudioCodec === true) && (ffmpegSettings.normalizeResolution === true) && (ffmpegSettings.normalizeAudio === true) + && (audioOnly !== true) /* loading screen is pointless in audio mode (also for some reason it makes it fail when codec is aac, and I can't figure out why) */ ) { - data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=0&session=${sessionId}'\n`; + //loading screen + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=0&session=${sessionId}&audioOnly=${audioOnly}'\n`; } - data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=1&session=${sessionId}'\n` + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=1&session=${sessionId}&audioOnly=${audioOnly}'\n` for (var i = 0; i < maxStreamsToPlayInARow - 1; i++) { - data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&session=${sessionId}'\n` + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&session=${sessionId}&audioOnly=${audioOnly}'\n` } res.send(data) }) + + let mediaPlayer = async(channelNum, path, req, res) => { + let channel = await channelCache.getChannelConfig(channelDB, channelNum ); + if (channel.length === 0) { + res.status(404).send("Channel not found."); + return; + } + res.type('video/x-mpegurl'); + res.status(200).send(`#EXTM3U\n${req.protocol}://${req.get('host')}/${path}?channel=${channelNum}\n\n`); + } + router.get('/media-player/:number.m3u', async (req, res) => { try { let channelNum = parseInt(req.params.number, 10); - let channel = await channelCache.getChannelConfig(channelDB, channelNum ); - if (channel.length === 0) { - res.status(404).send("Channel not found."); - return; - } - res.type('video/x-mpegurl'); let path ="video"; if (req.query.fast==="1") { path ="m3u8"; } - res.status(200).send(`#EXTM3U\n${req.protocol}://${req.get('host')}/${path}?channel=${channelNum}\n\n`); + return await mediaPlayer(channelNum, path, req, res); } catch(err) { console.error(err); res.status(500).send("There was an error."); } }); + + router.get('/media-player/fast/:number.m3u', async (req, res) => { + try { + let channelNum = parseInt(req.params.number, 10); + let path ="m3u8"; + return await mediaPlayer(channelNum, path, req, res); + } catch(err) { + console.error(err); + res.status(500).send("There was an error."); + } + }); + + router.get('/media-player/radio/:number.m3u', async (req, res) => { + try { + let channelNum = parseInt(req.params.number, 10); + let path ="radio"; + return await mediaPlayer(channelNum, path, req, res); + } catch(err) { + console.error(err); + res.status(500).send("There was an error."); + } + }); + + + return router } diff --git a/src/xmltv.js b/src/xmltv.js index 5cd8f9f..5ca97cf 100644 --- a/src/xmltv.js +++ b/src/xmltv.js @@ -6,7 +6,7 @@ module.exports = { WriteXMLTV: WriteXMLTV, shutdown: shutdown } let isShutdown = false; let isWorking = false; -async function WriteXMLTV(json, xmlSettings, throttle ) { +async function WriteXMLTV(json, xmlSettings, throttle, cacheImageService) { if (isShutdown) { return; } @@ -16,14 +16,14 @@ async function WriteXMLTV(json, xmlSettings, throttle ) { } isWorking = true; try { - await writePromise(json, xmlSettings, throttle); + await writePromise(json, xmlSettings, throttle, cacheImageService); } catch (err) { console.error("Error writing xmltv", err); } isWorking = false; } -function writePromise(json, xmlSettings, throttle) { +function writePromise(json, xmlSettings, throttle, cacheImageService) { return new Promise((resolve, reject) => { let ws = fs.createWriteStream(xmlSettings.file) let xw = new XMLWriter(true, (str, enc) => ws.write(str, enc)) @@ -37,7 +37,7 @@ function writePromise(json, xmlSettings, throttle) { _writeChannels( xw, channels ); for (let i = 0; i < channelNumbers.length; i++) { let number = channelNumbers[i]; - await _writePrograms(xw, json[number].channel, json[number].programs, throttle); + await _writePrograms(xw, json[number].channel, json[number].programs, throttle, xmlSettings, cacheImageService); } } middle().then( () => { @@ -75,16 +75,16 @@ function _writeChannels(xw, channels) { } } -async function _writePrograms(xw, channel, programs, throttle) { +async function _writePrograms(xw, channel, programs, throttle, xmlSettings, cacheImageService) { for (let i = 0; i < programs.length; i++) { if (! isShutdown) { await throttle(); } - await _writeProgramme(channel, programs[i], xw); + await _writeProgramme(channel, programs[i], xw, xmlSettings, cacheImageService); } } -async function _writeProgramme(channel, program, xw) { +async function _writeProgramme(channel, program, xw, xmlSettings, cacheImageService) { // Programme xw.startElement('programme') xw.writeAttribute('start', _createXMLTVDate(program.start)) @@ -117,9 +117,14 @@ async function _writeProgramme(channel, program, xw) { } // Icon if (typeof program.icon !== 'undefined') { - xw.startElement('icon') - xw.writeAttribute('src', program.icon) - xw.endElement() + xw.startElement('icon'); + let icon = program.icon; + if (xmlSettings.enableImageCache === true) { + const imgUrl = cacheImageService.registerImageOnDatabase(icon); + icon = `{{host}}/cache/images/${imgUrl}`; + } + xw.writeAttribute('src', icon); + xw.endElement(); } // Desc xw.startElement('desc') diff --git a/web/app.js b/web/app.js index af58820..ca21a73 100644 --- a/web/app.js +++ b/web/app.js @@ -10,6 +10,8 @@ var app = angular.module('myApp', ['ngRoute', 'vs-repeat', 'angularLazyImg', 'dn app.service('plex', require('./services/plex')) app.service('dizquetv', require('./services/dizquetv')) app.service('resolutionOptions', require('./services/resolution-options')) +app.service('getShowData', require('./services/get-show-data')) +app.service('commonProgramTools', require('./services/common-program-tools')) app.directive('plexSettings', require('./directives/plex-settings')) app.directive('ffmpegSettings', require('./directives/ffmpeg-settings')) @@ -18,7 +20,10 @@ app.directive('hdhrSettings', require('./directives/hdhr-settings')) app.directive('plexLibrary', require('./directives/plex-library')) app.directive('programConfig', require('./directives/program-config')) app.directive('flexConfig', require('./directives/flex-config')) +app.directive('timeSlotsTimeEditor', require('./directives/time-slots-time-editor')) +app.directive('toastNotifications', require('./directives/toast-notifications')) app.directive('fillerConfig', require('./directives/filler-config')) +app.directive('showConfig', require('./directives/show-config')) app.directive('deleteFiller', require('./directives/delete-filler')) app.directive('frequencyTweak', require('./directives/frequency-tweak')) app.directive('removeShows', require('./directives/remove-shows')) @@ -26,12 +31,16 @@ app.directive('channelRedirect', require('./directives/channel-redirect')) app.directive('plexServerEdit', require('./directives/plex-server-edit')) app.directive('channelConfig', require('./directives/channel-config')) app.directive('timeSlotsScheduleEditor', require('./directives/time-slots-schedule-editor')) +app.directive('randomSlotsScheduleEditor', require('./directives/random-slots-schedule-editor')) app.controller('settingsCtrl', require('./controllers/settings')) app.controller('channelsCtrl', require('./controllers/channels')) app.controller('versionCtrl', require('./controllers/version')) +app.controller('libraryCtrl', require('./controllers/library')) app.controller('guideCtrl', require('./controllers/guide')) +app.controller('playerCtrl', require('./controllers/player')) app.controller('fillerCtrl', require('./controllers/filler')) +app.controller('customShowsCtrl', require('./controllers/custom-shows')) app.config(function ($routeProvider) { $routeProvider @@ -47,15 +56,27 @@ app.config(function ($routeProvider) { templateUrl: "views/filler.html", controller: 'fillerCtrl' }) + .when("/custom-shows", { + templateUrl: "views/custom-shows.html", + controller: 'customShowsCtrl' + }) + .when("/library", { + templateUrl: "views/library.html", + controller: 'libraryCtrl' + }) .when("/guide", { templateUrl: "views/guide.html", controller: 'guideCtrl' }) + .when("/player", { + templateUrl: "views/player.html", + controller: 'playerCtrl' + }) .when("/version", { templateUrl: "views/version.html", controller: 'versionCtrl' }) .otherwise({ - redirectTo: "channels" + redirectTo: "guide" }) }) \ No newline at end of file diff --git a/web/controllers/channels.js b/web/controllers/channels.js index c9fa27f..1cc861d 100644 --- a/web/controllers/channels.js +++ b/web/controllers/channels.js @@ -80,7 +80,12 @@ module.exports = function ($scope, dizquetv) { $scope.showChannelConfig = true } else { $scope.channels[index].pending = true; - let ch = await dizquetv.getChannel($scope.channels[index].number); + let p = await Promise.all([ + dizquetv.getChannelProgramless($scope.channels[index].number), + dizquetv.getChannelPrograms($scope.channels[index].number), + ]); + let ch = p[0]; + ch.programs = p[1]; let newObj = ch; newObj.startTime = new Date(newObj.startTime) $scope.originalChannelNumber = newObj.number; diff --git a/web/controllers/custom-shows.js b/web/controllers/custom-shows.js new file mode 100644 index 0000000..d3b9378 --- /dev/null +++ b/web/controllers/custom-shows.js @@ -0,0 +1,90 @@ +module.exports = function ($scope, $timeout, dizquetv) { + $scope.showss = [] + $scope.showShowConfig = false + $scope.selectedShow = null + $scope.selectedShowIndex = -1 + + $scope.refreshShow = async () => { + $scope.shows = [ { id: '?', pending: true} ] + $timeout(); + let shows = await dizquetv.getAllShowsInfo(); + $scope.shows = shows; + $timeout(); + } + $scope.refreshShow(); + + + + let feedToShowConfig = () => {}; + let feedToDeleteShow = feedToShowConfig; + + $scope.registerShowConfig = (feed) => { + feedToShowConfig = feed; + } + + $scope.registerDeleteShow = (feed) => { + feedToDeleteShow = feed; + } + + $scope.queryChannel = async (index, channel) => { + let ch = await dizquetv.getChannelDescription(channel.number); + ch.pending = false; + $scope.shows[index] = ch; + $scope.$apply(); + } + + $scope.onShowConfigDone = async (show) => { + if ($scope.selectedChannelIndex != -1) { + $scope.shows[ $scope.selectedChannelIndex ].pending = false; + } + if (typeof show !== 'undefined') { + // not canceled + if ($scope.selectedChannelIndex == -1) { // add new channel + await dizquetv.createShow(show); + } else { + $scope.shows[ $scope.selectedChannelIndex ].pending = true; + await dizquetv.updateShow(show.id, show); + } + await $scope.refreshShow(); + } + } + $scope.selectShow = async (index) => { + try { + if ( (index != -1) && $scope.shows[index].pending) { + return; + } + $scope.selectedChannelIndex = index; + if (index === -1) { + feedToShowConfig(); + } else { + $scope.shows[index].pending = true; + let f = await dizquetv.getShow($scope.shows[index].id); + feedToShowConfig(f); + $timeout(); + } + } catch( err ) { + console.error("Could not fetch show.", err); + } + } + + $scope.deleteShow = async (index) => { + try { + if ( $scope.shows[index].pending) { + return; + } + let show = $scope.shows[index]; + if (confirm("Are you sure to delete show: " + show.name + "? This will NOT delete the show's programs from channels that are using.")) { + show.pending = true; + await dizquetv.deleteShow(show.id); + $timeout(); + await $scope.refreshShow(); + $timeout(); + } + + } catch (err) { + console.error("Could not delete show.", err); + } + + } + +} \ No newline at end of file diff --git a/web/controllers/guide.js b/web/controllers/guide.js index aa65776..7098b73 100644 --- a/web/controllers/guide.js +++ b/web/controllers/guide.js @@ -287,6 +287,7 @@ module.exports = function ($scope, $timeout, dizquetv) { $scope.enableNext = true; } let subTitle = undefined; + let episodeTitle = undefined; let altTitle = hourMinute(ad) + "-" + hourMinute(bd); if (typeof(program.title) !== 'undefined') { altTitle = altTitle + " · " + program.title; @@ -303,6 +304,7 @@ module.exports = function ($scope, $timeout, dizquetv) { } subTitle = `S${ps} · E${pe}`; altTitle = altTitle + " " + subTitle; + episodeTitle = program.sub.title; } else if ( typeof(program.date) === 'undefined' ) { subTitle = '.'; } else { @@ -313,6 +315,7 @@ module.exports = function ($scope, $timeout, dizquetv) { altTitle: altTitle, showTitle: program.title, subTitle: subTitle, + episodeTitle : episodeTitle, start: hasStart, end: hasStop, } ); diff --git a/web/controllers/library.js b/web/controllers/library.js new file mode 100644 index 0000000..264c31a --- /dev/null +++ b/web/controllers/library.js @@ -0,0 +1,2 @@ +module.exports = function () { +} \ No newline at end of file diff --git a/web/controllers/player.js b/web/controllers/player.js new file mode 100644 index 0000000..c591fd8 --- /dev/null +++ b/web/controllers/player.js @@ -0,0 +1,73 @@ +module.exports = function ($scope, dizquetv, $timeout) { + + $scope.loading = true; + $scope.channelOptions = [ + { id: undefined, description: "Select a channel" }, + ]; + $scope.icons = {}; + + + $scope.endpointOptions = [ + { id: "video", description: "/video - Channel mpegts" }, + { id: "m3u8", description: "/m3u8 - Playlist of individual videos" }, + { id: "radio", description: "/radio - Audio-only channel mpegts" }, + ]; + $scope.selectedEndpoint = "video"; + $scope.channel = undefined; + + $scope.endpointButtonHref = () => { + if ( $scope.selectedEndpoint == "video") { + return `./media-player/${$scope.channel}.m3u` + } else if ( $scope.selectedEndpoint == "m3u8") { + return `./media-player/fast/${$scope.channel}.m3u` + } else if ( $scope.selectedEndpoint == "radio") { + return `./media-player/radio/${$scope.channel}.m3u` + } + } + + $scope.buttonDisabled = () => { + return typeof($scope.channel) === 'undefined'; + } + + $scope.endpoint = () => { + if ( typeof($scope.channel) === 'undefined' ) { + return "--" + } + let path = ""; + if ( $scope.selectedEndpoint == "video") { + path = `/video?channel=${$scope.channel}` + } else if ( $scope.selectedEndpoint == "m3u8") { + path = `/m3u8?channel=${$scope.channel}` + } else if ( $scope.selectedEndpoint == "radio") { + path= `/radio?channel=${$scope.channel}` + } + return window.location.href.replace("/#!/player", path); + } + + let loadChannels = async() => { + let channelNumbers = await dizquetv.getChannelNumbers(); + try { + await Promise.all( channelNumbers.map( async(x) => { + let desc = await dizquetv.getChannelDescription(x); + let option = { + id: x, + description: `${x} - ${desc.name}`, + }; + $scope.channelOptions.push( option ); + $scope.icons[x] = desc.icon; + }) ); + $scope.channelOptions.sort( (a,b) => { + let za = ( (typeof(a.id) === undefined)?-1:a.id); + let zb = ( (typeof(b.id) === undefined)?-1:b.id); + return za - zb; + } ); + $scope.loading = false; + $scope.$apply(); + } catch (err) { + console.error(err); + } + $timeout( () => $scope.$apply(), 0); + } + + loadChannels(); +} \ No newline at end of file diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index 467f37f..9885f29 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -1,4 +1,4 @@ -module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { +module.exports = function ($timeout, $location, dizquetv, resolutionOptions, getShowData, commonProgramTools) { return { restrict: 'E', templateUrl: 'templates/channel-config.html', @@ -60,6 +60,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { scope.channel.guideMinimumDurationSeconds = 5 * 60; scope.isNewChannel = true scope.channel.icon = `${$location.protocol()}://${location.host}/images/dizquetv.png` + scope.channel.groupTitle = "dizqueTV"; scope.channel.disableFillerOverlay = true; scope.channel.iconWidth = 120 scope.channel.iconDuration = 60 @@ -95,6 +96,14 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { scope.channel.watermark = defaultWatermark(); } + if ( + (typeof(scope.channel.groupTitle) === 'undefined') + || + (scope.channel.groupTitle === '') + ) { + scope.channel.groupTitle = "dizqueTV"; + } + if (typeof(scope.channel.fillerRepeatCooldown) === 'undefined') { scope.channel.fillerRepeatCooldown = 30 * 60 * 1000; } @@ -154,7 +163,15 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { let t = Date.now(); let originalStart = scope.channel.startTime.getTime(); let n = scope.channel.programs.length; - let totalDuration = scope.channel.duration; + //scope.channel.totalDuration might not have been initialized + let totalDuration = 0; + for (let i = 0; i < n; i++) { + totalDuration += scope.channel.programs[i].duration; + } + if (totalDuration == 0) { + return; + } + let m = (t - originalStart) % totalDuration; let x = 0; let runningProgram = -1; @@ -284,39 +301,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { }) scope.sortShows = () => { scope.removeOffline(); - let shows = {} - let movies = [] - let newProgs = [] - let progs = scope.channel.programs - for (let i = 0, l = progs.length; i < l; i++) { - if ( progs[i].isOffline || (progs[i].type === 'movie') ) { - movies.push(progs[i]) - } else { - if (typeof shows[progs[i].showTitle] === 'undefined') - shows[progs[i].showTitle] = [] - shows[progs[i].showTitle].push(progs[i]) - } - } - let keys = Object.keys(shows) - for (let i = 0, l = keys.length; i < l; i++) { - shows[keys[i]].sort((a, b) => { - if (a.season === b.season) { - if (a.episode > b.episode) { - return 1 - } else { - return -1 - } - } else if (a.season > b.season) { - return 1; - } else if (b.season > a.season) { - return -1; - } else { - return 0 - } - }) - newProgs = newProgs.concat(shows[keys[i]]) - } - scope.channel.programs = newProgs.concat(movies) + scope.channel.programs = commonProgramTools.sortShows(scope.channel.programs); updateChannelDuration() } scope.dateForGuide = (date) => { @@ -336,43 +321,9 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { } scope.sortByDate = () => { scope.removeOffline(); - scope.channel.programs.sort( (a,b) => { - let aHas = ( typeof(a.date) !== 'undefined' ); - let bHas = ( typeof(b.date) !== 'undefined' ); - if (!aHas && !bHas) { - return 0; - } else if (! aHas) { - return 1; - } else if (! bHas) { - return -1; - } - if (a.date < b.date ) { - return -1; - } else if (a.date > b.date) { - return 1; - } else { - let aHasSeason = ( typeof(a.season) !== 'undefined' ); - let bHasSeason = ( typeof(b.season) !== 'undefined' ); - if (! aHasSeason && ! bHasSeason) { - return 0; - } else if (! aHasSeason) { - return 1; - } else if (! bHasSeason) { - return -1; - } - if (a.season < b.season) { - return -1; - } else if (a.season > b.season) { - return 1; - } else if (a.episode < b.episode) { - return -1; - } else if (a.episode > b.episode) { - return 1; - } else { - return 0; - } - } - }); + scope.channel.programs = commonProgramTools.sortByDate( + scope.channel.programs + ); updateChannelDuration() } scope.slideAllPrograms = (offset) => { @@ -388,26 +339,8 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { adjustStartTimeToCurrentProgram(); updateChannelDuration(); } - let removeDuplicatesSub = (progs) => { - let tmpProgs = {} - for (let i = 0, l = progs.length; i < l; i++) { - if ( progs[i].type ==='redirect' ) { - tmpProgs['_redirect ' + progs[i].channel + ' _ '+ progs[i].duration ] = progs[i]; - } else if (progs[i].type === 'movie') { - tmpProgs[progs[i].title + progs[i].durationStr] = progs[i] - } else { - tmpProgs[progs[i].showTitle + '-' + progs[i].season + '-' + progs[i].episode] = progs[i] - } - } - let newProgs = [] - let keys = Object.keys(tmpProgs) - for (let i = 0, l = keys.length; i < l; i++) { - newProgs.push(tmpProgs[keys[i]]) - } - return newProgs; - } scope.removeDuplicates = () => { - scope.channel.programs = removeDuplicatesSub(scope.channel.programs); + scope.channel.programs = commonProgramTools.removeDuplicates(scope.channel.programs); updateChannelDuration(); //oops someone forgot to add this } scope.removeOffline = () => { @@ -423,42 +356,37 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { } scope.wipeSpecials = () => { - let tmpProgs = [] - let progs = scope.channel.programs - for (let i = 0, l = progs.length; i < l; i++) { - if (progs[i].season !== 0) { - tmpProgs.push(progs[i]); - } - } - scope.channel.programs = tmpProgs + scope.channel.programs =commonProgramTools.removeSpecials(scope.channel.programs); updateChannelDuration() } - scope.getShowTitle = (program) => { - if (program.isOffline && program.type == 'redirect') { - return `Redirect to channel ${program.channel}`; - } else { - return program.showTitle; - } - } - scope.startRemoveShows = () => { - scope._removablePrograms = scope.channel.programs - .map(scope.getShowTitle) - .reduce((dedupedArr, showTitle) => { - if (!dedupedArr.includes(showTitle)) { - dedupedArr.push(showTitle) + let seenIds = {}; + let rem = []; + scope.channel.programs + .map( getShowData ) + .filter( data => data.hasShow ) + .forEach( x => { + if ( seenIds[x.showId] !== true) { + seenIds[x.showId] = true; + rem.push( { + id: x.showId, + displayName : x.showDisplayName + } ); } - return dedupedArr - }, []) - .filter(showTitle => !!showTitle); + } ); + scope._removablePrograms = rem; scope._deletedProgramNames = []; } - scope.removeShows = (deletedShowNames) => { + scope.removeShows = (deletedShowIds) => { const p = scope.channel.programs; let set = {}; - deletedShowNames.forEach( (a) => set[a] = true ); - scope.channel.programs = p.filter( (a) => (set[scope.getShowTitle(a)]!==true) ); + deletedShowIds.forEach( (a) => set[a] = true ); + scope.channel.programs = p.filter( (a) => { + let data = getShowData(a); + return ( ! data.hasShow || ! set[ data.showId ] ); + } ); + updateChannelDuration(); } scope.describeFallback = () => { @@ -476,102 +404,15 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { } } - let interpolate = ( () => { - let h = 60*60*1000; - let ix = [0, 1*h, 2*h, 4*h, 8*h, 24*h]; - let iy = [0, 1.0, 1.25, 1.5, 1.75, 2.0]; - let n = ix.length; - - return (x) => { - for (let i = 0; i < n-1; i++) { - if( (ix[i] <= x) && ( (x < ix[i+1]) || i==n-2 ) ) { - return iy[i] + (iy[i+1] - iy[i]) * ( (x - ix[i]) / (ix[i+1] - ix[i]) ); - } - } - } - - } )(); - - scope.programSquareStyle = (program) => { - let background =""; - if ( (program.isOffline) && (program.type !== 'redirect') ) { - background = "rgb(255, 255, 255)"; - } else { - let r = 0, g = 0, b = 0, r2=0, g2=0,b2=0; - let angle = 45; - let w = 3; - if (program.type === 'redirect') { - angle = 0; - w = 4 + (program.channel % 10); - let c = (program.channel * 100019); - //r = 255, g = 0, b = 0; - //r2 = 0, g2 = 0, b2 = 255; - - r = ( (c & 3) * 77 ); - g = ( ( (c >> 1) & 3) * 77 ); - b = ( ( (c >> 2) & 3) * 77 ); - r2 = ( ( (c >> 5) & 3) * 37 ); - g2 = ( ( (c >> 3) & 3) * 37 ); - b2 = ( ( (c >> 4) & 3) * 37 ); - - } else if (program.type === 'episode') { - let h = Math.abs(scope.getHashCode(program.showTitle, false)); - let h2 = Math.abs(scope.getHashCode(program.showTitle, true)); - r = h % 256; - g = (h / 256) % 256; - b = (h / (256*256) ) % 256; - r2 = (h2 / (256*256) ) % 256; - g2 = (h2 / (256*256) ) % 256; - b2 = (h2 / (256*256) ) % 256; - angle = (360 - 90 + h % 180) % 360; - if ( angle >= 350 || angle < 10 ) { - angle += 53; - } - } else { - r = 10, g = 10, b = 10; - r2 = 245, g2 = 245, b2 = 245; - angle = 45; - w = 6; - } - let rgb1 = "rgb("+ r + "," + g + "," + b +")"; - let rgb2 = "rgb("+ r2 + "," + g2 + "," + b2 +")" - angle += 90; - background = "repeating-linear-gradient( " + angle + "deg, " + rgb1 + ", " + rgb1 + " " + w + "px, " + rgb2 + " " + w + "px, " + rgb2 + " " + (w*2) + "px)"; - - } - let f = interpolate; - let w = 15.0; - let t = 4*60*60*1000; - //let d = Math.log( Math.min(t, program.duration) ) / Math.log(2); - //let a = (d * Math.log(2) ) / Math.log(t); - let a = ( f(program.duration) *w) / f(t); - a = Math.min( w, Math.max(0.3, a) ); - b = w - a + 0.01; - - return { - 'width': `${a}%`, - 'height': '1.3em', - 'margin-right': `${b}%`, - 'background': background, - 'border': '1px solid black', - 'margin-top': "0.01em", - 'margin-bottom': '1px', - }; + scope.getProgramDisplayTitle = (x) => { + return commonProgramTools.getProgramDisplayTitle(x); } - scope.getHashCode = (s, rev) => { - var hash = 0; - if (s.length == 0) return hash; - let inc = 1, st = 0, e = s.length; - if (rev) { - inc = -1, st = e - 1, e = -1; - } - for (var i = st; i != e; i+= inc) { - hash = s.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & hash; // Convert to 32bit integer - } - return hash; + + scope.programSquareStyle = (x) => { + return commonProgramTools.programSquareStyle(x); } + scope.doReruns = (rerunStart, rerunBlockSize, rerunRepeats) => { let o =(new Date()).getTimezoneOffset() * 60 * 1000; let start = (o + rerunStart * 60 * 60 * 1000) % (24*60*60*1000); @@ -712,13 +553,11 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { }; let array = scope.channel.programs; for (let i = 0; i < array.length; i++) { - if (array[i].type === 'episode' && array[i].season != 0) { - let key = array[i].showTitle; + let data = getShowData( array[i] ); + if (data.hasShow) { + let key = data.showId; if (typeof(scope.episodeMemory[key]) === 'undefined') { - scope.episodeMemory[key] = { - season: array[i].season, - episode: array[i].episode, - } + scope.episodeMemory[key] = data.order; } } } @@ -733,11 +572,11 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { // some precalculation, useful to stop the shuffle from being quadratic... for (let i = 0; i < array.length; i++) { let vid = array[i]; - if (vid.type === 'episode' && vid.season != 0) { + let data = getShowData(vid); + if (data.hasShow) { let countKey = { - title: vid.showTitle, - s: vid.season, - e: vid.episode, + id: data.showId, + order: data.order, } let key = JSON.stringify(countKey); let c = ( (typeof(counts[key]) === 'undefined') ? 0 : counts[key] ); @@ -746,10 +585,10 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { c: c, it: vid } - if ( typeof(shows[vid.showTitle]) === 'undefined') { - shows[vid.showTitle] = []; + if ( typeof(shows[data.showId]) === 'undefined') { + shows[data.showId] = []; } - shows[vid.showTitle].push(showEntry); + shows[data.showId].push(showEntry); } } //this is O(|N| log|M|) where |N| is the total number of TV @@ -759,15 +598,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { Object.keys(shows).forEach(function(key,index) { shows[key].sort( (a,b) => { if (a.c == b.c) { - if (a.it.season == b.it.season) { - if (a.it.episode == b.it.episode) { - return 0; - } else { - return (a.it.episode < b.it.episode)?-1: 1; - } - } else { - return (a.it.season < b.it.season)?-1: 1; - } + return getShowData(a.it).order - getShowData(b.it).order; } else { return (a.c < b.c)? -1: 1; } @@ -776,8 +607,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { if (typeof(scope.episodeMemory[key]) !== 'undefined') { for (let i = 0; i < shows[key].length; i++) { if ( - (shows[key][i].it.season === scope.episodeMemory[key].season) - &&(shows[key][i].it.episode === scope.episodeMemory[key].episode) + getShowData(shows[key][i].it).order == scope.episodeMemory[key] ) { next[key] = i; break; @@ -786,13 +616,14 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { } }); for (let i = 0; i < array.length; i++) { - if (array[i].type === 'episode' && array[i].season != 0) { - let title = array[i].showTitle; - var sequence = shows[title]; - let j = next[title]; + let data = getShowData( array[i] ); + if (data.hasShow) { + let key = data.showId; + var sequence = shows[key]; + let j = next[key]; array[i] = sequence[j].it; - next[title] = (j + 1) % sequence.length; + next[key] = (j + 1) % sequence.length; } } scope.channel.programs = array; @@ -874,18 +705,23 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { let newProgs = [] let progs = scope.channel.programs for (let i = 0, l = progs.length; i < l; i++) { - if (progs[i].type === 'movie') { + let data = getShowData(progs[i]); + if (! data.hasShow) { + continue; + } else if (data.showId === 'movie.') { movies.push(progs[i]) } else { - if (typeof shows[progs[i].showTitle] === 'undefined') - shows[progs[i].showTitle] = [] - shows[progs[i].showTitle].push(progs[i]) + if (typeof shows[data.showId] === 'undefined') { + shows[data.showId] = []; + } + shows[data.showId].push(progs[i]) } } let keys = Object.keys(shows) let index = 0 - if (randomize) - index = getRandomInt(0, keys.length - 1) + if (randomize) { + index = getRandomInt(0, keys.length - 1); + } while (keys.length > 0) { if (shows[keys[index]].length === 0) { keys.splice(index, 1) @@ -919,12 +755,17 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { updateChannelDuration() } scope.randomShuffle = () => { - shuffle(scope.channel.programs) + commonProgramTools.shuffle(scope.channel.programs); updateChannelDuration() } scope.cyclicShuffle = () => { - cyclicShuffle(scope.channel.programs); - updateChannelDuration(); + // cyclic shuffle can be reproduced by simulating the effects + // of save and recover positions. + let oldSaved = scope.episodeMemory; + commonProgramTools.shuffle(scope.channel.programs); + scope.savePositions(); + scope.recoverPositions(); + scope.episodeMemory = oldSaved; } scope.equalizeShows = () => { scope.removeDuplicates(); @@ -933,9 +774,12 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { } scope.startFrequencyTweak = () => { let programs = {}; + let displayName = {}; for (let i = 0; i < scope.channel.programs.length; i++) { - if ( !scope.channel.programs[i].isOffline || (scope.channel.programs[i].type === 'redirect') ) { - let c = getShowCode(scope.channel.programs[i]); + let data = getShowData( scope.channel.programs[i] ); + if ( data.hasShow ) { + let c = data.showId; + displayName[c] = data.showDisplayName; if ( typeof(programs[c]) === 'undefined') { programs[c] = 0; } @@ -953,11 +797,10 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { name : key, weight: w, specialCategory: false, - displayName: key, + displayName: displayName[key], } - if (key.startsWith("_internal.")) { + if (! key.startsWith("tv.")) { obj.specialCategory = true; - obj.displayName = key.slice("_internal.".length); } arr.push(obj); }); @@ -997,16 +840,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { } function getShowCode(program) { - //used for equalize and frequency tweak - let showName = "_internal.Unknown"; - if ( program.isOffline && (program.type == 'redirect') ) { - showName = `Redirect to channel ${program.channel}`; - } else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) { - showName = program.showTitle; - } else { - showName = "_internal.Movies"; - } - return showName; + return getShowData(program).showId; } function getRandomInt(min, max) { @@ -1014,21 +848,6 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { max = Math.floor(max) return Math.floor(Math.random() * (max - min + 1)) + min } - function shuffle(array, lo, hi ) { - if (typeof(lo) === 'undefined') { - lo = 0; - hi = array.length; - } - let currentIndex = hi, temporaryValue, randomIndex - while (lo !== currentIndex) { - randomIndex = lo + Math.floor(Math.random() * (currentIndex -lo) ); - currentIndex -= 1 - temporaryValue = array[currentIndex] - array[currentIndex] = array[randomIndex] - array[randomIndex] = temporaryValue - } - return array - } function equalizeShows(array, freqObject) { let shows = {}; let progs = []; @@ -1090,79 +909,17 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { updateChannelDuration(); } scope.shuffleReplicate =(t) => { - shuffle( scope.channel.programs ); + commonProgramTools.shuffle( scope.channel.programs ); let n = scope.channel.programs.length; let a = Math.floor(n / 2); scope.replicate(t); for (let i = 0; i < t; i++) { - shuffle( scope.channel.programs, n*i, n*i + a); - shuffle( scope.channel.programs, n*i + a, n*i + n); + commonProgramTools.shuffle( scope.channel.programs, n*i, n*i + a); + commonProgramTools.shuffle( scope.channel.programs, n*i + a, n*i + n); } updateChannelDuration(); } - function cyclicShuffle(array) { - let shows = {}; - let next = {}; - let counts = {}; - // some precalculation, useful to stop the shuffle from being quadratic... - for (let i = 0; i < array.length; i++) { - let vid = array[i]; - if (vid.type === 'episode' && vid.season != 0) { - let countKey = { - title: vid.showTitle, - s: vid.season, - e: vid.episode, - } - let key = JSON.stringify(countKey); - let c = ( (typeof(counts[key]) === 'undefined') ? 0 : counts[key] ); - counts[key] = c + 1; - let showEntry = { - c: c, - it: array[i], - } - if ( typeof(shows[vid.showTitle]) === 'undefined') { - shows[vid.showTitle] = []; - } - shows[vid.showTitle].push(showEntry); - } - } - //this is O(|N| log|M|) where |N| is the total number of TV - // episodes and |M| is the maximum number of episodes - // in a single show. I am pretty sure this is a lower bound - // on the time complexity that's possible here. - Object.keys(shows).forEach(function(key,index) { - shows[key].sort( (a,b) => { - if (a.c == b.c) { - if (a.it.season == b.it.season) { - if (a.it.episode == b.it.episode) { - return 0; - } else { - return (a.it.episode < b.it.episode)?-1: 1; - } - } else { - return (a.it.season < b.it.season)?-1: 1; - } - } else { - return (a.c < b.c)? -1: 1; - } - }); - next[key] = Math.floor( Math.random() * shows[key].length ); - }); - shuffle(array); - for (let i = 0; i < array.length; i++) { - if (array[i].type === 'episode' && array[i].season != 0) { - let title = array[i].showTitle; - var sequence = shows[title]; - let j = next[title]; - array[i] = sequence[j].it; - - next[title] = (j + 1) % sequence.length; - } - } - return array - } - scope.updateChannelDuration = updateChannelDuration function updateChannelDuration() { scope.showRotatedNote = false; @@ -1834,8 +1591,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { return false; } - - scope.onTimeSlotsDone = (slotsResult) => { + let readSlotsResult = (slotsResult) => { scope.channel.programs = slotsResult.programs; let t = (new Date()).getTime(); @@ -1845,7 +1601,6 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { total += slotsResult.programs[i].duration; } - scope.channel.scheduleBackup = slotsResult.schedule; while(t1 > t) { //TODO: Replace with division @@ -1854,19 +1609,59 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) { scope.channel.startTime = new Date(t1); adjustStartTimeToCurrentProgram(); updateChannelDuration(); + + }; + + + scope.onTimeSlotsDone = (slotsResult) => { + scope.channel.scheduleBackup = slotsResult.schedule; + readSlotsResult(slotsResult); } + + scope.onRandomSlotsDone = (slotsResult) => { + scope.channel.randomScheduleBackup = slotsResult.schedule; + readSlotsResult(slotsResult); + } + + scope.onTimeSlotsButtonClick = () => { - let progs = removeDuplicatesSub( scope.channel.programs ); + let progs = commonProgramTools.removeDuplicates( scope.channel.programs ); scope.timeSlots.startDialog( progs, scope.maxSize, scope.channel.scheduleBackup ); } + scope.onRandomSlotsButtonClick = () => { + let progs = commonProgramTools.removeDuplicates( scope.channel.programs ); + scope.randomSlots.startDialog(progs, scope.maxSize, scope.channel.randomScheduleBackup ); + } + + scope.logoOnChange = (event) => { + const formData = new FormData(); + formData.append('image', event.target.files[0]); + dizquetv.uploadImage(formData).then((response) => { + scope.channel.icon = response.data.fileUrl; + }) + } + + scope.watermarkOnChange = (event) => { + const formData = new FormData(); + formData.append('image', event.target.files[0]); + dizquetv.uploadImage(formData).then((response) => { + scope.channel.watermark.url = response.data.fileUrl; + }) + } + }, pre: function(scope) { scope.timeSlots = null; + scope.randomSlots = null; scope.registerTimeSlots = (timeSlots) => { scope.timeSlots = timeSlots; } + scope.registerRandomSlots = (randomSlots) => { + scope.randomSlots = randomSlots; + } + }, } diff --git a/web/directives/ffmpeg-settings.js b/web/directives/ffmpeg-settings.js index f4f66c7..52c507d 100644 --- a/web/directives/ffmpeg-settings.js +++ b/web/directives/ffmpeg-settings.js @@ -66,6 +66,14 @@ module.exports = function (dizquetv, resolutionOptions) { {id: "lanczos", description: "lanczos"}, {id: "spline", description: "spline"}, ]; + scope.deinterlaceOptions = [ + {value: "none", description: "do not deinterlace"}, + {value: "bwdif=0", description: "bwdif send frame"}, + {value: "bwdif=1", description: "bwdif send field"}, + {value: "w3fdif", description: "w3fdif"}, + {value: "yadif=0", description: "yadif send frame"}, + {value: "yadif=1", description: "yadif send field"} + ]; } } diff --git a/web/directives/filler-config.js b/web/directives/filler-config.js index f5c89f5..aea88bc 100644 --- a/web/directives/filler-config.js +++ b/web/directives/filler-config.js @@ -33,6 +33,7 @@ module.exports = function ($timeout) { z--; } scope.content.splice(z, 0, program ); + refreshContentIndexes(); $timeout(); return false; } diff --git a/web/directives/plex-library.js b/web/directives/plex-library.js index b2f6576..ce1018a 100644 --- a/web/directives/plex-library.js +++ b/web/directives/plex-library.js @@ -1,4 +1,4 @@ -module.exports = function (plex, dizquetv, $timeout) { +module.exports = function (plex, dizquetv, $timeout, commonProgramTools) { return { restrict: 'E', templateUrl: 'templates/plex-library.html', @@ -14,6 +14,9 @@ module.exports = function (plex, dizquetv, $timeout) { if ( typeof(scope.limit) == 'undefined') { scope.limit = 1000000000; } + scope.customShows = []; + scope.origins = []; + scope.currentOrigin = undefined; scope.pending = 0; scope.allowedIndexes = []; for (let i = -10; i <= -1; i++) { @@ -25,9 +28,14 @@ module.exports = function (plex, dizquetv, $timeout) { $timeout(resolve,t); }); } - scope.selectServer = function (server) { - scope.plexServer = server - updateLibrary(server) + scope.selectOrigin = function (origin) { + if ( origin.type === 'plex' ) { + scope.plexServer = origin.server; + updateLibrary(scope.plexServer); + } else { + scope.plexServer = undefined; + updateCustomShows(); + } } scope._onFinish = (s) => { if (s.length > scope.limit) { @@ -78,30 +86,41 @@ module.exports = function (plex, dizquetv, $timeout) { scope.$apply() } } + dizquetv.getPlexServers().then((servers) => { if (servers.length === 0) { scope.noServers = true return } - scope.plexServers = servers - scope.plexServer = servers[0] + scope.origins = servers.map( (s) => { + return { + "type" : "plex", + "name" : `Plex - ${s.name}`, + "server": s, + } + } ); + scope.currentOrigin = scope.origins[0]; + scope.plexServer = scope.currentOrigin.server; + scope.origins.push( { + "type": "dizquetv", + "name" : "dizqueTV - Custom Shows", + } ); updateLibrary(scope.plexServer) }) - function updateLibrary(server) { - plex.getLibrary(server).then((lib) => { - plex.getPlaylists(server).then((play) => { - for (let i = 0, l = play.length; i < l; i++) - play[i].type = 'playlist' + let updateLibrary = async(server) => { + let lib = await plex.getLibrary(server); + let play = await plex.getPlaylists(server); + + play.forEach( p => { + p.type = "playlist"; + } ); scope.$apply(() => { scope.libraries = lib if (play.length > 0) scope.libraries.push({ title: "Playlists", key: "", icon: "", nested: play }) }) - }) - }, (err) => { - console.log(err) - }) + } scope.fillNestedIfNecessary = async (x, isLibrary) => { if ( (typeof(x.nested) === 'undefined') && (x.type !== 'collection') ) { @@ -174,6 +193,57 @@ module.exports = function (plex, dizquetv, $timeout) { scope.createShowIdentifier = (season, ep) => { return 'S' + (season.toString().padStart(2, '0')) + 'E' + (ep.toString().padStart(2, '0')) } + scope.addCustomShow = async(show) => { + scope.pending++; + try { + show = await dizquetv.getShow(show.id); + for (let i = 0; i < show.content.length; i++) { + let item = JSON.parse(angular.toJson( show.content[i] )); + item.customShowId = show.id; + item.customShowName = show.name; + item.customOrder = i; + scope.selection.push(item); + } + scope.$apply(); + } finally { + scope.pending--; + } + + } + + scope.getProgramDisplayTitle = (x) => { + return commonProgramTools.getProgramDisplayTitle(x); + } + + let updateCustomShows = async() => { + scope.customShows = await dizquetv.getAllShowsInfo(); + scope.$apply(); + } + + scope.displayTitle = (show) => { + let r = ""; + if (show.type === 'episode') { + r += show.showTitle + " - "; + if ( typeof(show.season) !== 'undefined' ) { + r += "S" + show.season.toString().padStart(2,'0'); + } + if ( typeof(show.episode) !== 'undefined' ) { + r += "E" + show.episode.toString().padStart(2,'0'); + } + } + if (r != "") { + r = r + " - "; + } + r += show.title; + if ( + (show.type !== 'episode') + && + (typeof(show.year) !== 'undefined') + ) { + r += " (" + JSON.stringify(show.year) + ")"; + } + return r; + } } }; } \ No newline at end of file diff --git a/web/directives/plex-server-edit.js b/web/directives/plex-server-edit.js index c608d19..7772381 100644 --- a/web/directives/plex-server-edit.js +++ b/web/directives/plex-server-edit.js @@ -9,11 +9,13 @@ module.exports = function (dizquetv, $timeout) { }, link: function (scope, element, attrs) { scope.state.modified = false; + scope.loading = { show: false }; scope.setModified = () => { scope.state.modified = true; } scope.onSave = async () => { try { + scope.loading = { show: true }; await dizquetv.updatePlexServer(scope.state.server); scope.state.modified = false; scope.state.success = "The server was updated."; @@ -23,6 +25,8 @@ module.exports = function (dizquetv, $timeout) { scope.state.error = "There was an error updating the server"; scope.state.success = ""; console.error(scope.state.error, err); + } finally { + scope.loading = { show: false }; } $timeout( () => { scope.$apply() } , 0 ); } diff --git a/web/directives/plex-settings.js b/web/directives/plex-settings.js index 353962c..f9714b1 100644 --- a/web/directives/plex-settings.js +++ b/web/directives/plex-settings.js @@ -18,14 +18,16 @@ module.exports = function (plex, dizquetv, $timeout) { let servers = await dizquetv.getPlexServers(); scope.serversPending = false; scope.servers = servers; - for (let i = 0; i < scope.servers.length; i++) { - scope.servers[i].uiStatus = 0; - scope.servers[i].backendStatus = 0; - let t = (new Date()).getTime(); - scope.servers[i].uiPending = t; - scope.servers[i].backendPending = t; - scope.refreshUIStatus(t, i); - scope.refreshBackendStatus(t, i); + if(servers) { + for (let i = 0; i < scope.servers.length; i++) { + scope.servers[i].uiStatus = 0; + scope.servers[i].backendStatus = 0; + let t = (new Date()).getTime(); + scope.servers[i].uiPending = t; + scope.servers[i].backendPending = t; + scope.refreshUIStatus(t, i); + scope.refreshBackendStatus(t, i); + } } setTimeout( () => { scope.$apply() }, 31000 ); scope.$apply(); @@ -51,13 +53,15 @@ module.exports = function (plex, dizquetv, $timeout) { scope.isAnyUIBad = () => { let t = (new Date()).getTime(); - for (let i = 0; i < scope.servers.length; i++) { - let s = scope.servers[i]; - if ( - (s.uiStatus == -1) - || ( (s.uiStatus == 0) && (s.uiPending + 30000 < t) ) - ) { - return true; + if(scope.servers) { + for (let i = 0; i < scope.servers.length; i++) { + let s = scope.servers[i]; + if ( + (s.uiStatus == -1) + || ( (s.uiStatus == 0) && (s.uiPending + 30000 < t) ) + ) { + return true; + } } } return false; @@ -65,13 +69,15 @@ module.exports = function (plex, dizquetv, $timeout) { scope.isAnyBackendBad = () => { let t = (new Date()).getTime(); - for (let i = 0; i < scope.servers.length; i++) { - let s = scope.servers[i]; - if ( - (s.backendStatus == -1) - || ( (s.backendStatus == 0) && (s.backendPending + 30000 < t) ) - ) { - return true; + if(scope.servers) { + for (let i = 0; i < scope.servers.length; i++) { + let s = scope.servers[i]; + if ( + (s.backendStatus == -1) + || ( (s.backendStatus == 0) && (s.backendPending + 30000 < t) ) + ) { + return true; + } } } return false; @@ -146,7 +152,7 @@ module.exports = function (plex, dizquetv, $timeout) { } scope.shouldDisableSubtitles = () => { - return scope.settings.forceDirectPlay || (scope.settings.streamPath === "direct" ); + return scope.settings && (scope.settings.forceDirectPlay || (scope.settings.streamPath === "direct" )); } scope.addPlexServer = async () => { @@ -216,10 +222,10 @@ module.exports = function (plex, dizquetv, $timeout) { {id:"direct",description:"Direct"} ]; scope.hideIfNotPlexPath = () => { - return scope.settings.streamPath != 'plex' + return scope.settings && scope.settings.streamPath != 'plex' }; scope.hideIfNotDirectPath = () => { - return scope.settings.streamPath != 'direct' + return scope.settings && scope.settings.streamPath != 'direct' }; scope.maxAudioChannelsOptions=[ {id:"1",description:"1.0"}, diff --git a/web/directives/random-slots-schedule-editor.js b/web/directives/random-slots-schedule-editor.js new file mode 100644 index 0000000..0f88017 --- /dev/null +++ b/web/directives/random-slots-schedule-editor.js @@ -0,0 +1,322 @@ + +module.exports = function ($timeout, dizquetv, getShowData) { + const MINUTE = 60*1000; + const HOUR = 60*MINUTE; + const DAY = 24*HOUR; + const WEEK = 7 * DAY; + + return { + restrict: 'E', + templateUrl: 'templates/random-slots-schedule-editor.html', + replace: true, + scope: { + linker: "=linker", + onDone: "=onDone" + }, + + link: function (scope, element, attrs) { + scope.limit = 50000; + scope.visible = false; + + scope.badTimes = false; + scope._editedTime = null; + let showsById; + let shows; + + + function reset() { + showsById = {}; + shows = []; + scope.schedule = { + maxDays: 365, + flexPreference : "distribute", + padStyle: "slot", + randomDistribution: "uniform", + slots : [], + pad: 1, + } + } + reset(); + + function loadBackup(backup) { + scope.schedule = JSON.parse( JSON.stringify(backup) ); + if (typeof(scope.schedule.pad) == 'undefined') { + scope.schedule.pad = 1; + } + let slots = scope.schedule.slots; + for (let i = 0; i < slots.length; i++) { + let found = false; + for (let j = 0; j < scope.showOptions.length; j++) { + if (slots[i].showId == scope.showOptions[j].id) { + found = true; + } + } + if (! found) { + slots[i].showId = "flex."; + slots[i].order = "shuffle"; + } + } + if (typeof(scope.schedule.flexPreference) === 'undefined') { + scope.schedule.flexPreference = "distribute"; + } + if (typeof(scope.schedule.padStyle) === 'undefined') { + scope.schedule.padStyle = "slot"; + } + if (typeof(scope.schedule.randomDistribution) === 'undefined') { + scope.schedule.randomDistribution = "uniform"; + } + + scope.refreshSlots(); + + } + + getTitle = (index) => { + let showId = scope.schedule.slots[index].showId; + for (let i = 0; i < scope.showOptions.length; i++) { + if (scope.showOptions[i].id == showId) { + return scope.showOptions[i].description; + } + } + return "Unknown"; + } + scope.isWeekly = () => { + return (scope.schedule.period === WEEK); + }; + scope.addSlot = () => { + scope.schedule.slots.push( + { + duration: 30 * MINUTE, + showId: "flex.", + order: "next", + cooldown : 0, + } + ); + } + scope.timeColumnClass = () => { + return { "col-md-1": true}; + } + scope.programColumnClass = () => { + return { "col-md-6": true}; + }; + scope.durationOptions = [ + { id: 5 * MINUTE , description: "5 Minutes" }, + { id: 10 * MINUTE , description: "10 Minutes" }, + { id: 15 * MINUTE , description: "15 Minutes" }, + { id: 20 * MINUTE , description: "20 Minutes" }, + { id: 25 * MINUTE , description: "25 Minutes" }, + { id: 30 * MINUTE , description: "30 Minutes" }, + { id: 45 * MINUTE , description: "45 Minutes" }, + { id: 1 * HOUR , description: "1 Hour" }, + { id: 90 * MINUTE , description: "90 Minutes" }, + { id: 100 * MINUTE , description: "100 Minutes" }, + { id: 2 * HOUR , description: "2 Hours" }, + { id: 3 * HOUR , description: "3 Hours" }, + { id: 4 * HOUR , description: "4 Hours" }, + { id: 5 * HOUR , description: "5 Hours" }, + { id: 6 * HOUR , description: "6 Hours" }, + { id: 8 * HOUR , description: "8 Hours" }, + { id: 10* HOUR , description: "10 Hours" }, + { id: 12* HOUR , description: "12 Hours" }, + { id: 1 * DAY , description: "1 Day" }, + ]; + scope.cooldownOptions = [ + { id: 0 , description: "No cooldown" }, + { id: 1 * MINUTE , description: "1 Minute" }, + { id: 5 * MINUTE , description: "5 Minutes" }, + { id: 10 * MINUTE , description: "10 Minutes" }, + { id: 15 * MINUTE , description: "15 Minutes" }, + { id: 20 * MINUTE , description: "20 Minutes" }, + { id: 25 * MINUTE , description: "25 Minutes" }, + { id: 30 * MINUTE , description: "30 Minutes" }, + { id: 45 * MINUTE , description: "45 Minutes" }, + { id: 1 * HOUR , description: "1 Hour" }, + { id: 90 * MINUTE , description: "90 Minutes" }, + { id: 100 * MINUTE , description: "100 Minutes" }, + { id: 2 * HOUR , description: "2 Hours" }, + { id: 3 * HOUR , description: "3 Hours" }, + { id: 4 * HOUR , description: "4 Hours" }, + { id: 5 * HOUR , description: "5 Hours" }, + { id: 6 * HOUR , description: "6 Hours" }, + { id: 8 * HOUR , description: "8 Hours" }, + { id: 10* HOUR , description: "10 Hours" }, + { id: 12* HOUR , description: "12 Hours" }, + { id: 1 * DAY , description: "1 Day" }, + { id: 1 * DAY , description: "2 Days" }, + { id: 3 * DAY + 12 * HOUR , description: "3.5 Days" }, + { id: 7 * DAY , description: "1 Week" }, + ]; + + scope.flexOptions = [ + { id: "distribute", description: "Between videos" }, + { id: "end", description: "End of the slot" }, + ] + + scope.distributionOptions = [ + { id: "uniform", description: "Uniform" }, + { id: "weighted", description: "Weighted" }, + ] + + + scope.padOptions = [ + {id: 1, description: "Do not pad" }, + {id: 1*MINUTE, description: "0:00, 0:01, 0:02, ..., 0:59" }, + {id: 5*MINUTE, description: "0:00, 0:05, 0:10, ..., 0:55" }, + {id: 10*60*1000, description: "0:00, 0:10, 0:20, ..., 0:50" }, + {id: 15*60*1000, description: "0:00, 0:15, 0:30, ..., 0:45" }, + {id: 30*60*1000, description: "0:00, 0:30" }, + {id: 1*60*60*1000, description: "0:00" }, + ]; + scope.padStyleOptions = [ + {id: "episode" , description: "Pad Episodes" }, + {id: "slot" , description: "Pad Slots" }, + ]; + + scope.showOptions = []; + scope.orderOptions = [ + { id: "next", description: "Play Next" }, + { id: "shuffle", description: "Shuffle" }, + ]; + + let doIt = async() => { + let res = await dizquetv.calculateRandomSlots(scope.programs, scope.schedule ); + for (let i = 0; i < scope.schedule.slots.length; i++) { + delete scope.schedule.slots[i].weightPercentage; + } + res.schedule = scope.schedule; + return res; + } + + + + + let startDialog = (programs, limit, backup) => { + scope.limit = limit; + scope.programs = programs; + + reset(); + + + + programs.forEach( (p) => { + let show = getShow(p); + if (show != null) { + if (typeof(showsById[show.id]) === 'undefined') { + showsById[show.id] = shows.length; + shows.push( show ); + } else { + show = shows[ showsById[show.id] ]; + } + } + } ); + scope.showOptions = shows.map( (show) => { return show } ); + scope.showOptions.push( { + id: "flex.", + description: "Flex", + } ); + if (typeof(backup) !== 'undefined') { + loadBackup(backup); + } + + scope.visible = true; + } + + + scope.linker( { + startDialog: startDialog, + } ); + + scope.finished = async (cancel) => { + scope.error = null; + if (!cancel) { + try { + scope.loading = true; + $timeout(); + scope.onDone( await doIt() ); + scope.visible = false; + } catch(err) { + console.error("Unable to generate channel lineup", err); + scope.error = "There was an error processing the schedule"; + return; + } finally { + scope.loading = false; + $timeout(); + } + } else { + scope.visible = false; + } + } + + scope.deleteSlot = (index) => { + scope.schedule.slots.splice(index, 1); + } + + scope.hasTimeError = (slot) => { + return typeof(slot.timeError) !== 'undefined'; + } + + scope.disableCreateLineup = () => { + if (scope.badTimes) { + return true; + } + if (typeof(scope.schedule.maxDays) === 'undefined') { + return true; + } + if (scope.schedule.slots.length == 0) { + return true; + } + return false; + } + + scope.canShowSlot = (slot) => { + return (slot.showId != 'flex.') && !(slot.showId.startsWith('redirect.')); + } + + scope.refreshSlots = () => { + let sum = 0; + for (let i = 0; i < scope.schedule.slots.length; i++) { + sum += scope.schedule.slots[i].weight; + } + for (let i = 0; i < scope.schedule.slots.length; i++) { + if (scope.schedule.slots[i].showId == 'movie.') { + scope.schedule.slots[i].order = "shuffle"; + } + if ( isNaN(scope.schedule.slots[i].cooldown) ) { + scope.schedule.slots[i].cooldown = 0; + } + scope.schedule.slots[i].weightPercentage + = (100 * scope.schedule.slots[i].weight / sum).toFixed(2) + "%"; + } + $timeout(); + } + + scope.randomDistributionChanged = () => { + if (scope.schedule.randomDistribution === 'uniform') { + for (let i = 0; i < scope.schedule.slots.length; i++) { + scope.schedule.slots[i].weight = 1; + } + } else { + for (let i = 0; i < scope.schedule.slots.length; i++) { + scope.schedule.slots[i].weight = 300; + } + } + scope.refreshSlots(); + } + + + + } + }; + + function getShow(program) { + + let d = getShowData(program); + if (! d.hasShow) { + return null; + } else { + d.description = d.showDisplayName; + d.id = d.showId; + return d; + } + } + +} \ No newline at end of file diff --git a/web/directives/remove-shows.js b/web/directives/remove-shows.js index 33efcd5..788a409 100644 --- a/web/directives/remove-shows.js +++ b/web/directives/remove-shows.js @@ -4,23 +4,23 @@ module.exports = function ($timeout) { templateUrl: 'templates/remove-shows.html', replace: true, scope: { - programTitles: "=programTitles", + programInfos: "=programInfos", visible: "=visible", onDone: "=onDone", deleted: "=deleted" }, link: function (scope, element, attrs) { - scope.toggleShowDeletion = (programTitle) => { - const deletedIdx = scope.deleted.indexOf(programTitle); + scope.toggleShowDeletion = (programId) => { + const deletedIdx = scope.deleted.indexOf(programId); if (deletedIdx === -1) { - scope.deleted.push(programTitle); + scope.deleted.push(programId); } else { scope.deleted.splice(deletedIdx, 1); } } scope.finished = () => { const d = scope.deleted; - scope.programTitles = null; + scope.programInfos = null; scope.deleted = null; scope.onDone(d); } diff --git a/web/directives/show-config.js b/web/directives/show-config.js new file mode 100644 index 0000000..1dbafa1 --- /dev/null +++ b/web/directives/show-config.js @@ -0,0 +1,165 @@ +module.exports = function ($timeout, commonProgramTools) { + return { + restrict: 'E', + templateUrl: 'templates/show-config.html', + replace: true, + scope: { + linker: "=linker", + onDone: "=onDone" + }, + link: function (scope, element, attrs) { + scope.showTools = false; + scope.showPlexLibrary = false; + scope.content = []; + scope.visible = false; + scope.error = undefined; + + function refreshContentIndexes() { + for (let i = 0; i < scope.content.length; i++) { + scope.content[i].$index = i; + } + } + + scope.contentSplice = (a,b) => { + scope.content.splice(a,b) + refreshContentIndexes(); + } + + scope.dropFunction = (dropIndex, program) => { + let y = program.$index; + let z = dropIndex + scope.currentStartIndex - 1; + scope.content.splice(y, 1); + if (z >= y) { + z--; + } + scope.content.splice(z, 0, program ); + refreshContentIndexes(); + $timeout(); + return false; + } + scope.setUpWatcher = function setupWatchers() { + this.$watch('vsRepeat.startIndex', function(val) { + scope.currentStartIndex = val; + }); + }; + + scope.movedFunction = (index) => { + console.log("movedFunction(" + index + ")"); + } + + + + scope.linker( (show) => { + if ( typeof(show) === 'undefined') { + scope.name = ""; + scope.content = []; + scope.id = undefined; + scope.title = "Create Custom Show"; + } else { + scope.name = show.name; + scope.content = show.content; + scope.id = show.id; + scope.title = "Edit Custom Show"; + } + refreshContentIndexes(); + scope.visible = true; + } ); + + scope.finished = (cancelled) => { + if (cancelled) { + scope.visible = false; + return scope.onDone(); + } + if ( (typeof(scope.name) === 'undefined') || (scope.name.length == 0) ) { + scope.error = "Please enter a name"; + } + if ( scope.content.length == 0) { + scope.error = "Please add at least one clip."; + } + if (typeof(scope.error) !== 'undefined') { + $timeout( () => { + scope.error = undefined; + }, 30000); + return; + } + scope.visible = false; + scope.onDone( { + name: scope.name, + content: scope.content.map( (c) => { + delete c.$index + return c; + } ), + id: scope.id, + } ); + } + scope.showList = () => { + return ! scope.showPlexLibrary; + } + scope.sortShows = () => { + scope.content = commonProgramTools.sortShows(scope.content); + refreshContentIndexes(); + } + scope.sortByDate = () => { + scope.content = commonProgramTools.sortByDate(scope.content); + refreshContentIndexes(); + } + scope.shuffleShows = () => { + scope.content = commonProgramTools.shuffle(scope.content); + refreshContentIndexes(); + } + scope.showRemoveAllShow = () => { + scope.content = []; + refreshContentIndexes(); + } + scope.showRemoveDuplicates = () => { + scope.content = commonProgramTools.removeDuplicates(scope.content); + refreshContentIndexes(); + } + scope.getProgramDisplayTitle = (x) => { + return commonProgramTools.getProgramDisplayTitle(x); + } + + scope.removeSpecials = () => { + scope.content = commonProgramTools.removeSpecials(scope.content); + refreshContentIndexes(); + + } + scope.importPrograms = (selectedPrograms) => { + for (let i = 0, l = selectedPrograms.length; i < l; i++) { + selectedPrograms[i].commercials = [] + } + scope.content = scope.content.concat(selectedPrograms); + refreshContentIndexes(); + scope.showPlexLibrary = false; + } + + + scope.durationString = (duration) => { + var date = new Date(0); + date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here + return date.toISOString().substr(11, 8); + } + + let interpolate = ( () => { + let h = 60*60*1000 / 6; + let ix = [0, 1*h, 2*h, 4*h, 8*h, 24*h]; + let iy = [0, 1.0, 1.25, 1.5, 1.75, 2.0]; + let n = ix.length; + + return (x) => { + for (let i = 0; i < n-1; i++) { + if( (ix[i] <= x) && ( (x < ix[i+1]) || i==n-2 ) ) { + return iy[i] + (iy[i+1] - iy[i]) * ( (x - ix[i]) / (ix[i+1] - ix[i]) ); + } + } + } + + } )(); + + scope.programSquareStyle = (x) => { + return commonProgramTools.programSquareStyle(x); + } + + } + }; +} diff --git a/web/directives/time-slots-schedule-editor.js b/web/directives/time-slots-schedule-editor.js index 9abe540..45b2522 100644 --- a/web/directives/time-slots-schedule-editor.js +++ b/web/directives/time-slots-schedule-editor.js @@ -1,6 +1,9 @@ - -module.exports = function ($timeout, dizquetv) { +module.exports = function ($timeout, dizquetv, getShowData ) { + const DAY = 24*60*60*1000; + const WEEK = 7 * DAY; + const WEEK_DAYS = [ "Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday" ]; + return { restrict: 'E', templateUrl: 'templates/time-slots-schedule-editor.html', @@ -14,8 +17,8 @@ module.exports = function ($timeout, dizquetv) { scope.limit = 50000; scope.visible = false; scope.fake = { time: -1 }; - scope.timeOptions = [] scope.badTimes = false; + scope._editedTime = null; let showsById; let shows; @@ -24,6 +27,7 @@ module.exports = function ($timeout, dizquetv) { showsById = {}; shows = []; scope.schedule = { + period : DAY, lateness : 0, maxDays: 365, flexPreference : "distribute", @@ -56,19 +60,117 @@ module.exports = function ($timeout, dizquetv) { if (typeof(scope.schedule.flexPreference) === 'undefined') { scope.schedule.flexPreference = "distribute"; } + if (typeof(scope.schedule.period) === 'undefined') { + scope.schedule.period = DAY; + } scope.schedule.fake = { time: -1, } } - for (let h = 0; h < 24; h++) { - for (let m = 0; m < 60; m += 15) { - scope.timeOptions.push( { - id: (h * 60 + m) * 60 * 1000, - description: niceLookingTime(h,m), - } ); + let getTitle = (index) => { + let showId = scope.schedule.slots[index].showId; + for (let i = 0; i < scope.showOptions.length; i++) { + if (scope.showOptions[i].id == showId) { + return scope.showOptions[i].description; + } + } + return "Uknown"; + } + scope.isWeekly = () => { + return (scope.schedule.period === WEEK); + }; + scope.periodChanged = () => { + if (scope.isWeekly()) { + //From daily to weekly + let l = scope.schedule.slots.length; + for (let i = 0; i < l; i++) { + let t = scope.schedule.slots[i].time; + scope.schedule.slots[i].time = t % DAY; + for (let j = 1; j < 7; j++) { + //clone the slot for every day of the week + let c = JSON.parse( angular.toJson(scope.schedule.slots[i]) ); + c.time += j * DAY; + scope.schedule.slots.push(c); + } + } + } else { + //From weekly to daily + let newSlots = []; + let seen = {}; + for (let i = 0; i < scope.schedule.slots.length; i++) { + let slot = scope.schedule.slots[i]; + let t = slot.time % DAY; + if (seen[t] !== true) { + seen[t] = true; + newSlots.push(slot); + } + } + scope.schedule.slots = newSlots; + } + scope.refreshSlots(); + } + scope.editTime = (index) => { + let t = scope.schedule.slots[index].time; + scope._editedTime = { + time: t, + index : index, + isWeekly : scope.isWeekly(), + title : getTitle(index), + }; + } + scope.finishedTimeEdit = (slot) => { + scope.schedule.slots[slot.index].time = slot.time; + scope.refreshSlots(); + } + scope.addSlot = () => { + scope._addedTime = { + time: 0, + index : -1, + isWeekly : scope.isWeekly(), + title: "New time slot", } } + scope.finishedAddingTime = (slot) => { + scope.schedule.slots.push( { + time: slot.time, + showId: "flex.", + order: "next" + } ); + scope.refreshSlots(); + } + scope.displayTime = (t) => { + if (scope.isWeekly()) { + let w = Math.floor( t / DAY ); + let t2 = t % DAY; + return WEEK_DAYS[w].substring(0,3) + " " + niceLookingTime(t2); + + } else { + return niceLookingTime(t); + } + } + scope.timeColumnClass = () => { + let r = {}; + if (scope.isWeekly()) { + r["col-md-3"] = true; + } else { + r["col-md-2"] = true; + } + return r; + } + scope.programColumnClass = () => { + let r = {}; + if (scope.isWeekly()) { + r["col-md-6"] = true; + } else { + r["col-md-7"] = true; + } + return r; + }; + scope.periodOptions = [ + { id : DAY , description: "Daily" }, + { id : WEEK , description: "Weekly" }, + ] scope.latenessOptions = [ { id: 0 , description: "Do not allow" }, { id: 5*60*1000, description: "5 minutes" }, @@ -85,8 +187,6 @@ module.exports = function ($timeout, dizquetv) { { id: "distribute", description: "Between videos" }, { id: "end", description: "End of the slot" }, ] - scope.fakeTimeOptions = JSON.parse( JSON.stringify( scope.timeOptions ) ); - scope.fakeTimeOptions.push( {id: -1, description: "Add slot"} ); scope.padOptions = [ {id: 1, description: "Do not pad" }, @@ -171,19 +271,6 @@ module.exports = function ($timeout, dizquetv) { } } - scope.fakeTimeChanged = () => { - - if (scope.fake.time != -1) { - scope.schedule.slots.push( { - time: scope.fake.time, - showId: "flex.", - order: "next" - } ) - scope.fake.time = -1; - scope.refreshSlots(); - } - } - scope.deleteSlot = (index) => { scope.schedule.slots.splice(index, 1); } @@ -242,42 +329,28 @@ module.exports = function ($timeout, dizquetv) { } }; -} -function niceLookingTime(h, m) { - let d = new Date(); - d.setHours(h); - d.setMinutes(m); - d.setSeconds(0); - d.setMilliseconds(0); - return d.toLocaleTimeString(); -} + function getShow(program) { -//This is a duplicate code, but maybe it doesn't have to be? -function getShow(program) { - //used for equalize and frequency tweak - if (program.isOffline) { - if (program.type == 'redirect') { - return { - description : `Redirect to channel ${program.channel}`, - id: "redirect." + program.channel, - channel: program.channel, - } - } else { + let d = getShowData(program); + if (! d.hasShow) { return null; - } - } else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) { - return { - description: program.showTitle, - id: "tv." + program.showTitle, - } - } else { - return { - description: "Movies", - id: "movie.", + } else { + d.description = d.showDisplayName; + d.id = d.showId; + return d; } } + + + } +function niceLookingTime(t) { + let d = new Date(t); + d.setMilliseconds(0); + + return d.toLocaleTimeString( [] , {timeZone: 'UTC' } ); +} diff --git a/web/directives/time-slots-time-editor.js b/web/directives/time-slots-time-editor.js new file mode 100644 index 0000000..7e3b941 --- /dev/null +++ b/web/directives/time-slots-time-editor.js @@ -0,0 +1,106 @@ +module.exports = function ($timeout) { + return { + restrict: 'E', + templateUrl: 'templates/time-slots-time-editor.html', + replace: true, + scope: { + title: "@dialogTitle", + slot: "=slot", + visible: "=visible", + onDone: "=onDone" + }, + link: function (scope, element, attrs) { + let updateNext = true; + scope.w = 0; + scope.h = 0; + scope.m = 0; + scope.s = 0; + scope.weekDayOptions = [ + { id: 0, description : "Thursday" } , + { id: 1, description : "Friday" } , + { id: 2, description : "Saturday" } , + { id: 3, description : "Sunday" } , + { id: 4, description : "Monday" } , + { id: 5, description : "Tuesday" } , + { id: 6, description : "Wednesday" } , + ]; + + scope.hourOptions = []; + for (let i = 0; i < 24; i++) { + scope.hourOptions.push( { + id: i, + description: pad(i), + } ); + } + scope.minuteOptions = []; + let mods = [ 15, 5, 1 ]; + mods.forEach( x => { + for (let i = 0; i < 60; i+= x) { + scope.minuteOptions.push( { + id: i, + description: pad(i), + } ); + } + } ); + + function pad(x) { + let s = "" + x; + if (s.length < 2) { + s = "0" + s; + } + return s; + } + + scope.$watch('slot', () => { + try { + if ( (typeof(scope.slot) === 'undefined') || (scope.slot == null) ) { + updateNext = true; + return; + } else if (! updateNext) { + return; + } + updateNext = false; + scope.error = null; + t = Math.floor( scope.slot.time % (24 * 60 * 60 * 1000) / 1000 ); + let s = t % 60; + let m = ( (t - s) / 60 ) % 60; + let h = (t - m*60 - s) / 3600; + let w = Math.floor( scope.slot.time / (24 * 60 * 60 * 1000) ) % 7; + scope.slot.h = h; + scope.slot.m = m; + scope.slot.s = s; + scope.slot.w = w; + } catch (err) { + console.error(err); + } + }) + + scope.finished = (slot) => { + scope.error = null; + if (isNaN(slot.h) || slot.h < 0 || slot.h > 23 ) { + scope.error = { t: 'Invalid hour of the day' } + } + if (isNaN(slot.m) || slot.m < 0 || slot.m > 59 ) { + scope.error = { t: 'Invalid minutes' } + } + if (isNaN(slot.s) || slot.s < 0 || slot.s > 59 ) { + scope.error = { t: 'Invalid seconds' } + } + if (isNaN(slot.w) || slot.w < 0 || slot.w > 6 ) { + scope.error = { t: 'Invalid day' } + } + + if (scope.error != null) { + $timeout(() => { + scope.error = null + }, 30000) + return + } + slot.time = slot.w*24*60*60*1000 + slot.h*60*60*1000 + slot.m*60*1000+ slot.s*1000; + scope.onDone(JSON.parse(angular.toJson(slot))) + scope.slot = null + } + + } + }; +} diff --git a/web/directives/toast-notifications.js b/web/directives/toast-notifications.js new file mode 100644 index 0000000..cb83870 --- /dev/null +++ b/web/directives/toast-notifications.js @@ -0,0 +1,121 @@ +module.exports = function ($timeout) { + return { + restrict: 'E', + templateUrl: 'templates/toast-notifications.html', + replace: true, + scope: { + }, + link: function (scope, element, attrs) { + + const FADE_IN_START = 100; + const FADE_IN_END = 1000; + const FADE_OUT_START = 10000; + const TOTAL_DURATION = 11000; + + + scope.toasts = []; + + let eventSource = null; + + let timerHandle = null; + let refreshHandle = null; + + + let setResetTimer = () => { + if (timerHandle != null) { + clearTimeout( timerHandle ); + } + timerHandle = setTimeout( () => { + scope.setup(); + } , 10000); + }; + + let updateAfter = (wait) => { + if (refreshHandle != null) { + $timeout.cancel( refreshHandle ); + } + refreshHandle = $timeout( ()=> updater(), wait ); + }; + + let updater = () => { + let wait = 10000; + let updatedToasts = []; + try { + let t = (new Date()).getTime(); + for (let i = 0; i < scope.toasts.length; i++) { + let toast = scope.toasts[i]; + let diff = t - toast.time; + if (diff < TOTAL_DURATION) { + if (diff < FADE_IN_START) { + toast.clazz = { "about-to-fade-in" : true } + wait = Math.min( wait, FADE_IN_START - diff ); + } else if (diff < FADE_IN_END) { + toast.clazz = { "fade-in" : true } + wait = Math.min( wait, FADE_IN_END - diff ); + } else if (diff < FADE_OUT_START) { + toast.clazz = {} + wait = Math.min( wait, FADE_OUT_START - diff ); + } else { + toast.clazz = { "fade-out" : true } + wait = Math.min( wait, TOTAL_DURATION - diff ); + } + toast.clazz[toast.deco] = true; + updatedToasts.push(toast); + } + } + } catch (err) { + console.error("error", err); + } + scope.toasts = updatedToasts; + updateAfter(wait); + }; + + let addToast = (toast) => { + toast.time = (new Date()).getTime(); + toast.clazz= { "about-to-fade-in": true }; + toast.clazz[toast.deco] = true; + scope.toasts.push(toast); + $timeout( () => updateAfter(0) ); + }; + + let getDeco = (data) => { + return "bg-" + data.level; + } + + scope.setup = () => { + if (eventSource != null) { + eventSource.close(); + eventSource = null; + } + setResetTimer(); + + eventSource = new EventSource("api/events"); + + eventSource.addEventListener("heartbeat", () => { + setResetTimer(); + } ); + + let normalEvent = (title) => { + return (event) => { + let data = JSON.parse(event.data); + addToast ( { + title : title, + text : data.message, + deco: getDeco(data) + } ) + }; + }; + + eventSource.addEventListener('settings-update', normalEvent("Settings Update") ); + eventSource.addEventListener('xmltv', normalEvent("TV Guide") ); + eventSource.addEventListener('lifecycle', normalEvent("Server") ); + }; + + scope.destroy = (index) => { + scope.toasts.splice(index,1); + } + + scope.setup(); + } + }; +} diff --git a/web/public/index.html b/web/public/index.html index a57f005..f198895 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -7,6 +7,7 @@ + @@ -32,7 +33,7 @@ - Guide - Channels - Filler - Settings - Version + Guide - Channels - Library - Player - Settings - Version XMLTV @@ -43,6 +44,7 @@
+ diff --git a/web/public/style.css b/web/public/style.css index 0102e80..0ad24b1 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -1,3 +1,14 @@ +:root { + --guide-text : #F0F0f0; + --guide-header-even: #423cd4ff; + --guide-header-odd: #262198ff; + --guide-color-a: #212121; + --guide-color-b: #515151; + --guide-color-c: #313131; + --guide-color-d: #414141; +} + + .pull-right { float: right; } .modal-semi-body { @@ -5,14 +16,6 @@ flex: 1 1 auto; } -.commercials-panel { - background-color: rgb(70, 70, 70); - border-top: 1px solid #daa104; - border-left-color: #daa104; - border-right-color: #daa104; - color: white -} - .plex-panel { margin: 0; padding: 0; @@ -27,25 +30,15 @@ padding-right: 0.2em } -.list-group-item-video { - background-color: rgb(70, 70, 70); - border-top: 1px solid #daa104; - border-left-color: #daa104; - border-right-color: #daa104; - color: white -} -.list-group-item-video .fa-plus-circle { + +.fa-plus-circle { color: #daa104; } -.list-group-item-video:hover .fa-plus-circle { +.fa-plus-circle { color: #000; } -.list-group-item-video:hover { - background-color: #daa104; - color: #000 !important; -} .list-group.list-group-root .list-group-item { border-radius: 0; border-width: 1px 0 0 0; @@ -157,8 +150,7 @@ table.tvguide { position: sticky; top: 0; bottom: 0; - background: white; - border-bottom: 1px solid black; + /*border-bottom: 1px solid black;*/ } .tvguide th.guidenav { @@ -168,7 +160,7 @@ table.tvguide { .tvguide td, .tvguide th { - color: #F0F0f0; + color: var(--guide-text); border-top: 0; height: 3.5em; padding-top: 0; @@ -208,27 +200,27 @@ table.tvguide { .tvguide th.even { - background: #423cd4ff; + background: var(--guide-header-even); } .tvguide th.odd { - background: #262198ff; + background: var(--guide-header-odd); } .tvguide tr.odd td.even { - background: #212121; + background: var(--guide-color-a); } .tvguide tr.odd td.odd { - background: #515151;; + background: var(--guide-color-b); } .tvguide tr.even td.odd { - background: #313131 + background: var(--guide-color-c); } .tvguide tr.even td.even { - background: #414141; + background: var(--guide-color-d) ; } .tvguide td .play-channel { @@ -254,17 +246,21 @@ table.tvguide { text-align: right; } -.filler-list .list-group-item, .program-row { +.filler-list .list-group-item, .program-row, .show-list .list-group-item, .program-row { min-height: 1.5em; } -.filler-list .list-group-item .title, .program-row .title { +.filler-list .list-group-item .title, .program-row .title, .show-list .list-group-item .title, .program-row .title { margin-right: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.show-row .program-start { + width: 2em; +} + div.channel-tools { max-height: 20em; overflow-y: scroll; @@ -315,7 +311,7 @@ div.programming-programs div.list-group-item { } -.program-row:nth-child(odd), .filler-row:nth-child(odd), .channel-row:nth-child(odd) { +.program-row:nth-child(odd), .show-row:nth-child(odd), .filler-row:nth-child(odd), .channel-row:nth-child(odd) { background-color: #eeeeee; } @@ -357,6 +353,38 @@ div.programming-programs div.list-group-item { background : rgba(255,255,255, 0.1); } +.dizque-toast { + margin-top: 0.2rem; + padding: 0.5rem; + background: #FFFFFF; + border: 1px solid rgba(0,0,0,.1); + border-radius: .25rem; + color: #FFFFFF; +} + +.dizque-toast.bg-warning { + color: black +} + +.about-to-fade-in { + opacity: 0.00; + transition: opacity 1.00s ease-in-out; + -moz-transition: opacity 1.00s ease-in-out; + -webkit-transition: opacity 1.00s ease-in-out; +} +.fade-in { + opacity: 0.95; + transition: opacity 1.00s ease-in-out; + -moz-transition: opacity 1.00s ease-in-out; + -webkit-transition: opacity 1.00s ease-in-out; +} + +.fade-out { + transition: opacity 1.00s ease-in-out; + -moz-transition: opacity 1.00s ease-in-out; + -webkit-transition: opacity 1.00s ease-in-out; + opacity: 0.0; +} #dizquetv-logo { width: 1em; diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index a4f9531..2b37c3c 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -21,24 +21,48 @@