Merge remote-tracking branch 'origin/dev/1.5.x' into edge

This commit is contained in:
vexorian 2023-11-26 22:18:12 -04:00
commit c360ddae05
32 changed files with 584 additions and 120 deletions

46
.github/workflows/binaries-build.yaml vendored Normal file
View File

@ -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 }}

View File

@ -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

13
.github/workflows/development-tag.yaml vendored Normal file
View File

@ -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

43
.github/workflows/docker-build.yaml vendored Normal file
View File

@ -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

13
.github/workflows/edge-tag.yaml vendored Normal file
View File

@ -0,0 +1,13 @@
name: Edge Tag
on:
push:
branches:
- edge
jobs:
docker:
uses: ./.github/workflows/docker-build.yaml
with:
tag: edge
secrets: inherit

13
.github/workflows/latest-tag.yaml vendored Normal file
View File

@ -0,0 +1,13 @@
name: Latest Tag
on:
push:
branches:
- main
jobs:
docker:
uses: ./.github/workflows/docker-build.yaml
with:
tag: latest
secrets: inherit

13
.github/workflows/named-tag.yaml vendored Normal file
View File

@ -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

13
.github/workflows/tag-binaries.yaml vendored Normal file
View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)

View File

@ -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"
}
}
}

BIN
resources/black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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,

View File

@ -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"
}

View File

@ -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;

View File

@ -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");

View File

@ -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++) {

View File

@ -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);
}

View File

@ -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 */

View File

@ -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;
module.exports = wereThereTooManyAttempts;

View File

@ -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)

View File

@ -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();
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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 {

View File

@ -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
}

View File

@ -170,6 +170,7 @@
ng-init="setUpWatcher()"
ng-if="true"
ng-style="{'max-height':programmingHeight()}"
id="channelConfigProgramList"
>
<div ng-repeat="x in channel.programs track by x.$index"
ng-click="selectProgram(x.$index)"
@ -938,7 +939,7 @@
<frequency-tweak programs="_programFrequencies" message="_frequencyMessage" modified="_frequencyModified" on-done="tweakFrequencies"></frequency-tweak>
<remove-shows program-infos="_removablePrograms" on-done="removeShows" deleted="_deletedProgramNames"></remove-shows>
<flex-config offline-title="Add Flex Time" program="_addingOffline" on-done="finishedAddingOffline"></flex-config>
<plex-library limit="libraryLimit" height="300" visible="displayPlexLibrary" on-finish="importPrograms"></plex-library>
<plex-library limit="libraryLimit" height="300" visible="displayPlexLibrary" position-choice=true on-finish="importPrograms"></plex-library>
<plex-library height="300" limit=1 visible="showFallbackPlexLibrary" on-finish="importFallback"></plex-library>
<channel-redirect visible="_displayRedirect" on-done="finishRedirect" form-title="_redirectTitle" program="_selectedRedirect" ></channel-redirect>
<time-slots-schedule-editor linker="registerTimeSlots" on-done="onTimeSlotsDone"></time-slots-schedule-editor>

View File

@ -28,46 +28,59 @@
</span>
</div>
<div class="modal-body">
<select class="form-control form-control-sm custom-select" ng-model="currentOrigin"
<div class="mb-3">
<label for="source-selector" class="form-label">Source:</label>
<select class="form-select form-select-sm custom-select" ng-model="currentOrigin"
size="2"
id="source-selector"
ng-options="x.name for x in origins" ng-change="selectOrigin(currentOrigin)"></select>
<hr ></hr>
</div>
<div class="mb-3">
<label class="form-label">
<button class="btn btn-sm btn-link" ng-click="selectOrigin(currentOrigin)">
<span class="text-info fa fa-sync" ></span>
</button>
Content:
</label>
<ul ng-show="currentOrigin.type=='plex' " class="list-group list-group-root plex-panel" ng-init="setHeight = {'height': height + 'px'}" ng-style="setHeight" lazy-img-container>
<li class="list-group-item" ng-repeat="a in libraries">
<div class="flex-container {{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a, true);">
<div class="flex-container library-item-hover {{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a, true);">
<span class="fa {{ a.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
<img ng-if="displayImages" lazy-img="{{a.icon}}" ></img>
<span>{{ displayTitle(a) }}</span><!-- Library -->
<span ng-if="a.type === 'show' || a.type === 'movie' || a.type === 'artist'" class="flex-pull-right" ng-click='$event.stopPropagation(); selectLibrary(a)'>
<span class="fa fa-plus btn"></span>
<span class="fa fa-plus-circle btn"></span>
</span>
</div>
<ul ng-if="a.collapse" class="list-group">
<li class="list-group-item {{ b.type !== 'movie' ? 'list-group-item-secondary' : 'list-group-item-video' }}"
ng-repeat="b in a.nested">
<div class="flex-container"
<div class="flex-container library-item-hover"
ng-click="b.type !== 'movie' ? getNested(b) : selectItem(b, true)">
<span ng-if="b.type === 'movie'" class="fa fa-plus-circle tab"></span>
<span ng-if="b.type !== 'movie'" class="tab"></span>
<span ng-if="b.type !== 'movie'" class="fa {{ b.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
<img ng-if="displayImages" lazy-img="{{ b.type === 'episode' ? b.episodeIcon : b.icon }}" ></img>
{{ displayTitle(b) }}
<span ng-if="b.type === 'movie'" class="flex-pull-right">
<span class="flex-grow-1">{{ displayTitle(b) }}</span>
<span ng-if="b.type === 'movie'" class="">
{{b.durationStr}}
</span>
<span class="flex-pull-right" ng-if="b.type === 'movie'">
<span class="fa fa-plus-circle btn"></span>
</span>
<span ng-if="b.type === 'playlist'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectPlaylist(b);">
<span class="fa fa-plus btn"></span>
<span class="fa fa-plus-circle btn"></span>
</span>
<span ng-if="b.type === 'show' || b.type === 'collection' || b.type === 'genre' || b.type === 'artist'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectShow(b);">
<span class="fa fa-plus btn"></span>
<span class="fa fa-plus-circle btn"></span>
</span>
</div>
<ul ng-if="b.collapse" class="list-group">
<li ng-repeat="c in b.nested"
class="list-group-item {{ c.type !== 'movie' && c.type !== 'episode' && c.type !== 'track' ? 'list-group-item-dark' : 'list-group-item-video' }}">
<div class="flex-container"
<div class="flex-container library-item-hover"
ng-click="c.type !== 'movie' && c.type !== 'episode' && c.type !== 'track' ? getNested(c) : selectItem(c, true)">
<span ng-if="c.type === 'movie' || c.type === 'episode' || c.type === 'track'"
class="fa fa-plus-circle tab"></span>
<span ng-if="c.type !== 'movie' && c.type !== 'episode' && c.type !== 'track'"
class="tab"></span>
<span ng-if="c.type !== 'movie' && c.type !== 'episode' && c.type !== 'track'"
@ -75,23 +88,30 @@
<span ng-if="c.type !== 'movie' && c.type !== 'episode' && c.type !== 'track'"
class="fa {{ c.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
<img ng-if="displayImages" lazy-img="{{c.type === 'episode' ? c.episodeIcon : c.icon }}" ></img>
{{ displayTitle(c) }}
<span class="flex-grow-1">{{ displayTitle(c) }}</span>
<span ng-if="c.type === 'movie' || c.type === 'episode' || c.type === 'track' "
class="flex-pull-right">
class="">
{{c.durationStr}}
</span>
<span ng-if="c.type === 'movie' || c.type === 'episode' || c.type === 'track'" class="flex-pull-right">
<span
class="fa fa-plus-circle btn"></span>
</span>
<span ng-if="c.type === 'season' || c.type === 'album'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectSeason(c);">
<span class="fa fa-plus btn"></span>
<span class="fa fa-plus-circle btn"></span>
</span>
</div>
<ul ng-if="c.collapse" class="list-group">
<li class="list-group-item list-group-item-video"
ng-repeat="d in c.nested">
<div class="flex-container" ng-click="selectItem(d, true)">
<span class="fa fa-plus-circle tab"></span>
<div class="flex-container library-item-hover" ng-click="selectItem(d, true)">
<img ng-if="displayImages" lazy-img="{{d.episodeIcon}}" ></img>
{{ displayTitle(d) }}
<span class="flex-pull-right">{{d.durationStr}}</span>
<span class="flex-grow-1">{{ displayTitle(d) }}</span>
<span class="">{{d.durationStr}}</span>
<span class="flex-pull-right">
<span class="fa fa-plus-circle btn"></span>
</span>
<!-- Episode -->
</div>
</li>
@ -111,6 +131,7 @@
</div>
</li>
</ul>
</div>
<hr></hr>
<div class="loader" ng-if="pending &gt; 0" ></div> <h6 style='display:inline-block'>Selected Items</h6>
@ -129,9 +150,21 @@
</ul>
</div>
<div class='text-danger'>{{error}}</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-link" ng-click="_onFinish([])">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="_onFinish(selection);">Done</button>
<div class="modal-footer flex">
<div class="flex-grow-1" ng-show="positionChoice === true"">
<select class="form-select form-select-sm custom-select" ng-model="insertPoint"
id="position-selector">
<option value="end">Insert at the end of list</option>
<option value="start">Insert at the beginning of list</option>
<option value="current">Insert at current scroll position</option>
</select>
</div>
<div><button type="button" class="btn btn-sm btn-link" ng-click="_onFinish([])">Cancel</button></div>
<div><button type="button" class="btn btn-sm btn-primary" ng-click="_onFinish(selection, insertPoint);">Done</button></div>
</div>
</div>
</div>

View File

@ -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`;