diff --git a/.github/workflows/binaries-build.yaml b/.github/workflows/binaries-build.yaml new file mode 100644 index 0000000..f5e52b4 --- /dev/null +++ b/.github/workflows/binaries-build.yaml @@ -0,0 +1,46 @@ +name: Build Executables and Update Release + +on: + workflow_call: + inputs: + release: + required: true + type: string + +jobs: + release-files: + runs-on: ubuntu-latest + steps: + + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Build dist image + uses: docker/build-push-action@v2 + with: + context: . + file: Dockerfile-builder + load: true + tags: builder + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run dist docker + run: | + docker run -v ./dist:/home/node/app/dist builder sh make_dist.sh + + + - name: Upload Files + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ inputs.release }} + files: | + ./dist/dizquetv-win-x64.exe + ./dist/dizquetv-win-x86.exe + ./dist/dizquetv-linux-x64 + ./dist/dizquetv-macos-x64 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/development-binaries.yaml b/.github/workflows/development-binaries.yaml new file mode 100644 index 0000000..4a2705b --- /dev/null +++ b/.github/workflows/development-binaries.yaml @@ -0,0 +1,13 @@ +name: Development Binaries + +on: + push: + branches: + - dev/1.5.x + +jobs: + binaries: + uses: ./.github/workflows/binaries-build.yaml + with: + release: development-binaries + secrets: inherit diff --git a/.github/workflows/development-tag.yaml b/.github/workflows/development-tag.yaml new file mode 100644 index 0000000..a3dc0ee --- /dev/null +++ b/.github/workflows/development-tag.yaml @@ -0,0 +1,13 @@ +name: Development Tag + +on: + push: + branches: + - dev/1.5.x + +jobs: + docker: + uses: ./.github/workflows/docker-build.yaml + with: + tag: development + secrets: inherit diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml new file mode 100644 index 0000000..ef1cd73 --- /dev/null +++ b/.github/workflows/docker-build.yaml @@ -0,0 +1,43 @@ +name: Docker Build + +on: + workflow_call: + inputs: + tag: + required: true + type: string + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - name: Default + Dockerfile: Dockerfile + suffix: "" + - name: nvidia + Dockerfile: Dockerfile-nvidia + suffix: "-nvidia" + + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: vexorian/dizquetv:${{ inputs.tag }}${{ matrix.suffix }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/edge-tag.yaml b/.github/workflows/edge-tag.yaml new file mode 100644 index 0000000..d697a9f --- /dev/null +++ b/.github/workflows/edge-tag.yaml @@ -0,0 +1,13 @@ +name: Edge Tag + +on: + push: + branches: + - edge + +jobs: + docker: + uses: ./.github/workflows/docker-build.yaml + with: + tag: edge + secrets: inherit diff --git a/.github/workflows/latest-tag.yaml b/.github/workflows/latest-tag.yaml new file mode 100644 index 0000000..fa0b385 --- /dev/null +++ b/.github/workflows/latest-tag.yaml @@ -0,0 +1,13 @@ +name: Latest Tag + +on: + push: + branches: + - main + +jobs: + docker: + uses: ./.github/workflows/docker-build.yaml + with: + tag: latest + secrets: inherit diff --git a/.github/workflows/named-tag.yaml b/.github/workflows/named-tag.yaml new file mode 100644 index 0000000..6b6a22c --- /dev/null +++ b/.github/workflows/named-tag.yaml @@ -0,0 +1,13 @@ +name: Named Tag + +on: + push: + tags: + - '*' + +jobs: + docker: + uses: ./.github/workflows/docker-build.yaml + with: + tag: ${{ github.ref_name }} + secrets: inherit diff --git a/.github/workflows/tag-binaries.yaml b/.github/workflows/tag-binaries.yaml new file mode 100644 index 0000000..695d251 --- /dev/null +++ b/.github/workflows/tag-binaries.yaml @@ -0,0 +1,13 @@ +name: Release Binaries + +on: + push: + tags: + - "*" + +jobs: + binaries: + uses: ./.github/workflows/binaries-build.yaml + with: + release: ${{ github.ref_name }} + secrets: inherit diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100755 index fe4c17a..0000000 --- a/.husky/commit-msg +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx --no-install commitlint --edit "" diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg deleted file mode 100755 index 5c18e4b..0000000 --- a/.husky/prepare-commit-msg +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -exec < /dev/tty && node_modules/.bin/cz --hook || true diff --git a/Dockerfile b/Dockerfile index 23435cc..b2abb0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:12.18-alpine3.12 WORKDIR /home/node/app -COPY package*.json ./ +COPY package.json ./ RUN npm install && npm install -g browserify nexe@3.3.7 COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/ COPY . . diff --git a/README.md b/README.md index 5801a4a..e1f11c4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# dizqueTV 1.5.0 +# dizqueTV 1.5.1 ![Discord](https://img.shields.io/discord/711313431457693727?logo=discord&logoColor=fff&style=flat-square) ![GitHub top language](https://img.shields.io/github/languages/top/vexorian/dizquetv?logo=github&style=flat-square) ![Docker Pulls](https://img.shields.io/docker/pulls/vexorian/dizquetv?logo=docker&logoColor=fff&style=flat-square) Create live TV channel streams from media on your Plex servers. diff --git a/index.js b/index.js index b97c8c7..96fe298 100644 --- a/index.js +++ b/index.js @@ -29,6 +29,7 @@ const EventService = require("./src/services/event-service"); const OnDemandService = require("./src/services/on-demand-service"); const ProgrammingService = require("./src/services/programming-service"); const ActiveChannelService = require('./src/services/active-channel-service') +const ProgramPlayTimeDB = require('./src/dao/program-play-time-db') const onShutdown = require("node-graceful-shutdown").onShutdown; @@ -95,7 +96,19 @@ channelService = new ChannelService(channelDB); fillerDB = new FillerDB( path.join(process.env.DATABASE, 'filler') , channelService ); customShowDB = new CustomShowDB( path.join(process.env.DATABASE, 'custom-shows') ); +let programPlayTimeDB = new ProgramPlayTimeDB( path.join(process.env.DATABASE, 'play-cache') ); +async function initializeProgramPlayTimeDB() { + try { + let t0 = new Date().getTime(); + await programPlayTimeDB.load(); + let t1 = new Date().getTime(); + console.log(`Program Play Time Cache loaded in ${t1-t0} milliseconds.`); + } catch (err) { + console.log(err); + } +} +initializeProgramPlayTimeDB(); fileCache = new FileCacheService( path.join(process.env.DATABASE, 'cache') ); cacheImageService = new CacheImageService(db, fileCache); @@ -270,7 +283,7 @@ app.use('/custom.css', express.static(path.join(process.env.DATABASE, 'custom.cs app.use(api.router(db, channelService, fillerDB, customShowDB, xmltvInterval, guideService, m3uService, eventService )) app.use('/api/cache/images', cacheImageService.apiRouters()) -app.use(video.router( channelService, fillerDB, db, programmingService, activeChannelService )) +app.use(video.router( channelService, fillerDB, db, programmingService, activeChannelService, programPlayTimeDB )) app.use(hdhr.router) app.listen(process.env.PORT, () => { console.log(`HTTP server running on port: http://*:${process.env.PORT}`) @@ -309,6 +322,10 @@ function initDB(db, channelDB) { 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(process.env.DATABASE + '/images/black.png')) { + let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/black.png'))) + fs.writeFileSync(process.env.DATABASE + '/images/black.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) diff --git a/package.json b/package.json index 2bb8697..7182ff1 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,7 @@ "dev-server": "nodemon index.js --ignore ./web/ --ignore ./db/ --ignore ./xmltv.xml", "compile": "babel index.js -d dist && babel src -d dist/src", "package": "sh ./make_dist.sh", - "clean": "del-cli --force ./bin ./dist ./.dizquetv ./web/public/bundle.js", - "prepare": "husky install" + "clean": "del-cli --force ./bin ./dist ./.dizquetv ./web/public/bundle.js" }, "author": "vexorian", "license": "Zlib", @@ -35,9 +34,10 @@ "ng-i18next": "^1.0.7", "node-graceful-shutdown": "1.1.0", "node-ssdp": "^4.0.0", + "quickselect": "2.0.0", "random-js": "2.1.0", "request": "^2.88.2", - "uuid": "^8.0.0", + "uuid": "9.0.1", "xml-writer": "^1.7.0" }, "bin": "dist/index.js", @@ -50,9 +50,7 @@ "@commitlint/config-conventional": "^12.1.4", "browserify": "^16.5.1", "copyfiles": "^2.2.0", - "cz-conventional-changelog": "^3.3.0", "del-cli": "^3.0.0", - "husky": "^7.0.0", "nexe": "^3.3.7", "nodemon": "^2.0.3", "watchify": "^3.11.1" @@ -61,10 +59,5 @@ "plugins": [ "@babel/plugin-proposal-class-properties" ] - }, - "config": { - "commitizen": { - "path": "./node_modules/cz-conventional-changelog" - } } } diff --git a/resources/black.png b/resources/black.png new file mode 100644 index 0000000..72bdc8d Binary files /dev/null and b/resources/black.png differ diff --git a/src/channel-cache.js b/src/channel-cache.js index 302510a..9485ffb 100644 --- a/src/channel-cache.js +++ b/src/channel-cache.js @@ -1,7 +1,7 @@ const SLACK = require('./constants').SLACK; let cache = {}; -let programPlayTimeCache = {}; + let fillerPlayTimeCache = {}; let configCache = {}; let numbers = null; @@ -14,11 +14,9 @@ async function getChannelConfig(channelDB, channelId) { if (channel == null) { configCache[channelId] = []; } else { - //console.log("channel=" + JSON.stringify(channel) ); configCache[channelId] = [channel]; } } - //console.log("channel=" + JSON.stringify(configCache[channelId]).slice(0,200) ); return configCache[channelId]; } @@ -106,7 +104,7 @@ function getCurrentLineupItem(channelId, t1) { return lineupItem; } -function getKey(channelId, program) { +function getProgramKey(program) { let serverKey = "!unknown!"; if (typeof(program.serverKey) !== 'undefined') { if (typeof(program.serverKey) !== 'undefined') { @@ -117,36 +115,37 @@ function getKey(channelId, program) { if (typeof(program.key) !== 'undefined') { programKey = program.key; } - return channelId + "|" + serverKey + "|" + programKey; - + return serverKey + "|" + programKey; } + function getFillerKey(channelId, fillerId) { return channelId + "|" + fillerId; } -function recordProgramPlayTime(channelId, lineupItem, t0) { +function recordProgramPlayTime(programPlayTime, channelId, lineupItem, t0) { let remaining; if ( typeof(lineupItem.streamDuration) !== 'undefined') { remaining = lineupItem.streamDuration; } else { remaining = lineupItem.duration - lineupItem.start; } - programPlayTimeCache[ getKey(channelId, lineupItem) ] = t0 + remaining; + setProgramLastPlayTime(programPlayTime, channelId, lineupItem, t0 + remaining); if (typeof(lineupItem.fillerId) !== 'undefined') { fillerPlayTimeCache[ getFillerKey(channelId, lineupItem.fillerId) ] = t0 + remaining; } } -function getProgramLastPlayTime(channelId, program) { - let v = programPlayTimeCache[ getKey(channelId, program) ]; - if (typeof(v) === 'undefined') { - return 0; - } else { - return v; - } +function setProgramLastPlayTime(programPlayTime, channelId, lineupItem, t) { + let programKey = getProgramKey(lineupItem); + programPlayTime.update(channelId, programKey, t); +} + +function getProgramLastPlayTime(programPlayTime, channelId, program) { + let programKey = getProgramKey(program); + return programPlayTime.getProgramLastPlayTime(channelId, programKey); } function getFillerLastPlayTime(channelId, fillerId) { @@ -158,8 +157,8 @@ function getFillerLastPlayTime(channelId, fillerId) { } } -function recordPlayback(channelId, t0, lineupItem) { - recordProgramPlayTime(channelId, lineupItem, t0); +function recordPlayback(programPlayTime, channelId, t0, lineupItem) { + recordProgramPlayTime(programPlayTime, channelId, lineupItem, t0); cache[channelId] = { t0: t0, diff --git a/src/constants.js b/src/constants.js index 5865e39..711202a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,6 +5,13 @@ module.exports = { TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000, TOO_FREQUENT: 1000, + // Duration of things like the loading screen and the interlude (the black + // frame that appears between videos). The goal of these things is to + // prevent the video from getting stuck on the last second, which looks bad + // for some reason ~750 works well. I raised the fps to 60 and now 420 works + // but I wish it was lower. + GAP_DURATION: 10*42, + //when a channel is forcibly stopped due to an update, let's mark it as active // for a while during the transaction just in case. CHANNEL_STOP_SHIELD : 5000, @@ -28,5 +35,5 @@ module.exports = { // staying active, it checks every 5 seconds PLAYED_MONITOR_CHECK_FREQUENCY: 5*1000, - VERSION_NAME: "1.5.0" + VERSION_NAME: "1.5.1" } diff --git a/src/dao/program-play-time-db.js b/src/dao/program-play-time-db.js new file mode 100644 index 0000000..f285e74 --- /dev/null +++ b/src/dao/program-play-time-db.js @@ -0,0 +1,90 @@ +const path = require('path'); +var fs = require('fs'); + +class ProgramPlayTimeDB { + + constructor(dir) { + this.dir = dir; + this.programPlayTimeCache = {}; + } + + + async load() { + try { + if (! (await fs.promises.stat(this.dir)).isDirectory()) { + return; + } + } catch (err) { + return; + } + let files = await fs.promises.readdir(this.dir); + + let processSubFileName = async (fileName, subDir, subFileName) => { + try { + if (subFileName.endsWith(".json")) { + let programKey64 = subFileName.substring( + 0, + subFileName.length - 4 + ); + let programKey = Buffer.from(programKey64, 'base64') + .toString('utf-8'); + + + let filePath = path.join(subDir, subFileName); + let fileContent = await fs.promises.readFile( + filePath, 'utf-8'); + let jsonData = JSON.parse(fileContent); + let key = getKey(fileName, programKey); + this.programPlayTimeCache[ key ] = jsonData["t"] + } + } catch (err) { + console.log(`When processing ${subDir}/${subFileName}`, err); + } + } + + let processFileName = async(fileName) => { + + try { + const subDir = path.join(this.dir, fileName); + let subFiles = await fs.promises.readdir( subDir ); + + await Promise.all( subFiles.map( async subFileName => { + return processSubFileName(fileName, subDir, subFileName); + }) ); + } catch (err) { + console.log(`When processing ${subDir}`, err); + } + } + + await Promise.all( files.map(processFileName) ); + } + + getProgramLastPlayTime(channelId, programKey) { + let v = this.programPlayTimeCache[ getKey(channelId, programKey) ]; + if (typeof(v) === 'undefined') { + v = 0; + } + return v; + } + + async update(channelId, programKey, t) { + + let key = getKey(channelId, programKey); + this.programPlayTimeCache[ key ] = t; + + const channelDir = path.join(this.dir, `${channelId}`); + await fs.promises.mkdir( channelDir, { recursive: true } ); + let key64 = Buffer.from(programKey, 'utf-8').toString('base64'); + let filepath = path.join(channelDir, `${key64}.json`); + let data = {t:t}; + await fs.promises.writeFile(filepath, JSON.stringify(data), 'utf-8'); + } + +} + +function getKey(channelId, programKey) { + return channelId + "|" + programKey; +} + + +module.exports = ProgramPlayTimeDB; \ No newline at end of file diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 00ace6c..779e379 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -198,8 +198,9 @@ class FFMPEG extends events.EventEmitter { iW = this.wantedW; iH = this.wantedH; + let durstr = `duration=${streamStats.duration}ms`; + if (this.audioOnly !== true) { - ffmpegArgs.push("-r" , "24"); let pic = null; //does an image to play exist? @@ -216,6 +217,11 @@ class FFMPEG extends events.EventEmitter { } if (pic != null) { + if (this.opts.noRealTime === true) { + ffmpegArgs.push("-r" , "60"); + } else { + ffmpegArgs.push("-r" , "24"); + } ffmpegArgs.push( '-i', pic, ); @@ -230,11 +236,17 @@ class FFMPEG extends events.EventEmitter { 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]`; + videoComplex += `;[padded]loop=loop=-1:size=1:start=0`; + if (this.opts.noRealTime !== true) { + videoComplex +=`[looped];[looped]realtime[videox]`; + } else { + videoComplex +=`[videox]` + } //this tune apparently makes the video compress better // when it is the same image stillImage = true; + this.volumePercent = Math.min(70, this.volumePercent); + } else if (this.opts.errorScreen == 'static') { ffmpegArgs.push( '-f', 'lavfi', @@ -269,7 +281,7 @@ class FFMPEG extends events.EventEmitter { videoComplex = `;realtime[videox]`; } } - let durstr = `duration=${streamStats.duration}ms`; + if (typeof(streamUrl.errorTitle) !== 'undefined') { //silent audioComplex = `;aevalsrc=0:${durstr}[audioy]`; @@ -471,7 +483,8 @@ class FFMPEG extends events.EventEmitter { `-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'), `-sc_threshold`, `1000000000`, ); - if (stillImage) { + // do not use -tune stillimage for nv + if (stillImage && ! this.opts.videoEncoder.toLowerCase().includes("nv") ) { ffmpegArgs.push('-tune', 'stillimage'); } } @@ -482,7 +495,6 @@ class FFMPEG extends events.EventEmitter { if ( transcodeVideo && (this.audioOnly !== true) ) { // add the video encoder flags ffmpegArgs.push( - `-b:v`, `${this.opts.videoBitrate}k`, `-maxrate:v`, `${this.opts.videoBitrate}k`, `-bufsize:v`, `${this.opts.videoBufSize}k` ); @@ -549,6 +561,7 @@ class FFMPEG extends events.EventEmitter { if (this.hasBeenKilled) { return ; } + //console.log(this.ffmpegPath + " " + ffmpegArgs.join(" ") ); this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } ); if (this.hasBeenKilled) { console.log("Send SIGKILL to ffmpeg"); diff --git a/src/helperFuncs.js b/src/helperFuncs.js index 98e7552..1d905f9 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -6,8 +6,10 @@ module.exports = { } let channelCache = require('./channel-cache'); +const INFINITE_TIME = new Date().getTime() + 10*365*24*60*60*1000; //10 years from the initialization of the server. I dunno, I just wanted it to be a high time without it stopping being human readable if converted to date. const SLACK = require('./constants').SLACK; const randomJS = require("random-js"); +const quickselect = require("quickselect"); const Random = randomJS.Random; const random = new Random( randomJS.MersenneTwister19937.autoSeed() ); @@ -61,7 +63,7 @@ function getCurrentProgramAndTimeElapsed(date, channel) { return { program: channel.programs[currentProgramIndex], timeElapsed: timeElapsed, programIndex: currentProgramIndex } } -function createLineup(obj, channel, fillers, isFirst) { +function createLineup(programPlayTime, obj, channel, fillers, isFirst) { let timeElapsed = obj.timeElapsed // Start time of a file is never consistent unless 0. Run time of an episode can vary. // When within 30 seconds of start time, just make the time 0 to smooth things out @@ -96,7 +98,7 @@ function createLineup(obj, channel, fillers, isFirst) { if ( (channel.offlineMode === 'clip') && (channel.fallback.length != 0) ) { special = JSON.parse(JSON.stringify(channel.fallback[0])); } - let randomResult = pickRandomWithMaxDuration(channel, fillers, remaining + (isFirst? (7*24*60*60*1000) : 0) ); + let randomResult = pickRandomWithMaxDuration(programPlayTime, channel, fillers, remaining + (isFirst? (7*24*60*60*1000) : 0) ); filler = randomResult.filler; if (filler == null && (typeof(randomResult.minimumWait) !== undefined) && (remaining > randomResult.minimumWait) ) { remaining = randomResult.minimumWait; @@ -178,7 +180,7 @@ function weighedPick(a, total) { return random.bool(a, total); } -function pickRandomWithMaxDuration(channel, fillers, maxDuration) { +function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuration) { let list = []; for (let i = 0; i < fillers.length; i++) { list = list.concat(fillers[i].content); @@ -194,16 +196,36 @@ function pickRandomWithMaxDuration(channel, fillers, maxDuration) { } let listM = 0; let fillerId = undefined; - for (let j = 0; j < fillers.length; j++) { + + for (let medianCheck = 1; medianCheck >= 0; medianCheck--) { + for (let j = 0; j < fillers.length; j++) { list = fillers[j].content; let pickedList = false; let n = 0; + let maximumPlayTimeAllowed = INFINITE_TIME; + if (medianCheck==1) { + //calculate the median + let median = getFillerMedian(programPlayTime, channel, fillers[j]); + if (median > 0) { + maximumPlayTimeAllowed = median - 1; + // allow any clip with a play time that's less than the median. + } else { + // initially all times are 0, so if the median is 0, all of those + // are allowed. + maximumPlayTimeAllowed = 0; + } + } + + for (let i = 0; i < list.length; i++) { let clip = list[i]; // a few extra milliseconds won't hurt anyone, would it? dun dun dun if (clip.duration <= maxDuration + SLACK ) { - let t1 = channelCache.getProgramLastPlayTime( channel.number, clip ); + let t1 = channelCache.getProgramLastPlayTime(programPlayTime, channel.number, clip ); + if (t1 > maximumPlayTimeAllowed) { + continue; + } let timeSince = ( (t1 == 0) ? D : (t0 - t1) ); if (timeSince < channel.fillerRepeatCooldown - SLACK) { @@ -247,11 +269,13 @@ function pickRandomWithMaxDuration(channel, fillers, maxDuration) { } } } + } + if (pick1 != null) { + break; + } } let pick = pick1; - let pickTitle = "null"; if (pick != null) { - pickTitle = pick.title; pick = JSON.parse( JSON.stringify(pick) ); pick.fillerId = fillerId; } @@ -322,6 +346,26 @@ function getWatermark( ffmpegSettings, channel, type) { } +function getFillerMedian(programPlayTime, channel, filler) { + + let times = []; + list = filler.content; + for (let i = 0; i < list.length; i++) { + let clip = list[i]; + let t = channelCache.getProgramLastPlayTime(programPlayTime, channel.number, clip); + times.push(t); + } + + if (times.length <= 1) { + //if there are too few elements, the protection is not helpful. + return INFINITE_TIME; + } + let m = Math.floor(times.length / 2); + quickselect(times, m) + return times[m]; + +} + function generateChannelContext(channel) { let channelContext = {}; for (let i = 0; i < CHANNEL_CONTEXT_KEYS.length; i++) { diff --git a/src/offline-player.js b/src/offline-player.js index 38d846e..9140041 100644 --- a/src/offline-player.js +++ b/src/offline-player.js @@ -18,6 +18,11 @@ class OfflinePlayer { context.channel.offlinePicture = `http://localhost:${process.env.PORT}/images/loading-screen.png`; context.channel.offlineSoundtrack = undefined; } + if (context.isInterlude === true) { + context.channel = JSON.parse( JSON.stringify(context.channel) ); + context.channel.offlinePicture = `http://localhost:${process.env.PORT}/images/black.png`; + context.channel.offlineSoundtrack = undefined; + } this.ffmpeg = new FFMPEG(context.ffmpegSettings, context.channel); this.ffmpeg.setAudioOnly(this.context.audioOnly); } diff --git a/src/program-player.js b/src/program-player.js index 260ff10..d7ceea4 100644 --- a/src/program-player.js +++ b/src/program-player.js @@ -34,6 +34,7 @@ class ProgramPlayer { // people might want the codec normalization to stay because of player support context.ffmpegSettings.normalizeResolution = false; } + context.ffmpegSettings.noRealTime = program.noRealTime; if ( typeof(program.err) !== 'undefined') { console.log("About to play error stream"); this.delegate = new OfflinePlayer(true, context); @@ -42,6 +43,11 @@ class ProgramPlayer { /* loading */ context.isLoading = true; this.delegate = new OfflinePlayer(false, context); + } else if (program.type === 'interlude') { + console.log("About to play interlude stream"); + /* interlude */ + context.isInterlude = true; + this.delegate = new OfflinePlayer(false, context); } else if (program.type === 'offline') { console.log("About to play offline stream"); /* offline */ diff --git a/src/throttler.js b/src/throttler.js index ac42a11..10e4c67 100644 --- a/src/throttler.js +++ b/src/throttler.js @@ -7,7 +7,9 @@ function equalItems(a, b) { if ( (typeof(a) === 'undefined') || a.isOffline || b.isOffline ) { return false; } - return ( a.type === b.type); + console.log("no idea how to compare this: " + JSON.stringify(a) ); + console.log(" with this: " + JSON.stringify(b) ); + return a.title === b.title; } @@ -17,15 +19,14 @@ function wereThereTooManyAttempts(sessionId, lineupItem) { let t1 = (new Date()).getTime(); let previous = cache[sessionId]; + let result = false; + if (typeof(previous) === 'undefined') { previous = cache[sessionId] = { t0: t1 - constants.TOO_FREQUENT * 5, lineupItem: null, }; - } - - let result = false; - if (t1 - previous.t0 < constants.TOO_FREQUENT) { + } else if (t1 - previous.t0 < constants.TOO_FREQUENT) { //certainly too frequent result = equalItems( previous.lineupItem, lineupItem ); } @@ -49,4 +50,4 @@ function wereThereTooManyAttempts(sessionId, lineupItem) { } -module.exports = wereThereTooManyAttempts; \ No newline at end of file +module.exports = wereThereTooManyAttempts; diff --git a/src/video.js b/src/video.js index fb6fefa..c21481c 100644 --- a/src/video.js +++ b/src/video.js @@ -18,7 +18,7 @@ async function shutdown() { stopPlayback = true; } -function video( channelService, fillerDB, db, programmingService, activeChannelService ) { +function video( channelService, fillerDB, db, programmingService, activeChannelService, programPlayTimeDB ) { var router = express.Router() router.get('/setup', (req, res) => { @@ -51,7 +51,10 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS }) }) // Continuously stream video to client. Leverage ffmpeg concat for piecing together videos - let concat = async (req, res, audioOnly) => { + let concat = async (req, res, audioOnly, step) => { + if ( typeof(step) === 'undefined') { + step = 0; + } if (stopPlayback) { res.status(503).send("Server is shutting down.") return; @@ -78,9 +81,11 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS return } - res.writeHead(200, { - 'Content-Type': 'video/mp2t' - }) + if (step == 0) { + res.writeHead(200, { + 'Content-Type': 'video/mp2t' + }) + } console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`) @@ -107,7 +112,7 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS return; }) - ffmpeg.on('close', stop) + //ffmpeg.on('close', stop) res.on('close', () => { // on HTTP close, kill ffmpeg console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`); @@ -115,13 +120,13 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS }) ffmpeg.on('end', () => { - console.log("Video queue exhausted. Either you played 100 different clips in a row or there were technical issues that made all of the possible 100 attempts fail.") - stop(); + console.log("Queue exhausted so we are appending the channel stream again to the http output.") + concat(req, res, audioOnly, step+1); }) let channelNum = parseInt(req.query.channel, 10) - let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}&audioOnly=${audioOnly}`); - ff.pipe(res ); + let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}&audioOnly=${audioOnly}&stepNumber={step}`); + ff.pipe(res, { end: false} ); }; router.get('/video', async(req, res) => { return await concat(req, res, false); @@ -167,6 +172,8 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS isFirst = true; } + let isBetween = ( (typeof req.query.between !== 'undefined') && (req.query.between=='1') ); + let ffmpegSettings = db['ffmpeg-settings'].find()[0] // Check if ffmpeg path is valid @@ -180,20 +187,37 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS // Get video lineup (array of video urls with calculated start times and durations.) - let lineupItem = channelCache.getCurrentLineupItem( channel.number, t0); + let prog = null; let brandChannel = channel; let redirectChannels = []; let upperBounds = []; + const GAP_DURATION = constants.GAP_DURATION; if (isLoading) { lineupItem = { type: 'loading', - streamDuration: 40, - duration: 40, + title: "Loading Screen", + noRealTime: true, + streamDuration: GAP_DURATION, + duration: GAP_DURATION, + redirectChannels: [channel], start: 0, }; - } else if (lineupItem != null) { + } else if (isBetween) { + lineupItem = { + type: 'interlude', + title: "Interlude Screen", + noRealTime: true, + streamDuration: GAP_DURATION, + duration: GAP_DURATION, + redirectChannels: [channel], + start: 0, + }; + } else { + lineupItem = channelCache.getCurrentLineupItem( channel.number, t0); + } + if (lineupItem != null) { redirectChannels = lineupItem.redirectChannels; upperBounds = lineupItem.upperBounds; brandChannel = redirectChannels[ redirectChannels.length -1]; @@ -208,7 +232,8 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS if ( !(prog.program.isOffline) || (prog.program.type != 'redirect') ) { break; } - channelCache.recordPlayback( brandChannel.number, t0, { + channelCache.recordPlayback(programPlayTimeDB, + brandChannel.number, t0, { /*type: 'offline',*/ title: 'Error', err: Error("Recursive channel redirect found"), @@ -274,11 +299,20 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS throw "No video to play, this means there's a serious unexpected bug or the channel db is corrupted." } let fillers = await fillerDB.getFillersFromChannel(brandChannel); - let lineup = helperFuncs.createLineup(prog, brandChannel, fillers, isFirst) - lineupItem = lineup.shift(); + try { + let lineup = helperFuncs.createLineup(programPlayTimeDB, prog, brandChannel, fillers, isFirst) + lineupItem = lineup.shift(); + } catch (err) { + console.log("Error when attempting to pick video: " +err.stack); + lineupItem = { + isOffline: true, + err: err, + duration : 60000, + }; + } } - if ( !isLoading && (lineupItem != null) ) { + if ( !isBetween && !isLoading && (lineupItem != null) ) { let upperBound = 1000000000; let beginningOffset = 0; if (typeof(lineupItem.beginningOffset) !== 'undefined') { @@ -298,10 +332,13 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS lineupItem.streamDuration = Math.min(u2, u); upperBound = lineupItem.streamDuration; } - channelCache.recordPlayback( redirectChannels[i].number, t0, lineupItem ); + channelCache.recordPlayback( programPlayTimeDB, redirectChannels[i].number, t0, lineupItem ); } } + let t2 = (new Date()).getTime(); + console.log( `Decision Latency: (${t2-t0})ms` ); + console.log("========================================================="); console.log("! Start playback"); @@ -317,8 +354,8 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS } console.log("========================================================="); - if (! isLoading) { - channelCache.recordPlayback(channel.number, t0, lineupItem); + if (! isLoading && ! isBetween) { + channelCache.recordPlayback(programPlayTimeDB, channel.number, t0, lineupItem); } if (wereThereTooManyAttempts(session, lineupItem)) { console.error("There are too many attempts to play the same item in a short period of time, playing the error stream instead."); @@ -363,7 +400,7 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS try { playerObj = await player.play(res); t1 = (new Date()).getTime(); - console.log("Latency: (" + (t1- t0) ); + console.log( `Player Latency: (${t1-t0})ms` ); } catch (err) { console.log("Error when attempting to play video: " +err.stack); try { @@ -523,6 +560,10 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS return } + let stepNumber = parseInt(req.query.stepNumber, 10) + if (isNaN(stepNumber)) { + stepNumber = 0; + } let channelNum = parseInt(req.query.channel, 10) let channel = await channelService.getChannel(channelNum ); if (channel == null) { @@ -541,20 +582,35 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS let sessionId = StreamCount++; let audioOnly = ("true" == req.query.audioOnly); - if ( - (ffmpegSettings.enableFFMPEGTranscoding === true) + let transcodingEnabled = (ffmpegSettings.enableFFMPEGTranscoding === true) && (ffmpegSettings.normalizeVideoCodec === true) && (ffmpegSettings.normalizeAudioCodec === true) && (ffmpegSettings.normalizeResolution === true) - && (ffmpegSettings.normalizeAudio === true) + && (ffmpegSettings.normalizeAudio === true); + + if ( + transcodingEnabled && (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) */ + && (stepNumber == 0) ) { //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}&audioOnly=${audioOnly}'\n` - for (var i = 0; i < maxStreamsToPlayInARow - 1; i++) { + let remaining = maxStreamsToPlayInARow; + if (stepNumber == 0) { + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=1&session=${sessionId}&audioOnly=${audioOnly}'\n` + + if (transcodingEnabled && (audioOnly !== true)) { + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&between=1&session=${sessionId}&audioOnly=${audioOnly}'\n`; + } + remaining--; + } + + for (var i = 0; i < remaining; i++) { data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&session=${sessionId}&audioOnly=${audioOnly}'\n` + if (transcodingEnabled && (audioOnly !== true) ) { + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&between=1&session=${sessionId}&audioOnly=${audioOnly}'\n` + } } res.send(data) diff --git a/web/controllers/custom-shows.js b/web/controllers/custom-shows.js index d3b9378..a593d16 100644 --- a/web/controllers/custom-shows.js +++ b/web/controllers/custom-shows.js @@ -8,6 +8,10 @@ module.exports = function ($scope, $timeout, dizquetv) { $scope.shows = [ { id: '?', pending: true} ] $timeout(); let shows = await dizquetv.getAllShowsInfo(); + shows.sort( (a,b) => { + return a.name > b.name; + } ); + $scope.shows = shows; $timeout(); } diff --git a/web/controllers/filler.js b/web/controllers/filler.js index 0fd821b..773f47e 100644 --- a/web/controllers/filler.js +++ b/web/controllers/filler.js @@ -8,13 +8,14 @@ module.exports = function ($scope, $timeout, dizquetv) { $scope.fillers = [ { id: '?', pending: true} ] $timeout(); let fillers = await dizquetv.getAllFillersInfo(); + fillers.sort( (a,b) => { + return a.name > b.name; + } ); $scope.fillers = fillers; $timeout(); } $scope.refreshFiller(); - - let feedToFillerConfig = () => {}; let feedToDeleteFiller = feedToFillerConfig; diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index 41a0f35..d8da0fe 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -11,7 +11,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get }, link: { - post: function (scope, element, attrs) { + post: function (scope, $element, attrs) { scope.screenW = 1920; scope.screenh = 1080; @@ -326,9 +326,10 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get duration: duration, isOffline: true } - scope.channel.programs.splice(scope.minProgramIndex, 0, program); + scope.channel.programs.splice(scope.channel.programs.length, 0, program); scope._selectedOffline = null scope._addingOffline = null; + scrollToLast(); updateChannelDuration() } @@ -1077,11 +1078,37 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get } } - scope.importPrograms = (selectedPrograms) => { + + function getAllMethods(object) { + + return Object.getOwnPropertyNames(object).filter(function (p) { + return typeof object[p] == 'function'; + }); + } + function scrollToLast() { + var programListElement = document.getElementById("channelConfigProgramList"); + $timeout(() => { programListElement.scrollTo(0, 2000000); }, 0) + } + scope.importPrograms = (selectedPrograms, insertPoint) => { for (let i = 0, l = selectedPrograms.length; i < l; i++) { delete selectedPrograms[i].commercials; } - scope.channel.programs = scope.channel.programs.concat(selectedPrograms) + + var programListElement = document.getElementById("channelConfigProgramList"); + if (insertPoint === "start") { + scope.channel.programs = selectedPrograms.concat(scope.channel.programs); + programListElement.scrollTo(0, 0); + } else if (insertPoint === "current") { + scope.channel.programs = [ + ...scope.channel.programs.slice(0, scope.currentStartIndex), + ...selectedPrograms, + ...scope.channel.programs.slice(scope.currentStartIndex) + ]; + } else { + scope.channel.programs = scope.channel.programs.concat(selectedPrograms) + + scrollToLast(); + } updateChannelDuration() setTimeout( () => { @@ -1093,7 +1120,9 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get } scope.finishRedirect = (program) => { if (scope.selectedProgram == -1) { - scope.channel.programs.splice(scope.minProgramIndex, 0, program); + scope.channel.programs.splice(scope.channel.programs.length, 0, program); + scrollToLast(); + } else { scope.channel.programs[ scope.selectedProgram ] = program; } diff --git a/web/directives/plex-library.js b/web/directives/plex-library.js index 23f5e25..153b2e2 100644 --- a/web/directives/plex-library.js +++ b/web/directives/plex-library.js @@ -6,6 +6,7 @@ module.exports = function (plex, dizquetv, $timeout, commonProgramTools) { scope: { onFinish: "=onFinish", height: "=height", + positionChoice: "=positionChoice", visible: "=visible", limit: "=limit", }, @@ -14,6 +15,7 @@ module.exports = function (plex, dizquetv, $timeout, commonProgramTools) { if ( typeof(scope.limit) == 'undefined') { scope.limit = 1000000000; } + scope.insertPoint = "end"; scope.customShows = []; scope.origins = []; scope.currentOrigin = undefined; @@ -37,7 +39,7 @@ module.exports = function (plex, dizquetv, $timeout, commonProgramTools) { updateCustomShows(); } } - scope._onFinish = (s) => { + scope._onFinish = (s, insertPoint) => { if (s.length > scope.limit) { if (scope.limit == 1) { scope.error = "Please select only one clip."; @@ -45,7 +47,7 @@ module.exports = function (plex, dizquetv, $timeout, commonProgramTools) { scope.error = `Please select at most ${scope.limit} clips.`; } } else { - scope.onFinish(s) + scope.onFinish(s, insertPoint) scope.selection = [] scope.visible = false } @@ -69,7 +71,7 @@ module.exports = function (plex, dizquetv, $timeout, commonProgramTools) { } } scope.selectLibrary = async (library) => { - await scope.fillNestedIfNecessary(library); + await scope.fillNestedIfNecessary(library, true); let p = library.nested.length; scope.pending += library.nested.length; try { diff --git a/web/public/style.css b/web/public/style.css index 0ad24b1..4f63c88 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -390,4 +390,8 @@ div.programming-programs div.list-group-item { width: 1em; height: 1em; margin-bottom: 0.25em; +} + +.list-group-item .library-item-hover:hover { + background: #D0D0FF } \ No newline at end of file diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 97dcddb..081300d 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -170,6 +170,7 @@ ng-init="setUpWatcher()" ng-if="true" ng-style="{'max-height':programmingHeight()}" + id="channelConfigProgramList" >
- + diff --git a/web/public/templates/plex-library.html b/web/public/templates/plex-library.html index 5fd6218..adc2f58 100644 --- a/web/public/templates/plex-library.html +++ b/web/public/templates/plex-library.html @@ -28,46 +28,59 @@
+
+ +

Selected Items
@@ -129,9 +150,21 @@
{{error}}
- diff --git a/web/services/plex.js b/web/services/plex.js index 35575ff..18913bc 100644 --- a/web/services/plex.js +++ b/web/services/plex.js @@ -283,7 +283,7 @@ module.exports = function ($http, $window, $interval) { console.error(msg , err); } } - if ( (includeCollections === true) && (res.viewGroup !== "artist" ) ) { + if (includeCollections === true) { let k = res.librarySectionID; k = `/library/sections/${k}/collections`;