Merge branch 'edge' into main

This commit is contained in:
vexorian 2021-05-30 08:43:24 -04:00
commit 535b6cec5d
76 changed files with 6140 additions and 1198 deletions

View File

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

View File

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

View File

@ -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).
<img src="./resources/dizquetv.png" width="200">
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/resources/dizquetv.png" width="200">
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
<img src="./docs/channels.png" width="500">
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/docs/channels.png" width="500">
<br/>
<img src="./docs/channel-config.png" width="500">
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/docs/channel-config.png" width="500">
<br/>
<img src="./docs/plex-guide.png" width="500">
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/docs/plex-guide.png" width="500">
<br/>
<img src="./docs/plex-stream.png" width="500">
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/docs/plex-stream.png" width="500">
## Development
Building/Packaging Binaries: (uses `browserify`, `babel` and `pkg`)

View File

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

1462
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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": [

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because it is too large Load Diff

View File

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

View File

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

131
src/dao/custom-show-db.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1920"
height="1080"
viewBox="0 0 507.99999 285.75001"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="generic-music-screen.svg"
inkscape:export-filename="/home/vx/dev/pseudotv/resources/generic-music-screen.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="4.4585776"
inkscape:cx="925.75604"
inkscape:cy="448.17449"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
units="px" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Capa 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-11.249983)">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.20000029;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect836"
width="508"
height="285.75"
x="0"
y="11.249983" />
<g
id="g6050"
transform="translate(-8.4960767,30.053154)">
<rect
transform="rotate(0.52601418)"
y="85.000603"
x="214.56714"
height="73.832573"
width="32.814484"
id="rect4518"
style="opacity:1;fill:#9cbc28;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
transform="rotate(1.4727575)"
style="opacity:1;fill:#289bbc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4520"
width="32.81448"
height="73.832573"
x="248.74632"
y="80.901688" />
<rect
transform="rotate(-3.2986121)"
y="103.78287"
x="269.35843"
height="73.832588"
width="32.814476"
id="rect4522"
style="opacity:1;fill:#bc289b;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:76.95687866px;line-height:125%;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="147.14322"
y="234.94209"
id="text838"
transform="scale(1.3642872,0.73298349)"><tspan
sodipodi:role="line"
id="tspan836"
x="147.14322"
y="234.94209"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:KacstPoster;-inkscape-font-specification:KacstPoster;stroke-width:0.26458335px">♪</tspan></text>
<ellipse
style="opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0.52916664;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path840"
cx="240.60326"
cy="169.0907"
rx="15.090722"
ry="15.089045" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
module.exports = function () {
}

73
web/controllers/player.js Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -33,6 +33,7 @@ module.exports = function ($timeout) {
z--;
}
scope.content.splice(z, 0, program );
refreshContentIndexes();
$timeout();
return false;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.14.0/css/all.min.css">
<link href="style.css" rel="stylesheet">
<link href="custom.css" rel="stylesheet">
<script src="version.js"></script>
<script src="bundle.js"></script>
</head>
@ -32,7 +33,7 @@
</a>
</small>
</h1>
<a href="#!/guide">Guide</a> - <a href="#!/channels">Channels</a> - <a href="#!/filler">Filler</a> - <a href="#!/settings">Settings</a> - <a href="#!/version">Version</a>
<a href="#!/guide">Guide</a> - <a href="#!/channels">Channels</a> - <a href="#!/library">Library</a> - <a href="#!/player">Player</a> - <a href="#!/settings">Settings</a> - <a href="#!/version">Version</a>
<span class="pull-right">
<span style="margin-right: 15px;">
<a href="/api/xmltv.xml">XMLTV <span class="far fa-file-code"></span></a>
@ -43,6 +44,7 @@
</span>
<hr/>
<div ng-view></div>
<toast-notifications></toast-notifications>
</div>
</body>

View File

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

View File

@ -21,24 +21,48 @@
</div>
<!-- ============= TAB: PROPERTIES ========================= -->
<div class="modal-body" ng-if="tab == 'basic'">
<div>
<span class="pull-right text-danger">{{error.number}}</span>
<label id="channelNumber" class="small">Ch. #</label>
<input for="channelNumber" class="form-control form-control-sm" type="number" ng-model="channel.number" />
<div class='form-group'>
<label class='form-label' >Channel Number:</label>
<input type="text" class='form-control' type='number' ng-model="channel.number" id='channelNumber' aria-describedby="channelNumberHelp" />
<small id='channelNumberHelp' class="text-danger" for='channelNumber'>{{error.number}}</small>
</div>
<div>
<span class="pull-right text-danger">{{error.name}}</span>
<label id="channelName" class="small">Channel Name</label>
<input for="channelName" class="form-control form-control-sm" type="text" ng-model="channel.name" />
<div class='form-group'>
<label class='form-label' >Channel Name:</label>
<input type="text" class='form-control' ng-model="channel.name" id='channelName' aria-describedby="channelNameHelp" />
<small id='channelNumberHelp' class="text-danger" for='channelNumber'>{{error.name}}</small>
</div>
<div class='form-group'>
<label class='form-label' >Channel Group:</label>
<input type="text" class='form-control' ng-model="channel.groupTitle" id='groupTitle' placeholder="dizqueTV" aria-describedby="groupTitleHelp" />
<small id='groupTitleHelp' class="text-muted" for='channelNumber'>This is used by iptv clients to categorize the channels. You can leave it as dizqueTV if you don&apos;t need this sort of classification.</small>
</div>
<div>
<span class="pull-right text-danger">{{error.icon}}</span>
<label for="channelIcon" class="small">Channel Icon</label>
<div class="input-group mb-1">
<input name="channelIcon" id="channelIcon" class="form-control form-control-sm" type="url" ng-model="channel.icon" />
<div class="input-group-append">
<input type="file"
accept="image/*"
class="form-control-file"
onchange="angular.element(this).scope().logoOnChange(event)"
name="logo"
id="logo">
</input>
</div>
</div>
<br />
<div>
<h6>Channel Icon Preview</h6>
<h6>Preview</h6>
<img ng-if="channel.icon !== ''" ng-src="{{channel.icon}}" alt="{{channel.name}}" style="max-height: 120px;"/>
<span ng-if="channel.icon === ''">{{channel.name}}</span>
</div>
@ -157,7 +181,7 @@
<div ng-style="programSquareStyle(x)" />
<div ng-hidden="x.isOffline" class='title' >
{{ x.type === 'episode' ? x.showTitle + ' - S' + x.season.toString().padStart(2, '0') + 'E' + x.episode.toString().padStart(2, '0') : x.title}}
{{ getProgramDisplayTitle(x) }}
</div>
<div style="font-weight:ligther" ng-show="x.isOffline" class='title' >
<i ng-if="x.type !== 'redirect' " >Flex</i>
@ -425,7 +449,7 @@
<p ng-show='showHelp.check'>Slides the whole schedule. The &quot;Fast-Forward&quot; button will advance the stream by the specified amount of time. The &quot;Rewind&quot; button does the opposite.</p>
</div>
<div class="col-xl-6 col-lg-12" style="padding: 5px;" ng-show="hasPrograms()">
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group">
<button class='btn btn-sm btn-warning form-control' ng-click="onTimeSlotsButtonClick()"
title="Time Slots..."
@ -433,7 +457,20 @@
<i class='fas fa-blender'></i> Time Slots...
</button>
</div>
<p ng-show='showHelp.check'>A more advanced dialog wip description.</p>
<p ng-show='showHelp.check'>This allows to schedul specific shows to run at specific time slots of the day or a week. It&apos;s recommended you first populate the channel with the episodes from the shows you want to play and/or other content like movies and redirects.</p>
</div>
<div class="col-xl-3 col-lg-6" style="padding: 5px;" ng-show="hasPrograms()">
<div class="input-group">
<button class='btn btn-sm btn-warning form-control' ng-click="onRandomSlotsButtonClick()"
title="Random Slots..."
>
<i class='fas fa-flask'></i> Random Slots...
</button>
</div>
<p ng-show='showHelp.check'>This is similar to Time Slots, but instead of time sections, you pick a probability to play each tv show and the length of the block.</p>
</div>
@ -564,7 +601,7 @@
<h5 style="margin-top: 10px;">Filler</h5>
<div>
<label>Minimum time before replaying a filler (Minutes): </label>
<input type="number" class="form-control form-control-sm" ng-model="channel.fillerRepeatCooldownMinutes" ng-pattern="/^([1-9][0-9]*)$/" min='0' max='1440' />
<input type="number" class="form-control form-control-sm" ng-model="channel.fillerRepeatCooldownMinutes" ng-pattern="/^([1-9][0-9]*)$/" min='0' max='10080' />
<span class="text-danger pull-right">{{error.blockRepeats}}</span>
</div>
@ -590,7 +627,7 @@
<div class='form-group col-md-2' ng-if="x.id !== &apos;none&apos; " >
<label ng-if="$index==0" for="cooldown{{$index}}">Cooldown (minutes)</label>
<input class='form-control' id="cooldown{{$index}}" type='number' ng-model='x.cooldownMinutes' ng-pattern="/^([0-9][0-9]*)$/"
min='0' max='1440'
min='0' max='10080'
data-toggle="tooltip" data-placement="bottom" title="The channel won&apos;t pick a video from this list if it played something from this list less than this amount of minutes ago."
> </input>
</div>
@ -690,8 +727,19 @@
<label for="overlayURL">
Watermark Picture URL:
</label>
<input id='overlayURL' class='form-control' type='url' ng-model='channel.watermark.url' placeholder="Leave empty to use the channel&apos;s icon.">
</input>
<div class="input-group-append">
<input type="file"
accept="image/*"
class="form-control-file"
onchange="angular.element(this).scope().watermarkOnChange(event)"
name="logo"
id="logo">
</input>
</div>
</div>
<div class="form-group">
@ -771,14 +819,14 @@
<div class="form-group col-sm-auto">
<label for="channelBitrate">Video Bitrate (k:</label>
<label for="channelBitrate">Video Bitrate (K):</label>
<input id='channelBitrate' class='form-control' type='number' ng-model='channel.transcoding.videoBitrate' min=0 placeholder='{{videoRateDefault}}'>
</input>
<small class='text-muted form-text'>Leave unassigned to use the global setting</small>
</div>
<div class="form-group col-sm-auto">
<label for="channelBufsize">Video Buffer Size (k):</label>
<label for="channelBufsize">Video Buffer Size (K):</label>
<input id='channelBufsize' class='form-control' type='number' ng-model='channel.transcoding.videoBufSize' min=0 placeholder='{{videoBufSizeDefault}}'>
</input>
<small class='text-muted form-text'>Leave unassigned to use the global setting</small>
@ -808,10 +856,11 @@
<program-config program="_selectedProgram" on-done="finshedProgramEdit"></program-config>
<flex-config offline-title="Modify Flex Time" program="_selectedOffline" on-done="finishedOfflineEdit"></flex-config>
<frequency-tweak programs="_programFrequencies" message="_frequencyMessage" modified="_frequencyModified" on-done="tweakFrequencies"></frequency-tweak>
<remove-shows program-titles="_removablePrograms" on-done="removeShows" deleted="_deletedProgramNames"></remove-shows>
<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 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>
<random-slots-schedule-editor linker="registerRandomSlots" on-done="onRandomSlotsDone"></random-slots-schedule-editor>
</div>

View File

@ -4,7 +4,7 @@
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
</button>
<button class="pull-right btn btn-sm btn-info" ng-click="resetSettings(settings)">
<button class="pull-right btn btn-sm btn-warning" ng-click="resetSettings(settings)">
Reset Options
</button>
</h5>
@ -103,6 +103,11 @@
ng-options="o.id as o.description for o in scalingOptions" ></select>
<small id='scalingHelp' class='form-text text-muted'>Scaling algorithm to use when the transcoder needs to change the video size.</small>
<br />
<label>Deinterlace Filter</label>
<select class='form-control custom-select' ng-model="settings.deinterlaceFilter" ria-describedby="deinterlaceHelp"
ng-options="o.value as o.description for o in deinterlaceOptions" ></select>
<small id='deinterlaceHelp' class='form-text text-muted'>Deinterlace filter to use when video is interlaced. This is only needed when Plex transcoding is not used.</small>
</div>
<div class="form-group">

View File

@ -3,7 +3,7 @@
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
</button>
<button class="pull-right btn btn-sm btn-info" ng-click="resetSettings(settings)">
<button class="pull-right btn btn-sm btn-warning" ng-click="resetSettings(settings)">
Reset Options
</button>
</h5>

View File

@ -3,7 +3,7 @@
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
<div class="modal-content" ng-if="noServers">
<div class="modal-header">
<h5 class="modal-title">Plex Library</h5>
<h5 class="modal-title">Library</h5>
</div>
<div class="model-body">
<br/>
@ -21,23 +21,23 @@
<div class="modal-content" ng-if="!noServers">
<div class="modal-header">
<h5 class="modal-title">Plex Library</h5>
<h5 class="modal-title">Library</h5>
<span class="pull-right">
<label class="small" for="displayImages">Thumbnails</label>&nbsp;
<input id="displayImages" type="checkbox" ng-model="displayImages" />&nbsp;
</span>
</div>
<div class="modal-body">
<select class="form-control form-control-sm" ng-model="plexServer"
ng-options="x.name for x in plexServers" ng-change="selectServer(plexServer)"></select>
<select class="form-control form-control-sm custom-select" ng-model="currentOrigin"
ng-options="x.name for x in origins" ng-change="selectOrigin(currentOrigin)"></select>
<hr />
<ul class="list-group list-group-root plex-panel" ng-init="setHeight = {'height': height + 'px'}" ng-style="setHeight" lazy-img-container>
<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);">
<span class="fa {{ a.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
<img ng-if="displayImages" lazy-img="{{a.icon}}" />
<span>{{a.title}}</span><!-- Library -->
<span ng-if="a.type === 'show' || a.type === 'movie'" class="flex-pull-right" ng-click='$event.stopPropagation(); selectLibrary(a)'>
<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>
</div>
@ -50,38 +50,37 @@
<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 }}" />
{{b.title}}
{{ displayTitle(b) }}
<span ng-if="b.type === 'movie'" class="flex-pull-right">
{{b.durationStr}}
</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>
<span ng-if="b.type === 'show' || b.type === 'collection' || b.type === 'genre'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectShow(b);">
<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>
</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' ? 'list-group-item-dark' : 'list-group-item-video' }}">
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"
ng-click="c.type !== 'movie' && c.type !== 'episode' ? getNested(c) : selectItem(c, true)">
<span ng-if="c.type === 'movie' || c.type === 'episode'"
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'"
<span ng-if="c.type !== 'movie' && c.type !== 'episode' && c.type !== 'track'"
class="tab"></span>
<span ng-if="c.type !== 'movie' && c.type !== 'episode'"
<span ng-if="c.type !== 'movie' && c.type !== 'episode' && c.type !== 'track'"
class="tab"></span>
<span ng-if="c.type !== 'movie' && c.type !== 'episode'"
<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 }}" />
{{ c.type === 'episode' ? c.showTitle + ' - S' + c.season.toString().padStart(2,'0') + 'E' + c.episode.toString().padStart(2,'0') + ' - ' : '' }}
{{c.title}}
<span ng-if="c.type === 'movie' || c.type === 'episode'"
{{ displayTitle(c) }}
<span ng-if="c.type === 'movie' || c.type === 'episode' || c.type === 'track' "
class="flex-pull-right">
{{c.durationStr}}
</span>
<span ng-if="c.type === 'season'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectSeason(c);">
<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>
</div>
@ -91,7 +90,7 @@
<div class="flex-container" ng-click="selectItem(d, true)">
<span class="fa fa-plus-circle tab"></span>
<img ng-if="displayImages" lazy-img="{{d.episodeIcon}}" />
E{{ d.episode.toString().padStart(2,'0')}} - {{d.title}}
{{ displayTitle(d) }}
<span class="flex-pull-right">{{d.durationStr}}</span>
<!-- Episode -->
</div>
@ -103,6 +102,16 @@
</ul>
</li>
</ul>
<ul ng-show="currentOrigin.type=='dizquetv' " 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="x in customShows">
<div class="flex-container" ng-click="addCustomShow(x);">
<span class="fa fa-plus-circle tab"></span>
<span>{{x.name}} ({{x.count}})</span>
</div>
</li>
</ul>
<hr/>
<div class="loader" ng-if="pending &gt; 0" ></div> <h6 style='display:inline-block'>Selected Items</h6>
@ -112,7 +121,7 @@
<ul class="list-group list-group-root" style="height: 180px; overflow-y: scroll" dnd-list="selection" scroll-glue>
<div ng-if="selection.length === 0">Select media items from your plex library above.</div>
<li ng-if="selection.length + x &gt;= 0" class="list-group-item" ng-repeat="x in allowedIndexes" style="cursor:default;" dnd-draggable="x" dnd-moved="selection.splice(selection.length + x, 1)" dnd-effect-allowed="move">
{{ (selection[selection.length + x].type !== 'episode') ? selection[selection.length + x].title : (selection[selection.length + x].showTitle + ' - S' + selection[selection.length + x].season.toString().padStart(2,'0') + 'E' + selection[selection.length + x].episode.toString().padStart(2,'0'))}}
{{ getProgramDisplayTitle(selection[selection.length + x]) }}
<button class="pull-right btn btn-sm btn-link" ng-click="selection.splice(selection.length + x,1)">
<span class="text-danger fa fa-trash-alt" ></span>
</button>

View File

@ -84,12 +84,15 @@
</div>
<div class="modal-footer">
<div class="modal-footer" ng-show='! loading.show'>
<div class='text-success small'>{{state.success}}</div>
<div class='text-danger small'>{{state.error}}</div>
<button type="button" class="btn btn-sm btn-link" ng-click="onFinish()">{{state.modified?"Cancel":"Close"}}</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="onSave();" ng-show="state.modified" >Save</button>
</div>
<div class="modal-footer" ng-show='loading.show'>
<div class='loader'></div>
</div>
</div>
</div>
</div>

View File

@ -70,7 +70,7 @@
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
</button>
<button class="pull-right btn btn-sm btn-info" ng-click="resetSettings(settings)">
<button class="pull-right btn btn-sm btn-warning" ng-click="resetSettings(settings)">
Reset Options
</button>
</h6>

View File

@ -4,14 +4,33 @@
<div class="modal-content">
<div>
<div class="modal-header">
<h5 class="modal-title">Program Config</h5>
<h5 class="modal-title">Program Config (EPG)</h5>
</div>
</div>
<div class="modal-body container">
<select ng-model="program.type" class="pull-right">
<option>movie</option>
<option>episode</option>
<option>track</option>
</select>
<div ng-if="program.type === 'track'">
<label>Track Title
<span class="text-danger pull-right">{{error.title}}</span>
</label>
<input class="form-control form-control-sm" type="text" ng-model="program.title"/>
<label>Subtitle</label>
<input class="form-control form-control-sm" type="text" ng-model="program.subtitle"/>
<label>Summary</label>
<textarea class="form-control form-control-sm" ng-model="program.summary"></textarea>
<label>Rating</label>
<input class="form-control form-control-sm" type="text" ng-model="program.rating"/>
<label>Icon</label>
<input class="form-control form-control-sm" type="text" ng-model="program.icon"/>
<h6>Icon Preview</h6>
<div class="text-center">
<img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"/>
</div>
</div>
<div ng-if="program.type === 'movie'">
<label>Movie Title
<span class="text-danger pull-right">{{error.title}}</span>

View File

@ -0,0 +1,185 @@
<div ng-show="visible">
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
<div class="modal-content">
<div>
<div class="modal-header">
<h5 class="modal-title">Random Slots</h5>
</div>
</div>
<div class="modal-body" ng-show='loading' >
<p><span class='loader'></span> Generating lineup, please wait...</p>
</div>
<div class="modal-body" ng-show='! loading' >
<div class="form-row" ng-repeat = "slot in schedule.slots" track-by = "$index">
<div class='form-group col-md-2' >
<label ng-if="$index==0" for="showTime{{$index}}">Duration</label>
<select
id="showDuration{{$index}}" class="custom-select form-control"
ng-model="slot.duration" ng-options="o.id as o.description for o in durationOptions"
ng-change="refreshSlots()"
>
</select>
<small class='form-text text-danger'>{{slot.timeError}}</small>
</div>
<div class='form-group col-md-5' >
<label ng-if="$index==0" for="showId{{$index}}">Program</label>
<select
id="showId{{$index}}" class="custom-select form-control"
ng-model="slot.showId" ng-options="o.id as o.description for o in showOptions"
ng-change="refreshSlots()"
>
</select>
</div>
<div class='form-group col-md-2'>
<label ng-if="$index==0" for="showCooldown{{$index}}" >Cooldown</label>
<select
id="showCooldown{{$index}}" class="custom-select form-control"
ng-model="slot.cooldown" ng-options="o.id as o.description for o in cooldownOptions"
ng-change="refreshSlots()"
>
</select>
</div>
<div class='form-group col-md-2'>
<label ng-if="$index==0" for="showOrder{{$index}}" ng-show="canShowSlot(slot)" >Order</label>
<select
id="showOrder{{$index}}" class="custom-select form-control"
ng-model="slot.order" ng-options="o.id as o.description for o in orderOptions"
ng-change="refreshSlots()"
ng-show="canShowSlot(slot)"
ng-disabled="slot.showId == 'movie.'"
>
</select>
</div>
<div class='form-group col-md-1'>
<label ng-if="$index==0" for="delete{{$index}}">-</label>
<button id='delete{{$index}}' class='btn btn-link form-control' ng-click='deleteSlot($index)' >
<i class='text-danger fa fa-trash-alt'></i>
</button>
</div>
<div class='form-group col-md-2'>
</div>
<div class='form-group col-md-7' ng-if="schedule.randomDistribution == 'weighted'" >
<label ng-if="$index==0" for="weightRange{{$index}}">Weight</label>
<input class='form-control-range custom-range' id="weightRange{{$index}}" type='range' ng-model='slot.weight' min=1 max=600
data-toggle="tooltip" data-placement="bottom" title="Slots with more weight will be picked more frequently."
ng-change="refreshSlots()"
>
</input>
</div>
<div class='form-group col-md-2' ng-if="schedule.randomDistribution == 'weighted'" >
<label ng-if="$index==0" for="weightp{{$index}}">%</label>
<input class='form-control flex-filler-percent' id="weightp{{$index}}" type='text' ng-model='slot.weightPercentage'
data-toggle="tooltip" data-placement="bottom" title="This is the overall probability this slot might be picked, assuming all lists are available." readonly
>
</input>
</div>
</div>
<div class="form-row">
<div class='form-group col-md-2'>
<label ng-if="schedule.slots.length==0" for="fakeTime">Duration</label>
<button
type="button"
class="btn btn-outline-secondary form-control"
ng-click="addSlot()"
>
Add Slot
</button>
</div>
</div>
<hr>
<div class='form-group'>
<label for="pad">Pad times</label>
<select
id="pad" class="custom-select form-control"
ng-model="schedule.pad" ng-options="o.id as o.description for o in padOptions"
aria-describedby="padHelp"
>
</select>
<small id='padHelp' class='form-text text-muted'>
Ensures programs have a nice-looking start time, it will add Flex time to fill the gaps.
</small>
</div>
<div class='form-group' ng-show='schedule.pad != 1'>
<label for="padStyle">What to pad?</label>
<select
id="padStyle" class="custom-select form-control"
ng-model="schedule.padStyle" ng-options="o.id as o.description for o in padStyleOptions"
aria-describedby="padStyleHelp"
>
</select>
<small id='padStyleHelp' class='form-text text-muted'>
When using the pad times option, you might prefer to only ensure the start times of the slot and not the individual episodes.
</small>
</div>
<div class='form-group'>
<label for="flexPreference">What to do with flex?</label>
<select
id="flexPreference" class="custom-select form-control"
ng-model="schedule.flexPreference" ng-options="o.id as o.description for o in flexOptions"
aria-describedby="flexPreferenceHelp"
>
</select>
<small id='flexPreferenceHelp' class='form-text text-muted'>
Usually slots need to add flex time to ensure that the next slot starts at the correct time. When there are multiple videos in the slot, you might prefer to distribute the flex time between the videos or to place most of the flex time at the end of the slot.
</small>
</div>
<div class='form-group'>
<label for="randomDistribution">Random Distribution</label>
<select
id="randomDistribution" class="custom-select form-control"
ng-model="schedule.randomDistribution" ng-options="o.id as o.description for o in distributionOptions"
aria-describedby="randomDistributiondHelp"
ng-change="randomDistributionChanged()"
>
</select>
<small id='randomDistributionHelp' class='form-text text-muted'>
Uniform means that all slots have an equal chancel to be picked. Weighted makes the configuration of the slots more complicated but allows to tweak the weight for each slot so you can make some slots more likely to be picked than others.
</small>
</div>
<div class='form-group'>
<label for="lateness">Maximum days to precalculate</label>
<input
id="maxDays" class="form-control"
type='number'
ng-model="schedule.maxDays"
min = 1
max = 3652
aria-describedby="maxDaysHelp"
required
>
</input>
<small id="maxDaysHelp" class='form-text text-muted'>
Maximum number of days to precalculate the schedule. Note that the length of the schedule is also bounded by the maximum number of programs allowed in a channel.
</small>
</div>
</div>
<div class="modal-footer" ng-show='!loading'>
<div class='text-danger small'>{{error}}</div>
<button type="button" class="btn btn-sm btn-link" ng-click="finished(true)">Cancel</button>
<button ng-disabled='disableCreateLineup()' type="button" class="btn btn-sm btn-primary" ng-click="finished(false);">Create Lineup</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
<div ng-show="programTitles.length > 0">
<div ng-show="programInfos.length > 0">
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
<div class="modal-content">
@ -10,16 +10,16 @@
<div class="modal-body container">
<div class="list-group list-group-root">
<div class="list-group-item flex-container program-row" ng-repeat="title in programTitles" ng-click="toggleShowDeletion(title)">
<div class="list-group-item flex-container program-row" ng-repeat="program in programInfos" ng-click="toggleShowDeletion(program.id)">
<div class='col-sm-7 col-md-9'>
<span ng-show='deleted.indexOf(title) === -1'>{{title}}</span>
<span class="text-muted" ng-show='deleted.indexOf(title) > -1'><strike>{{title}}</strike></span>
<span ng-show='deleted.indexOf(program.id) === -1'>{{program.displayName}}</span>
<span class="text-muted" ng-show='deleted.indexOf(program.id) > -1'><strike>{{program.displayName}}</strike></span>
</div>
<div class="flex-pull-right"></div>
<div class='col-sm-1 col-md-1'>
<button class="btn btn-sm btn-link">
<i ng-show="deleted.indexOf(title) === -1" class="text-danger fa fa-trash-alt"></i>
<i ng-show="deleted.indexOf(title) > -1" class="text-success fa fa-undo"></i>
<i ng-show="deleted.indexOf(program.id) === -1" class="text-danger fa fa-trash-alt"></i>
<i ng-show="deleted.indexOf(program.id) > -1" class="text-success fa fa-undo"></i>
</button>
</div>
</div>
@ -28,9 +28,9 @@
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-link" ng-click="programTitles = null" ng-show="deleted.length > 0">Cancel</button>
<button type="button" class="btn btn-sm btn-link" ng-click="programTitles = null" ng-show="deleted.length === 0">Close</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="finished(programTitles);" ng-show="deleted.length > 0" >Apply</button>
<button type="button" class="btn btn-sm btn-link" ng-click="programInfos = null" ng-show="deleted.length > 0">Cancel</button>
<button type="button" class="btn btn-sm btn-link" ng-click="programInfos = null" ng-show="deleted.length === 0">Close</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="finished(programInfos);" ng-show="deleted.length > 0" >Apply</button>
</div>
</div>
</div>

View File

@ -0,0 +1,121 @@
<div ng-show="visible">
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
<div class="modal-content">
<div>
<div class="modal-header">
<h5 class="modal-title">{{title}}</h5>
</div>
<div style='padding-left: 1rem; padding-right: 1rem' >
<div class="form-group">
<label for="name">Show Name:</label>
<input type="text" class="form-control" id="name" placeholder="Show Name" ng-model="name" ></input>
</div>
<h6 style="margin-top: 10px;">Clips</h6>
<div class="flex-container">
<div class="programming-counter small" ng-show="content.length > 0">
<span class="small"><b>Total:</b> {{content.length}}</span>
</div>
<div class='flex-pull-right' />
<div>
<button class="btn btn-sm btn-secondary btn-programming-tools"
ng-click="showTools = !showTools"
ng-show="content.length !== 0">
<span
class="fa {{ showTools ? 'fa-chevron-down' : 'fa-chevron-right'}}"></span>&nbsp;&nbsp;Tools
</button>
</div>
<div>
<button class="btn btn-sm btn-primary" ng-click="showPlexLibrary = true">
<span class="fa fa-plus"></span>
</button>
</div>
</div>
<div ng-show="showTools" class='tools-pane' >
<div class="row">
<!-- TODO: Probably sort shows and sort dates are needed here -->
<div class="input-group col-xl-3 col-lg-6" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortShows()">
<i class='fa fa-sort-alpha-down'></i> Sort TV Shows
</button>
</div>
<div class="input-group col-xl-3 col-lg-6" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortByDate()">
<i class='fa fa-sort-alpha-down'></i> Sort Release Dates
</button>
</div>
<div class="input-group col-xl-6 col-lg-12" style="padding: 5px;">
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="shuffleShows()">
<i class='fa fa fa-random'></i> Random Shuffle
</button>
</div>
<div class="input-group col-xl-3 col-lg-6" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="showRemoveDuplicates()">
<i class='fa fa-trash-alt'></i> Remove Duplicates
</button>
</div>
<div class="input-group col-xl-3 col-lg-6" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="removeSpecials()">
<i class='fa fa-trash-alt'></i> Remove Specials
</button>
</div>
<div class="input-group col-xl-6 col-lg-12" style="padding: 5px;">
<button class="btn btn-sm btn-danger form-control form-control-sm" type="button" ng-click="showRemoveAllShow()">
<i class='fa fa-trash-alt'></i> Remove All
</button>
</div>
</div>
</div>
<div ng-show="content.length === 0">
<p class="text-center text-info">Click the <span class="fa fa-plus"></span> to import show content from your Plex server(s).</p>
</div>
</div>
</div>
<div vs-repeat class="modal-body container list-group list-group-root show-list"
dnd-list="content" ng-if="showList()"
vs-repeat-reinitialized="vsReinitialized(event, startIndex, endIndex)"
ng-init="setUpWatcher()"
dnd-drop="dropFunction(index , item)"
dnd-list=""
>
<div class="list-group-item flex-container show-row" style="cursor: default; height:1.1em; overflow:hidden" ng-repeat="x in content" track-by="x.$index" dnd-draggable="x"
dnd-effect-allowed="move"
dnd-moved="movedFunction(x.$index)"
>
<div class="program-start" >
X{{ (x.$index + 1).toString().padStart(2, '0') }}
</div>
<div ng-style="programSquareStyle(x, false)" />
<div class="title" >
{{ getProgramDisplayTitle(x) }}
</div>
<div class="flex-pull-right">
<button class="btn btn-sm btn-link" ng-click="contentSplice(x.$index,1)">
<i class="text-danger fa fa-trash-alt" ></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<div class='text-danger small'>{{error}}</div>
<button type="button" class="btn btn-sm btn-link" ng-click="finished(true)">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="finished(false);">Done</button>
</div>
</div>
</div>
</div>
<plex-library limit=1000000000 height="300" visible="showPlexLibrary" on-finish="importPrograms"></plex-library>
</div>

View File

@ -14,19 +14,19 @@
<div class="modal-body" ng-show='! loading' >
<div class="form-row" ng-repeat = "slot in schedule.slots" track-by = "$index">
<div class='form-group col-md-2'>
<div class='form-group' ng-class='timeColumnClass()' >
<label ng-if="$index==0" for="showTime{{$index}}">Time</label>
<select
id="showTime{{$index}}" class="custom-select form-control"
ng-class="{'is-invalid': hasTimeError(slot) }"
ng-model="slot.time" ng-options="o.id as o.description for o in timeOptions"
ng-change="refreshSlots()"
aria-describedby="showTime{{$index}}Help"
>
</select>
<button
type="button"
class="btn btn-outline-secondary form-control"
ng-click="editTime($index)"
>
{{ displayTime(slot.time) }}
</button>
<small class='form-text text-danger'>{{slot.timeError}}</small>
</div>
<div class='form-group col-md-7'>
<div class='form-group' ng-class='programColumnClass()' >
<label ng-if="$index==0" for="showId{{$index}}">Program</label>
<select
id="showId{{$index}}" class="custom-select form-control"
@ -56,17 +56,33 @@
</div>
<div class="form-row">
<div class='form-group col-md-2'>
<div class='form-group col-md-2' ng-class='timeColumnClass()'>
<label ng-if="schedule.slots.length==0" for="fakeTime">Time</label>
<select
id="fakeTime" class="custom-select form-control"
ng-model="fake.time" ng-options="o.id as o.description for o in fakeTimeOptions"
ng-change="fakeTimeChanged()"
>
</select>
<button
type="button"
class="btn btn-outline-secondary form-control"
ng-click="addSlot()"
>
Add Slot
</button>
</div>
</div>
<hr>
<div class='form-group'>
<label for="period">Period</label>
<select
id="period" class="custom-select form-control"
ng-model="schedule.period" ng-options="o.id as o.description for o in periodOptions"
ng-change="periodChanged()"
aria-describedby="periodHelp"
>
</select>
<small id='periodHelp' class='form-text text-muted'>
By default, time slots are time of the day-based, you can change it to time of the day + day of the week. That means scheduling 7x the number of time slots. If you change from daily to weekly, the current schedule will be repeated 7 times. If you change from weekly to daily, many of the slots will be deleted.
</small>
</div>
<div class='form-group'>
<label for="lateness">Maximum lateness</label>
<select
@ -137,4 +153,8 @@
</div>
</div>
</div>
<time-slots-time-editor slot="_editedTime" on-done="finishedTimeEdit"></time-slots-time-editor>
<time-slots-time-editor slot="_addedTime" on-done="finishedAddingTime"></time-slots-time-editor>
</div>

View File

@ -0,0 +1,70 @@
<div ng-show="slot">
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div>
<div class="modal-header">
<h5 class="modal-title">{{slot.title}}</h5>
</div>
</div>
<div class="modal-body container">
<div class="form-row" ng-show='slot.isWeekly' >
<div class='form-group col-sm-auto'>
<label for="hour">Day</label>
<select
id="w" class="custom-select form-control"
ng-model="slot.w" ng-options="o.id as o.description for o in weekDayOptions"
>
</select>
</div>
</div>
<div class="form-row">
<div class='form-group col-sm-auto'>
<label for="hour">Hour</label>
<select
id="hour" class="custom-select form-control"
ng-model="slot.h" ng-options="o.id as o.description for o in hourOptions"
>
</select>
</div>
<div class='form-group col-sm-auto'>
<label for="hour">Minutes</label>
<select
id="hour" class="custom-select form-control"
ng-model="slot.m" ng-options="o.id as o.description for o in minuteOptions"
>
</select>
</div>
<div class='form-group col-sm-auto'>
<label for="hour">Seconds</label>
<select
id="hour" class="custom-select form-control"
ng-model="slot.s" ng-options="o.id as o.description for o in minuteOptions"
>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-link" ng-click="slot = null">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="finished(slot);">Done</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
<div style='position: fixed; top: 30px; right: 30px; width:400px; z-index: 1000000;'>
<div
ng-repeat="toast in toasts track by $index"
class="dizque-toast"
ng-class="toast.clazz"
ng-click="destroy($index)"
>
<div
class="flex-container"
>
<div>
<strong>{{ toast.title }}</strong>
</div>
<div class='flex-pull-right'>
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<div>{{ toast.text }}</div>
</div>
</div>

View File

@ -4,7 +4,7 @@
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
</button>
<button class="pull-right btn btn-sm btn-info" ng-click="resetSettings(settings)">
<button class="pull-right btn btn-sm btn-warning" ng-click="resetSettings(settings)">
Reset Options
</button>
</h5>
@ -14,7 +14,7 @@
<br/>
<div class="row">
<div class="col-sm-6">
<label>EPG Cache (hours)</label>
<label>EPG Hours</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.cache" aria-describedby="cachehelp"/>
<small id="cachehelp" class="form-text text-muted">How many hours of programming to include in the xmltv file.</small>
</div>
@ -24,5 +24,13 @@
<small id="timerhelp" class="form-text text-muted">How often should the xmltv file be updated.</small>
</div>
</div>
<br />
<div class="form-check">
<input type="checkbox" class="form-check-input" id="imageCache" aria-describedby="imageCacheHelp" ng-model='settings.enableImageCache'>
<label class="form-check-label" for="stealth">Image Cache</label>
<div class='text-muted' id="imageCacheHelp">If enabled the pictures used for Movie and TV Show posters will be cached in dizqueTV's .dizqueTV folder and will be delivered by dizqueTV's server instead of requiring calls to Plex. Note that using fixed xmltv location in Plex (as opposed to url) will not work correctly in this case.</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
<div>
<div class='container'>
<channel-config ng-if="showChannelConfig" channel="selectedChannel" channels="channels" on-done="onChannelConfigDone"></channel-config>
<h5>

View File

@ -0,0 +1,37 @@
<div class='container'>
<show-config linker="registerShowConfig" on-done="onShowConfigDone"></show-config>
<delete-show linker="registerDeleteShow" on-exit="onShowDelete"></delete-show>
<h5>
Custom Shows
<button class="pull-right btn btn-sm btn-primary" ng-click="selectShow(-1)">
<span class="fa fa-plus"></span>
</button>
</h5>
<table class="table">
<tr>
<th>Name</th>
<th width="40">Clips</th>
<th style='width:2em'></th>
</tr>
<tr ng-if="shows.length === 0">
<td colspan="3">
<p class="text-center text-danger">No Custom Shows set. Click the <span class="fa fa-plus"></span> to add custom shows.</p>
</td>
</tr>
<tr class='show-row' ng-repeat="x in shows" ng-click="selectShow($index)" style="cursor: pointer; height: 3em" >
<td style='height: 3em'>
<div class="loader" ng-if="x.pending"></div>
<span ng-show="!x.pending">{{x.name}}</span>
</td>
<td>{{x.count}}</td>
<td>
<button class='btn btn-link' title='Delete...' ng-click='deleteShow($index)' >
<i class='fas fa-trash-alt text-danger'></i>
</button>
</td>
</tr>
</table>
</div>

View File

@ -1,4 +1,4 @@
<div>
<div class='container'>
<filler-config linker="registerFillerConfig" on-done="onFillerConfigDone"></filler-config>
<delete-filler linker="registerDeleteFiller" on-exit="onFillerDelete"></delete-filler>

View File

@ -1,4 +1,4 @@
<div>
<div class='container-fluid'>
<h5>
{{title}}
@ -54,7 +54,7 @@
{{program.showTitle}}
</div>
<div class='sub-title'>
{{program.subTitle}}
{{program.subTitle}} <span class='episodeTitle'>{{program.episodeTitle}}</span>
</div>
</td>
</tr>

View File

@ -0,0 +1,40 @@
<div class='container'>
<div class='row gy-15'>
<div class='col'>
<h5>
Library
</h5>
<p>Components that will allow you to organize your media library to help with the creation of channels that do things the way you want.</p>
</div>
</div>
<div class='row'>
<div class='col-md-auto'>
<div class="card" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title"><a href="#!/filler" class="card-link">Filler...</a></h5>
<p class="card-text">Filler lists are collections of videos that you may want to play during <i>&apos;flex&apos;</i> time segments. Flex is time within a channel that does not have a program scheduled (Usually used for padding).</p>
</div>
</div>
</div>
<div class='col-md-auto'>
<div class="card" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title"><a href="#!/custom-shows" class="card-link">Custom Shows....</a></h5>
<p class="card-text">Custom Shows are sequences of videos that represent a episodes of a virtual TV show. When you add these shows to a channel, the schedule tools will treat the videos as if they belonged to a single TV show.</p>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,85 @@
<div class='container'>
<h5>Player</h5>
<p class='text-small text-info'>Play your channels in a local media player. This is mostly meant for testing purposes and to show what endpoints are available. </p>
<div class="form-group row">
<label for="channel" class="col-form-label col-sm-2">Channel:</label>
<div class="col">
<div ng-show='loading'>
<div class='loader'></div>
</div>
<input ng-show='! loading && channelOptions.length == 1' readonly class='form-control-plaintext'
id="endpoint"
value = "No channels found."
>
</input>
<select
id="channel"
ng-show='! loading && channelOptions.length != 1'
class="custom-select"
ng-model="channel"
ng-options="o.id as o.description for o in channelOptions"
>
</select>
</div>
</div>
<div class="form-group row">
<label for="endpoint" class="col-form-label col-sm-2">Endpoint:</label>
<div class="col">
<select
id="endpoint"
class="custom-select"
ng-model="selectedEndpoint"
ng-options="o.id as o.description for o in endpointOptions"
aria-describedby="endpointHelp"
>
</select>
<small id="endpointHelp" class="form-text text-muted">
<span ng-show="selectedEndpoint == 'video' ">
The /video endpoint is the one used by IPTV player or Plex to play the channel&apos;s content. It creates a single mpegts stream for the channel out of all of the videos scheduled for it. For this reason, it needs the videos to be formatted to the same codec and resolution (normalized). Use this endpoint to debug issues with Plex/IPTV players or when the other endpoints don&apos;t work correctly in your player.
</span>
<span ng-show="selectedEndpoint == 'm3u8' ">
The /m3u8 endpoint (misnomer) sends the channel as a playlist of videos, which allows <i>some</i> players to play the channel in sequence without the need for a single stream. Since there is no need for a single stream, it requires less normalization work .
</span>
<span ng-show="selectedEndpoint == 'radio' ">
The /radio endpoint plays only the audio of the channel, effectively turning it into a radio station. If you only need the audio, this endpoint is much more efficient as it will not need to extract or transcode video at all.
</span>
</small>
</div>
</div>
<div class="form-group row">
<label for="endpoint" class="col-form-label col-sm-2">URL to play:</label>
<div class="col">
<input readonly class='form-control-plaintext'
id="endpoint"
style="font-family: monospace"
value = "{{ endpoint() }}"
>
</input>
</div>
</div>
<div class='form-group row' ng-show="! buttonDisabled()" >
<div class='col-sm-2'>
<a
role="button"
ng-href='{{ endpointButtonHref() }}'
style="width:99%"
title="Attempt to play in local media player" class='btn btn-primary player-button'
>
<span class='fa fa-play'> Play</span>
</a>
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
<div>
<div class='container'>
<ul class="nav nav-tabs">
<li class="nav-item">
<span class="nav-link btn btn-link {{ selected === 'xmltv' ? 'active' : ''}}" ng-click="selected = 'xmltv'">

View File

@ -1,4 +1,4 @@
<div>
<div class='container'>
<h5>
Version Info

View File

@ -0,0 +1,262 @@
//This is an exact copy of the file with the same now in the nodejs
//one of these days, we'll figure out how to share the code.
module.exports = function (getShowData) {
/*** Input: list of programs
* output: sorted list of programs */
function sortShows(programs) {
let shows = {}
let movies = [] //not exactly accurate name
let newProgs = []
let progs = programs
for (let i = 0, l = progs.length; i < l; i++) {
let showData = getShowData( progs[i] );
if ( showData.showId === 'movie.' || ! showData.hasShow ) {
movies.push(progs[i]);
} else {
if (typeof shows[showData.showId] === 'undefined') {
shows[showData.showId] = [];
}
shows[showData.showId].push(progs[i]);
}
}
let keys = Object.keys(shows)
for (let i = 0, l = keys.length; i < l; i++) {
shows[keys[i]].sort((a, b) => {
let aData = getShowData(a);
let bData = getShowData(b);
return aData.order - bData.order;
})
newProgs = newProgs.concat(shows[keys[i]])
}
return newProgs.concat(movies);
}
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
}
let removeDuplicates = (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 {
let data = getShowData(progs[i]);
if (data.hasShow) {
let key = data.showId + "|" + data.order;
tmpProgs[key] = 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;
}
let removeSpecials = (progs) => {
let tmpProgs = []
for (let i = 0, l = progs.length; i < l; i++) {
if (
(typeof(progs[i].customShowId) !== 'undefined')
||
(progs[i].season !== 0)
) {
tmpProgs.push(progs[i]);
}
}
return tmpProgs;
}
let getProgramDisplayTitle = (x) => {
let s = x.type === 'episode' ? x.showTitle + ' - S' + x.season.toString().padStart(2, '0') + 'E' + x.episode.toString().padStart(2, '0') : x.title
if (typeof(x.customShowId) !== 'undefined') {
s = x.customShowName + " X" + (x.customOrder+1).toString().padStart(2,'0') + " (" + s + ")";
}
return s;
}
let sortByDate = (programs) => {
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;
}
}
});
return programs;
}
let 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 ( typeof(program.customShowId) !== 'undefined') {
let h = Math.abs( getHashCode(program.customShowId, false));
let h2 = Math.abs( getHashCode(program.customShowId, 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 if (program.type === 'episode') {
let h = Math.abs( getHashCode(program.showTitle, false));
let h2 = Math.abs( 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 if (program.type === 'track') {
r = 10, g = 10, b = 10;
r2 = 245, g2 = 245, b2 = 245;
angle = 315;
w = 2;
} 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',
};
}
let 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;
}
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]) );
}
}
}
} )();
return {
sortShows: sortShows,
shuffle: shuffle,
removeDuplicates: removeDuplicates,
removeSpecials: removeSpecials,
sortByDate: sortByDate,
getProgramDisplayTitle: getProgramDisplayTitle,
programSquareStyle: programSquareStyle,
}
}

View File

@ -1,4 +1,4 @@
module.exports = function ($http) {
module.exports = function ($http, $q) {
return {
getVersion: () => {
return $http.get('/api/version').then((d) => { return d.data })
@ -137,6 +137,13 @@ module.exports = function ($http) {
return $http.get(`/api/channel/description/${number}`).then( (d) => { return d.data } )
},
getChannelProgramless: (number) => {
return $http.get(`/api/channel/programless/${number}`).then( (d) => { return d.data })
},
getChannelPrograms: (number) => {
return $http.get(`/api/channel/programs/${number}`).then( (d) => { return d.data } )
},
getChannelNumbers: () => {
return $http.get('/api/channelNumbers').then( (d) => { return d.data } )
@ -150,6 +157,22 @@ module.exports = function ($http) {
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
uploadImage: (file) => {
return $http({
method: 'POST',
url: '/api/upload/image',
data: file,
headers: { 'Content-Type': undefined }
}).then((d) => { return d.data })
},
addChannelWatermark: (file) => {
return $http({
method: 'POST',
url: '/api/channel/watermark',
data: file,
headers: { 'Content-Type': undefined }
}).then((d) => { return d.data })
},
updateChannel: (channel) => {
return $http({
method: 'PUT',
@ -211,6 +234,47 @@ module.exports = function ($http) {
return (await $http.get( `/api/filler/${fillerId}/channels` )).data;
},
/*======================================================================
* Custom Show stuff
*/
getAllShowsInfo: async () => {
let f = await $http.get('/api/shows');
return f.data;
},
getShow: async (id) => {
let f = await $http.get(`/api/show/${id}`);
return f.data;
},
updateShow: async(id, show) => {
return (await $http({
method: "POST",
url : `/api/show/${id}`,
data: angular.toJson(show),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}) ).data;
},
createShow: async(show) => {
return (await $http({
method: "PUT",
url : `/api/show`,
data: angular.toJson(show),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}) ).data;
},
deleteShow: async(id) => {
return ( await $http({
method: "DELETE",
url : `/api/show/${id}`,
data: {},
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}) ).data;
},
/*======================================================================
* TV Guide endpoints
*/
@ -248,8 +312,60 @@ module.exports = function ($http) {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
} );
return d.data;
}
},
calculateRandomSlots: async( programs, schedule) => {
let d = await $http( {
method: "POST",
url : "/api/channel-tools/random-slots",
data: {
programs: programs,
schedule: schedule,
},
headers: { 'Content-Type': 'application/json; charset=utf-8' },
} );
return d.data;
},
/*======================================================================
* Settings
*/
getAllSettings: async () => {
var deferred = $q.defer();
$http({
method: "GET",
url : "/api/settings/cache",
headers: { 'Content-Type': 'application/json; charset=utf-8' },
}).then((response) => {
if(response.status === 200) {
deferred.resolve(response.data);
} else {
deferred.reject();
}
});
return deferred.promise;
},
putSetting: async (key, value) => {
console.warn(key, value);
var deferred = $q.defer();
$http({
method: "PUT",
url : `/api/settings/cache/${key}`,
data: {
value
},
headers: { 'Content-Type': 'application/json; charset=utf-8' },
}).then((response) => {
if(response.status === 200) {
deferred.resolve(response.data);
} else {
deferred.reject();
}
});
return deferred.promise;
}
}
}

View File

@ -0,0 +1,65 @@
//This is an exact copy of the file with the same now in the nodejs
//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,
}
}
}
}

View File

@ -17,7 +17,21 @@ module.exports = function ($http, $window, $interval) {
url: 'https://plex.tv/api/v2/pins?strong=true',
headers: headers
}).then((res) => {
$window.open('https://app.plex.tv/auth/#!?clientID=rg14zekk3pa5zp4safjwaa8z&context[device][version]=Plex OAuth&context[device][model]=Plex OAuth&code=' + res.data.code + '&context[device][product]=Plex Web')
const plexWindowSizes = {
width: 800,
height: 700
}
const plexWindowPosition = {
width: window.innerWidth / 2 + plexWindowSizes.width,
height: window.innerHeight / 2 - plexWindowSizes.height
}
const authModal = $window.open(
`https://app.plex.tv/auth/#!?clientID=rg14zekk3pa5zp4safjwaa8z&context[device][version]=Plex OAuth&context[device][model]=Plex OAuth&code=${res.data.code}&context[device][product]=Plex Web`,
"_blank",
`height=${plexWindowSizes.height}, width=${plexWindowSizes.width}, top=${plexWindowPosition.height}, left=${plexWindowPosition.width}`
);
let limit = 120000 // 2 minute time out limit
let poll = 2000 // check every 2 seconds for token
let interval = $interval(() => {
@ -29,11 +43,17 @@ module.exports = function ($http, $window, $interval) {
limit -= poll
if (limit <= 0) {
$interval.cancel(interval)
if(authModal) {
authModal.close();
}
reject('Timed Out. Failed to sign in a timely manner (2 mins)')
}
if (r2.data.authToken !== null) {
$interval.cancel(interval)
if(authModal) {
authModal.close();
}
headers['X-Plex-Token'] = r2.data.authToken
$http({
@ -63,6 +83,9 @@ module.exports = function ($http, $window, $interval) {
}
}, (err) => {
$interval.cancel(interval)
if(authModal) {
authModal.close();
}
reject(err)
})
@ -89,7 +112,7 @@ module.exports = function ($http, $window, $interval) {
const res = await client.Get('/library/sections')
var sections = []
for (let i = 0, l = typeof res.Directory !== 'undefined' ? res.Directory.length : 0; i < l; i++)
if (res.Directory[i].type === 'movie' || res.Directory[i].type === 'show') {
if (res.Directory[i].type === 'movie' || res.Directory[i].type === 'show' || res.Directory[i].type === 'artist' ) {
var genres = []
if (res.Directory[i].type === 'movie') {
const genresRes = await client.Get(`/library/sections/${res.Directory[i].key}/genre`)
@ -119,13 +142,18 @@ module.exports = function ($http, $window, $interval) {
const res = await client.Get('/playlists')
var playlists = []
for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++)
if (res.Metadata[i].playlistType === 'video')
if (
(res.Metadata[i].playlistType === 'video')
||
(res.Metadata[i].playlistType === 'audio')
) {
playlists.push({
title: res.Metadata[i].title,
key: res.Metadata[i].key,
icon: `${server.uri}${res.Metadata[i].composite}?X-Plex-Token=${server.accessToken}`,
duration: res.Metadata[i].duration
})
}
return playlists
},
getStreams: async (server, key) => {
@ -144,19 +172,56 @@ module.exports = function ($http, $window, $interval) {
var client = new Plex(server)
const key = lib.key
const res = await client.Get(key)
const size = res.Metadata !== 'undefined' ? res.Metadata.length : 0;
var nested = []
if (typeof (lib.genres) !== 'undefined') {
nested = Array.from(lib.genres)
}
var seenFiles = {};
var collections = {};
for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++) {
let albumKeys = {};
let albums = {};
for (let i = 0; i < size; i++) {
let meta = res.Metadata[i];
if (meta.type === 'track') {
albumKeys[ meta.parentKey ] = false;
}
}
albumKeys = Object.keys( albumKeys );
await Promise.all( albumKeys.map( async(albumKey) => {
try {
let album = await client.Get(albumKey);
if ( (typeof(album)!=='undefined') && album.size == 1) {
album = album.Metadata[0];
}
albums[albumKey] = album;
} catch (err) {
console.error(err);
}
} ) );
for (let i = 0; i < size; i++) {
try {
// Skip any videos (movie or episode) without a duration set...
if (typeof res.Metadata[i].duration === 'undefined' && (res.Metadata[i].type === "episode" || res.Metadata[i].type === "movie"))
continue
if (res.Metadata[i].duration <= 0 && (res.Metadata[i].type === "episode" || res.Metadata[i].type === "movie"))
continue
let year = res.Metadata[i].year;
let date = res.Metadata[i].originallyAvailableAt;
let album = undefined;
if (res.Metadata[i].type === 'track') {
//complete album year and date
album = albums[res.Metadata[i].parentKey];
if (typeof(album) !== 'undefined') {
year = album.year;
date = album.originallyAvailableAt;
}
}
if ( (typeof(date)==='undefined') && (typeof(year)!=='undefined') ) {
date = `${year}-01-01`;
}
var program = {
title: res.Metadata[i].title,
key: res.Metadata[i].key,
@ -169,10 +234,10 @@ module.exports = function ($http, $window, $interval) {
subtitle: res.Metadata[i].subtitle,
summary: res.Metadata[i].summary,
rating: res.Metadata[i].contentRating,
date: res.Metadata[i].originallyAvailableAt,
year: res.Metadata[i].year,
date: date,
year: year,
}
if (program.type === 'episode' || program.type === 'movie') {
if (program.type === 'episode' || program.type === 'movie' || program.type === 'track') {
program.plexFile = `${res.Metadata[i].Media[0].Part[0].key}`
program.file = `${res.Metadata[i].Media[0].Part[0].file}`
}
@ -198,8 +263,15 @@ module.exports = function ($http, $window, $interval) {
program.episodeIcon = `${server.uri}${res.Metadata[i].thumb}?X-Plex-Token=${server.accessToken}`
program.seasonIcon = `${server.uri}${res.Metadata[i].parentThumb}?X-Plex-Token=${server.accessToken}`
program.showIcon = `${server.uri}${res.Metadata[i].grandparentThumb}?X-Plex-Token=${server.accessToken}`
}
else if (program.type === 'movie') {
} else if (program.type === 'track') {
if (typeof(album) !== 'undefined') {
program.showTitle = album.title;
} else {
program.showTitle = res.Metadata[i].title
}
program.episode = res.Metadata[i].index;
program.season = res.Metadata[i].parentIndex;
} else if (program.type === 'movie') {
program.showTitle = res.Metadata[i].title
program.episode = 1
program.season = 1
@ -233,7 +305,7 @@ module.exports = function ($http, $window, $interval) {
});
for (let k = 0; k < keys.length; k++) {
let key = keys[k];
if (collections[key].length <= 1) {
if ( !(collections[key].length >= 1) ) {
//it's pointless to include it.
continue;
}