Compare commits

...

24 Commits
main ... edge

Author SHA1 Message Date
vexorian
d99eda2ccf
Merge pull request #493 from vexorian/development
Version 1.7.0
2025-12-15 00:43:37 -04:00
vexorian
1175840b29 Merge remote-tracking branch 'origin/edge' into development 2025-12-15 00:38:56 -04:00
vexorian
9b5fef01b0 1.7.0 2025-12-15 00:38:07 -04:00
vexorian
647c401370 Apparently animated watermarks were getting constantly downloaded by ffmpeg. This hopefully prevents this a bit. 2025-12-15 00:32:22 -04:00
vexorian
2541e42513 Fillers can be set up to import playlists or collections from Plex automatically. Filler Service refactor. 2025-12-14 23:12:57 -04:00
vexorian
caf3b3b72c FFMpeg path is now set by environment var or file.. 2025-12-14 23:05:32 -04:00
vexorian
7daad9e33f When adding media from plex, the UI will now use dizqueTV as a proxy to connect to Plex. Thanks to this the 'ui route' is no longer necessary. Connection to plex through UI is still in use for authenticating with plex when adding the servers. 2025-12-14 22:04:57 -04:00
vexorian
4cdf87121a Prepare 1.7.0 development 2025-12-14 21:18:46 -04:00
vexorian
c413580b00 Merge remote-tracking branch 'origin/dev/1.6.x' 2025-12-14 21:17:58 -04:00
vexorian
8258a59e27
Merge pull request #492 from theweebcoders/dev/1.6.x
Fix original duration override bug and add start time validation
2025-12-02 17:33:39 -04:00
tim000x3
b54c64445e Fix original duration override bug 2025-12-02 12:57:19 -05:00
vexorian
0346a67ca9 Prepare 1.6.1 development 2025-12-01 11:33:09 -04:00
tim000x3
b65eaae38a Start and end time can be set in program config but only applies in direct play
got seek and end time working on non-direct play

Enhance program duration calculation and entry creation in channel services and guide

improved guide generation

Add debug logging and really agressive program merging logic and placeholder avoidence in XMLTV writer

Add a final pass duplicate detection and merging logic in _smartMerge function

Refactor XMLTV writing logic: enhance debug logging, streamline program merging, and improve error handling

backwards compatibly

Human readable time

Refactor program configuration modal: move optional position offsets to collapsible advanced options section

Update program configuration modal: rename position offset labels to custom start and end time

Changed how I build the guide based on an implementation that's closer to the original implementation and requires less changes.

Reverted Unneeded Changes

Simplified how StreamSeeK and custom end positions are applied in both transcoding and direct play.

Implement merging of adjacent programs with the same ratingKey in TVGuideService

Made merging-adjacent programs optional and disabled by default

custom time can actuall be set

cleanup

Enhance time input validation for program duration and seek positions
2025-12-01 11:32:06 -04:00
vexorian
caa99226ae Merge remote-tracking branch 'origin/dev/1.5.x' into dev/1.6.x 2025-12-01 11:30:20 -04:00
vexorian
682d4365b5 Merge remote-tracking branch 'main' into dev/1.5.x 2025-12-01 11:29:11 -04:00
vexorian
ba4ca13564 Revert start/end time changes. They are being included in 1.6.x 2025-12-01 11:27:33 -04:00
vexorian
483ace42d2
Merge pull request #491 from vexorian/dev/1.6.x
1.6.0
2025-12-01 11:12:52 -04:00
vexorian
1aee6abdcb 1.6.0 2025-12-01 11:09:52 -04:00
vexorian
88982104aa Remove this 'channel time cannot be in the future' condition, which was supposed to be gone a long time ago, since the player will actually deal with this situation just fine and play Flex until the channel start time 2025-12-01 11:01:05 -04:00
vexorian
c1374d6de7 Fix typo, thanks to @sultanofcardio 2025-12-01 11:00:53 -04:00
vexorian
1cd6409401 Bumping the docker-provided ffmpeg version to 4.4.5 which is now considered the baseline ffmpeg supported version. The Docker image now has Intel Quicksync support. 2025-12-01 11:00:26 -04:00
vexorian
9193d62e07 Changes to the filler algorithm. Content that has never been played before in the channel gets a higher chance to be picked. If all content available has been played before, the one with the longest time since playback is given a higher chance to be picked. 2025-12-01 10:58:40 -04:00
vexorian
6adf09c41b
Merge pull request #488 from vexorian/feature/start-end
feature/start end
2025-05-17 23:20:57 -04:00
tim000x3
44a2cd9b8b Start and end time can be set in program config but only applies in direct play
got seek and end time working on non-direct play

Enhance program duration calculation and entry creation in channel services and guide

improved guide generation

Add debug logging and really agressive program merging logic and placeholder avoidence in XMLTV writer

Add a final pass duplicate detection and merging logic in _smartMerge function

Refactor XMLTV writing logic: enhance debug logging, streamline program merging, and improve error handling

backwards compatibly

Human readable time

Refactor program configuration modal: move optional position offsets to collapsible advanced options section

Update program configuration modal: rename position offset labels to custom start and end time

Changed how I build the guide based on an implementation that's closer to the original implementation and requires less changes.

Reverted Unneeded Changes

Simplified how StreamSeeK and custom end positions are applied in both transcoding and direct play.

Implement merging of adjacent programs with the same ratingKey in TVGuideService

Made merging-adjacent programs optional and disabled by default

custom time can actuall be set

cleanup

Enhance time input validation for program duration and seek positions
2025-05-17 23:18:51 -04:00
35 changed files with 1279 additions and 329 deletions

View File

@ -6,9 +6,10 @@ COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/
COPY . . COPY . .
RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly
FROM jrottenberg/ffmpeg:4.3-ubuntu1804 FROM akashisn/ffmpeg:4.4.5
EXPOSE 8000 EXPOSE 8000
WORKDIR /home/node/app WORKDIR /home/node/app
ENTRYPOINT [ "./dizquetv" ] ENTRYPOINT [ "./dizquetv" ]
ENV DIZQUETV_FFMPEG_PATH=/usr/bin/ffmpeg
COPY --from=0 /home/node/app/dist/dizquetv /home/node/app/ COPY --from=0 /home/node/app/dist/dizquetv /home/node/app/
RUN ln -s /usr/local/bin/ffmpeg /usr/bin/ffmpeg RUN ln -s /usr/local/bin/ffmpeg /usr/bin/ffmpeg

View File

@ -6,9 +6,10 @@ COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/
COPY . . COPY . .
RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly
FROM jrottenberg/ffmpeg:4.3-nvidia1804 FROM jrottenberg/ffmpeg:4.4.5-nvidia2204
EXPOSE 8000 EXPOSE 8000
WORKDIR /home/node/app WORKDIR /home/node/app
ENTRYPOINT [ "./dizquetv" ] ENTRYPOINT [ "./dizquetv" ]
ENV DIZQUETV_FFMPEG_PATH=/usr/bin/ffmpeg
COPY --from=0 /home/node/app/dist/dizquetv /home/node/app/ COPY --from=0 /home/node/app/dist/dizquetv /home/node/app/
RUN ln -s /usr/local/bin/ffmpeg /usr/bin/ffmpeg RUN ln -s /usr/local/bin/ffmpeg /usr/bin/ffmpeg

View File

@ -1,4 +1,4 @@
# dizqueTV 1.5.5 # dizqueTV 1.7.0
![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) ![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. Create live TV channel streams from media on your Plex servers.

View File

@ -17,6 +17,7 @@ const HDHR = require('./src/hdhr')
const FileCacheService = require('./src/services/file-cache-service'); const FileCacheService = require('./src/services/file-cache-service');
const CacheImageService = require('./src/services/cache-image-service'); const CacheImageService = require('./src/services/cache-image-service');
const ChannelService = require("./src/services/channel-service"); const ChannelService = require("./src/services/channel-service");
const FillerService = require("./src/services/filler-service");
const xmltv = require('./src/xmltv') const xmltv = require('./src/xmltv')
const Plex = require('./src/plex'); const Plex = require('./src/plex');
@ -32,6 +33,9 @@ const ProgrammingService = require("./src/services/programming-service");
const ActiveChannelService = require('./src/services/active-channel-service') const ActiveChannelService = require('./src/services/active-channel-service')
const ProgramPlayTimeDB = require('./src/dao/program-play-time-db') const ProgramPlayTimeDB = require('./src/dao/program-play-time-db')
const FfmpegSettingsService = require('./src/services/ffmpeg-settings-service') const FfmpegSettingsService = require('./src/services/ffmpeg-settings-service')
const PlexProxyService = require('./src/services/plex-proxy-service')
const PlexServerDB = require('./src/dao/plex-server-db');
const FFMPEGInfo = require('./src/ffmpeg-info');
const onShutdown = require("node-graceful-shutdown").onShutdown; const onShutdown = require("node-graceful-shutdown").onShutdown;
@ -51,16 +55,12 @@ if (NODE < 12) {
console.error(`WARNING: Your nodejs version ${process.version} is lower than supported. dizqueTV has been tested best on nodejs 12.16.`); console.error(`WARNING: Your nodejs version ${process.version} is lower than supported. dizqueTV has been tested best on nodejs 12.16.`);
} }
unlockPath = false;
for (let i = 0, l = process.argv.length; i < l; i++) { for (let i = 0, l = process.argv.length; i < l; i++) {
if ((process.argv[i] === "-p" || process.argv[i] === "--port") && i + 1 !== l) if ((process.argv[i] === "-p" || process.argv[i] === "--port") && i + 1 !== l)
process.env.PORT = process.argv[i + 1] process.env.PORT = process.argv[i + 1]
if ((process.argv[i] === "-d" || process.argv[i] === "--database") && i + 1 !== l) if ((process.argv[i] === "-d" || process.argv[i] === "--database") && i + 1 !== l)
process.env.DATABASE = process.argv[i + 1] process.env.DATABASE = process.argv[i + 1]
if (process.argv[i] === "--unlock") {
unlockPath = true;
}
} }
process.env.DATABASE = process.env.DATABASE || path.join(".", ".dizquetv") process.env.DATABASE = process.env.DATABASE || path.join(".", ".dizquetv")
@ -92,6 +92,8 @@ if(!fs.existsSync(path.join(process.env.DATABASE, 'cache','images'))) {
fs.mkdirSync(path.join(process.env.DATABASE, 'cache','images')) fs.mkdirSync(path.join(process.env.DATABASE, 'cache','images'))
} }
const ffmpegInfo = new FFMPEGInfo(process.env, process.env.DATABASE);
ffmpegInfo.initialize();
channelDB = new ChannelDB( path.join(process.env.DATABASE, 'channels') ); channelDB = new ChannelDB( path.join(process.env.DATABASE, 'channels') );
@ -103,10 +105,13 @@ initDB(db, channelDB)
channelService = new ChannelService(channelDB); channelService = new ChannelService(channelDB);
fillerDB = new FillerDB( path.join(process.env.DATABASE, 'filler') , channelService ); let fillerDB = new FillerDB( path.join(process.env.DATABASE, 'filler') );
customShowDB = new CustomShowDB( path.join(process.env.DATABASE, 'custom-shows') ); customShowDB = new CustomShowDB( path.join(process.env.DATABASE, 'custom-shows') );
let programPlayTimeDB = new ProgramPlayTimeDB( path.join(process.env.DATABASE, 'play-cache') ); let programPlayTimeDB = new ProgramPlayTimeDB( path.join(process.env.DATABASE, 'play-cache') );
let ffmpegSettingsService = new FfmpegSettingsService(db, unlockPath); let ffmpegSettingsService = new FfmpegSettingsService(db);
let plexServerDB = new PlexServerDB(channelService, fillerDB, customShowDB, db);
let plexProxyService = new PlexProxyService(plexServerDB);
async function initializeProgramPlayTimeDB() { async function initializeProgramPlayTimeDB() {
try { try {
@ -130,6 +135,9 @@ activeChannelService = new ActiveChannelService(onDemandService, channelService)
eventService = new EventService(); eventService = new EventService();
let fillerService = new FillerService(fillerDB, plexProxyService,
channelService);
i18next i18next
.use(i18nextBackend) .use(i18nextBackend)
.use(i18nextMiddleware.LanguageDetector) .use(i18nextMiddleware.LanguageDetector)
@ -251,6 +259,38 @@ channelService.on("channel-update", (data) => {
let hdhr = HDHR(db, channelDB) let hdhr = HDHR(db, channelDB)
let app = express() let app = express()
const responseInterceptor = (
req,
res,
next
) => {
let t0 = new Date().getTime();
const originalSend = res.send;
let responseSent = false;
console.log(`${req.method} ${req.url} ...`);
if (req.method === "GET" && req.url.includes("images/uploads") ) {
res.setHeader("Cache-Control", "public, max-age=86400");
}
res.send = function (body) {
if (!responseSent) {
let t1 = new Date().getTime();
let dt = t1 - t0;
console.log(`${req.method} ${req.url} ${res.statusCode} in ${dt}ms`);
responseSent = true;
}
return originalSend.call(this, body);
};
next();
};
app.use(responseInterceptor);
eventService.setup(app); eventService.setup(app);
app.use( app.use(
@ -290,12 +330,12 @@ app.use('/favicon.svg', express.static(
app.use('/custom.css', express.static(path.join(process.env.DATABASE, 'custom.css'))) app.use('/custom.css', express.static(path.join(process.env.DATABASE, 'custom.css')))
// API Routers // API Routers
app.use(api.router(db, channelService, fillerDB, customShowDB, xmltvInterval, guideService, m3uService, eventService, ffmpegSettingsService)) app.use(api.router(db, channelService, fillerDB, customShowDB, xmltvInterval, guideService, m3uService, eventService, ffmpegSettingsService, plexServerDB, plexProxyService, ffmpegInfo, fillerService))
app.use('/api/cache/images', cacheImageService.apiRouters()) app.use('/api/cache/images', cacheImageService.apiRouters())
app.use('/' + fontAwesome, express.static(path.join(process.env.DATABASE, fontAwesome))) app.use('/' + fontAwesome, express.static(path.join(process.env.DATABASE, fontAwesome)))
app.use('/' + bootstrap, express.static(path.join(process.env.DATABASE, bootstrap))) app.use('/' + bootstrap, express.static(path.join(process.env.DATABASE, bootstrap)))
app.use(video.router( channelService, fillerDB, db, programmingService, activeChannelService, programPlayTimeDB )) app.use(video.router( channelService, fillerService, db, programmingService, activeChannelService, programPlayTimeDB, ffmpegInfo ))
app.use(hdhr.router) app.use(hdhr.router)
app.listen(process.env.PORT, () => { app.listen(process.env.PORT, () => {
console.log(`HTTP server running on port: http://*:${process.env.PORT}`) console.log(`HTTP server running on port: http://*:${process.env.PORT}`)

View File

@ -4,8 +4,6 @@ const path = require('path')
const fs = require('fs') const fs = require('fs')
const constants = require('./constants'); const constants = require('./constants');
const JSONStream = require('JSONStream'); const JSONStream = require('JSONStream');
const FFMPEGInfo = require('./ffmpeg-info');
const PlexServerDB = require('./dao/plex-server-db');
const Plex = require("./plex.js"); const Plex = require("./plex.js");
const timeSlotsService = require('./services/time-slots-service'); const timeSlotsService = require('./services/time-slots-service');
@ -24,15 +22,13 @@ function safeString(object) {
} }
module.exports = { router: api } module.exports = { router: api }
function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideService, _m3uService, eventService, ffmpegSettingsService ) { function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideService, _m3uService, eventService, ffmpegSettingsService, plexServerDB, plexProxyService, ffmpegInfo, fillerService ) {
let m3uService = _m3uService; let m3uService = _m3uService;
const router = express.Router() const router = express.Router()
const plexServerDB = new PlexServerDB(channelService, fillerDB, customShowDB, db);
router.get('/api/version', async (req, res) => { router.get('/api/version', async (req, res) => {
try { try {
let ffmpegSettings = db['ffmpeg-settings'].find()[0]; let v = await ffmpegInfo.getVersion();
let v = await (new FFMPEGInfo(ffmpegSettings)).getVersion();
res.send( { res.send( {
"dizquetv" : constants.VERSION_NAME, "dizquetv" : constants.VERSION_NAME,
"ffmpeg" : v, "ffmpeg" : v,
@ -216,7 +212,15 @@ function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideSe
); );
} }
}) })
router.get('/api/plex-server/:serverName64/:path(*)', async (req, res) => {
try {
let result = await plexProxyService.get(req.params.serverName64, req.params.path);
res.status(200).send(result);
} catch (err) {
console.error("Could not use plex proxy.", err);
res.status(404).send("Could not call plex server.");
}
});
// Channels // Channels
router.get('/api/channels', async (req, res) => { router.get('/api/channels', async (req, res) => {
@ -415,7 +419,7 @@ function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideSe
if (typeof(id) === 'undefined') { if (typeof(id) === 'undefined') {
return res.status(400).send("Missing id"); return res.status(400).send("Missing id");
} }
await fillerDB.saveFiller(id, req.body ); await fillerService.saveFiller(id, req.body );
return res.status(204).send({}); return res.status(204).send({});
} catch(err) { } catch(err) {
console.error(err); console.error(err);
@ -424,7 +428,7 @@ function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideSe
}) })
router.put('/api/filler', async (req, res) => { router.put('/api/filler', async (req, res) => {
try { try {
let uuid = await fillerDB.createFiller(req.body ); let uuid = await fillerService.createFiller(req.body );
return res.status(201).send({id: uuid}); return res.status(201).send({id: uuid});
} catch(err) { } catch(err) {
console.error(err); console.error(err);
@ -437,7 +441,7 @@ function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideSe
if (typeof(id) === 'undefined') { if (typeof(id) === 'undefined') {
return res.status(400).send("Missing id"); return res.status(400).send("Missing id");
} }
await fillerDB.deleteFiller(id); await fillerService.deleteFiller(id);
return res.status(204).send({}); return res.status(204).send({});
} catch(err) { } catch(err) {
console.error(err); console.error(err);
@ -451,7 +455,7 @@ function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideSe
if (typeof(id) === 'undefined') { if (typeof(id) === 'undefined') {
return res.status(400).send("Missing id"); return res.status(400).send("Missing id");
} }
let channels = await fillerDB.getFillerChannels(id); let channels = await fillerService.getFillerChannels(id);
if (channels == null) { if (channels == null) {
return res.status(404).send("Filler not found"); return res.status(404).send("Filler not found");
} }
@ -609,6 +613,18 @@ function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideSe
}) })
router.get('/api/ffmpeg-info', async (req, res) => {
try {
let ffmpeg = await ffmpegInfo.getPath();
let obj = { ffmpegPath: ffmpeg }
res.send(obj)
} catch(err) {
console.error(err);
res.status(500).send("error");
}
})
// PLEX SETTINGS // PLEX SETTINGS
router.get('/api/plex-settings', (req, res) => { router.get('/api/plex-settings', (req, res) => {
try { try {

View File

@ -35,5 +35,5 @@ module.exports = {
// staying active, it checks every 5 seconds // staying active, it checks every 5 seconds
PLAYED_MONITOR_CHECK_FREQUENCY: 5*1000, PLAYED_MONITOR_CHECK_FREQUENCY: 5*1000,
VERSION_NAME: "1.5.5" VERSION_NAME: "1.7.0"
} }

View File

@ -4,12 +4,9 @@ let fs = require('fs');
class FillerDB { class FillerDB {
constructor(folder, channelService) { constructor(folder) {
this.folder = folder; this.folder = folder;
this.cache = {}; this.cache = {};
this.channelService = channelService;
} }
async $loadFiller(id) { async $loadFiller(id) {
@ -77,40 +74,8 @@ class FillerDB {
return id; return id;
} }
async getFillerChannels(id) {
let numbers = await this.channelService.getAllChannelNumbers();
let channels = [];
await Promise.all( numbers.map( async(number) => {
let ch = await this.channelService.getChannel(number);
let name = ch.name;
let fillerCollections = ch.fillerCollections;
for (let i = 0 ; i < fillerCollections.length; i++) {
if (fillerCollections[i].id === id) {
channels.push( {
number: number,
name : name,
} );
break;
}
}
ch = null;
} ) );
return channels;
}
async deleteFiller(id) { async deleteFiller(id) {
try { try {
let channels = await this.getFillerChannels(id);
await Promise.all( channels.map( async(channel) => {
console.log(`Updating channel ${channel.number} , remove filler: ${id}`);
let json = await channelService.getChannel(channel.number);
json.fillerCollections = json.fillerCollections.filter( (col) => {
return col.id != id;
} );
await this.channelService.saveChannel( channel.number, json );
} ) );
let f = path.join(this.folder, `${id}.json` ); let f = path.join(this.folder, `${id}.json` );
await new Promise( (resolve, reject) => { await new Promise( (resolve, reject) => {
fs.unlink(f, function (err) { fs.unlink(f, function (err) {
@ -162,30 +127,6 @@ class FillerDB {
} ); } );
} }
async getFillersFromChannel(channel) {
let f = [];
if (typeof(channel.fillerCollections) !== 'undefined') {
f = channel.fillerContent;
}
let loadChannelFiller = async(fillerEntry) => {
let content = [];
try {
let filler = await this.getFiller(fillerEntry.id);
content = filler.content;
} catch(e) {
console.error(`Channel #${channel.number} - ${channel.name} references an unattainable filler id: ${fillerEntry.id}`);
}
return {
id: fillerEntry.id,
content: content,
weight: fillerEntry.weight,
cooldown: fillerEntry.cooldown,
}
};
return await Promise.all(
channel.fillerCollections.map(loadChannelFiller)
);
}
} }

View File

@ -15,6 +15,17 @@ class PlexServerDB
this.showDB = showDB; this.showDB = showDB;
} }
async getPlexServerByName(name) {
let servers = this.db['plex-servers'].find()
let server = servers.sort( (a,b) => { return a.index - b.index } )
.filter( (server) => name === server.name )
[0];
if (typeof(server) === "undefined") {
return null;
}
return server;
}
async fixupAllChannels(name, newServer) { async fixupAllChannels(name, newServer) {
let channelNumbers = await this.channelService.getAllChannelNumbers(); let channelNumbers = await this.channelService.getAllChannelNumbers();
let report = await Promise.all( channelNumbers.map( async (i) => { let report = await Promise.all( channelNumbers.map( async (i) => {

View File

@ -20,8 +20,7 @@
const path = require('path'); const path = require('path');
var fs = require('fs'); var fs = require('fs');
const TARGET_VERSION = 805; const TARGET_VERSION = 1000;
const DAY_MS = 1000 * 60 * 60 * 24;
const STEPS = [ const STEPS = [
// [v, v2, x] : if the current version is v, call x(db), and version becomes v2 // [v, v2, x] : if the current version is v, call x(db), and version becomes v2
@ -44,8 +43,10 @@ const STEPS = [
[ 800, 801, (db) => addImageCache(db) ], [ 800, 801, (db) => addImageCache(db) ],
[ 801, 802, () => addGroupTitle() ], [ 801, 802, () => addGroupTitle() ],
[ 802, 803, () => fixNonIntegerDurations() ], [ 802, 803, () => fixNonIntegerDurations() ],
[ 803, 805, (db) => addFFMpegLock(db) ], [ 803, 900, (db) => fixFFMpegPathSetting(db) ],
[ 804, 805, (db) => addFFMpegLock(db) ], [ 804, 900, (db) => fixFFMpegPathSetting(db) ],
[ 805, 900, (db) => fixFFMpegPathSetting(db) ],
[ 900, 1000, () => fixFillerModes() ],
] ]
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
@ -75,7 +76,7 @@ function appNameChange(db) {
function basicDB(db) { function basicDB(db) {
//this one should either try recovering the db from a very old version //this one should either try recovering the db from a very old version
//or buildl a completely empty db at version 0 //or build a completely empty db at version 0
let ffmpegSettings = db['ffmpeg-settings'].find() let ffmpegSettings = db['ffmpeg-settings'].find()
let plexSettings = db['plex-settings'].find() let plexSettings = db['plex-settings'].find()
@ -386,8 +387,6 @@ function ffmpeg() {
return { return {
//How default ffmpeg settings should look //How default ffmpeg settings should look
configVersion: 5, configVersion: 5,
ffmpegPath: "/usr/bin/ffmpeg",
ffmpegPathLockDate: new Date().getTime() + DAY_MS,
threads: 4, threads: 4,
concatMuxDelay: "0", concatMuxDelay: "0",
logFfmpeg: false, logFfmpeg: false,
@ -681,6 +680,25 @@ function extractFillersFromChannels() {
} }
function fixFillerModes() {
console.log("Fixing filler modes...");
let fillers = path.join(process.env.DATABASE, 'filler');
let fillerFiles = fs.readdirSync(fillers);
for (let i = 0; i < fillerFiles.length; i++) {
if (path.extname( fillerFiles[i] ) === '.json') {
console.log("Migrating filler : " + fillerFiles[i] +"..." );
let fillerPath = path.join(fillers, fillerFiles[i]);
let filler = JSON.parse(fs.readFileSync(fillerPath, 'utf-8'));
if ( typeof(filler.mode) !== "string" ) {
filler.mode = "custom";
}
fs.writeFileSync( fillerPath, JSON.stringify(filler), 'utf-8');
}
}
console.log("Done fixing filler modes.");
}
function addFPS(db) { function addFPS(db) {
let ffmpegSettings = db['ffmpeg-settings'].find()[0]; let ffmpegSettings = db['ffmpeg-settings'].find()[0];
let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json'); let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json');
@ -769,19 +787,23 @@ function addScalingAlgorithm(db) {
fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) ); fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) );
} }
function addFFMpegLock(db) { function fixFFMpegPathSetting(db) {
let ffmpegSettings = db['ffmpeg-settings'].find()[0]; let ffmpegSettings = db['ffmpeg-settings'].find()[0];
let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json'); let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json');
if ( typeof(ffmpegSettings.ffmpegPathLockDate) === 'undefined' || ffmpegSettings.ffmpegPathLockDate == null ) { let f2 = path.join(process.env.DATABASE, 'ffmpeg-path.json');
delete ffmpegSettings.ffmpegPathLockDate;
let fpath = ffmpegSettings.ffmpegPath;
delete ffmpegSettings.ffmpegPath;
console.log("Adding ffmpeg lock. For your security it will not be possible to modify the ffmpeg path using the UI anymore unless you launch dizquetv by following special instructions.."); if (typeof(fpath) === "string" ) {
// We are migrating an existing db that had a ffmpeg path. Make sure console.log(`Found existing setting ffmpegPath=${fpath}, creating setting file (This file will get ignored if you are already setting an environment variable (the docker images do that)).`);
// it's already locked. let pathJson = { ffmpegPath : fpath };
ffmpegSettings.ffmpegPathLockDate = new Date().getTime() - 2 * DAY_MS; fs.writeFileSync( f2, JSON.stringify( pathJson ) );
} }
fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) ); fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) );
} }
function moveBackup(path) { function moveBackup(path) {
if (fs.existsSync(`${process.env.DATABASE}${path}`) ) { if (fs.existsSync(`${process.env.DATABASE}${path}`) ) {
let i = 0; let i = 0;

View File

@ -1,13 +1,98 @@
const exec = require('child_process').exec; const exec = require('child_process').exec;
const fs = require('fs');
const path = require('path');
class FFMPEGInfo { class FFMPEGInfo {
constructor(opts) {
this.ffmpegPath = opts.ffmpegPath constructor(env, dbPath) {
this.initialized = false;
this.env = env;
this.dbPath = dbPath;
this.ffmpegPath = null;
this.origin = "Not found";
} }
async getVersion() {
async initialize() {
let selectedPath = null;
if (typeof(this.env.DIZQUETV_FFMPEG_PATH) === "string") {
selectedPath = this.env.DIZQUETV_FFMPEG_PATH;
this.origin = "env.DIZQUETV_FFMPEG_PATH";
} else {
selectedPath = await this.getPathFromFile(this.dbPath, 'ffmpeg-path.json');
this.origin = "ffmpeg-path.json";
}
if (selectedPath == null) {
//windows Path environment var
let paths = this.env.Path;
if (typeof(paths) === "string") {
let maybe = paths.split(";").filter(
(str) => str.contains("ffmpeg" )
)[0];
if (typeof(maybe) === "string") {
selectedPath = path.join(maybe, "ffmpeg.exe");
this.origin = "Widnows Env. Path";
}
}
}
if (selectedPath == null) {
//Default install path for ffmpeg in n*x OSes.
// if someone has built ffmpeg manually or wants an alternate
// path, they are most likely capable of configuring it manually.
selectedPath = "/usr/bin/ffmpeg";
this.origin = "Default";
}
if (selectedPath != null) {
let version = await this.checkVersion(selectedPath);
if (version == null) {
selectedPath = null;
} else {
console.log(`FFmpeg found: ${selectedPath} from: ${this.origin}. version: ${version}`);
this.ffmpegPath = selectedPath;
}
}
this.initialized = true;
}
async getPath() {
if (! this.initialized) {
await this.initialize();
}
return this.ffmpegPath;
}
async getPathFromFile(folder, fileName) {
let f = path.join(folder, fileName);
try {
let json = await new Promise( (resolve, reject) => {
fs.readFile(f, (err, data) => {
if (err) {
return reject(err);
}
try {
resolve( JSON.parse(data) )
} catch (err) {
reject(err);
}
})
});
let ffmpeg = json["ffmpegPath"];
if (typeof(ffmpeg) === "string") {
return ffmpeg;
} else {
return null;
}
} catch (err) {
console.error(err);
return null;
}
}
async checkVersion(ffmpegPath) {
try { try {
let s = await new Promise( (resolve, reject) => { let s = await new Promise( (resolve, reject) => {
exec( `"${this.ffmpegPath}" -version`, function(error, stdout, stderr){ exec( `"${ffmpegPath}" -version`, function(error, stdout, stderr){
if (error !== null) { if (error !== null) {
reject(error); reject(error);
} else { } else {
@ -23,7 +108,20 @@ class FFMPEGInfo {
return m[1]; return m[1];
} catch (err) { } catch (err) {
console.error("Error getting ffmpeg version", err); console.error("Error getting ffmpeg version", err);
return null;
}
}
async getVersion() {
if (! this.initialized) {
await this.initialize();
}
let version = await this.checkVersion(this.ffmpegPath);
if (version == null) {
return "Error"; return "Error";
} else {
return version;
} }
} }
} }

View File

@ -7,6 +7,7 @@ const REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE = 120;
class FFMPEG extends events.EventEmitter { class FFMPEG extends events.EventEmitter {
constructor(opts, channel) { constructor(opts, channel) {
super() super()
this.ffmpegPath = opts.ffmpegPath;
this.opts = opts; this.opts = opts;
this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`; this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`;
this.ffmpegName = "unnamed ffmpeg"; this.ffmpegName = "unnamed ffmpeg";
@ -22,7 +23,6 @@ class FFMPEG extends events.EventEmitter {
this.opts.maxFPS = REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE; this.opts.maxFPS = REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE;
} }
this.channel = channel this.channel = channel
this.ffmpegPath = opts.ffmpegPath
let resString = opts.targetResolution; let resString = opts.targetResolution;
if ( if (
@ -322,7 +322,7 @@ class FFMPEG extends events.EventEmitter {
if (watermark.animated === true) { if (watermark.animated === true) {
ffmpegArgs.push('-ignore_loop', '0'); ffmpegArgs.push('-ignore_loop', '0');
} }
ffmpegArgs.push(`-i`, `${watermark.url}` ); ffmpegArgs.push(`-i`, `async:cache:${watermark.url}` );
overlayFile = inputFiles++; overlayFile = inputFiles++;
this.ensureResolution = true; this.ensureResolution = true;
} }
@ -601,7 +601,7 @@ class FFMPEG extends events.EventEmitter {
return; return;
} }
if (! this.sentData) { if (! this.sentData) {
this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` }) this.emit('error', { code: code, cmd: `${this.ffmpegPath} ${ffmpegArgs.join(' ')}` })
} }
console.log( `${this.ffmpegName} exited with code 255.` ); console.log( `${this.ffmpegName} exited with code 255.` );
this.emit('close', code) this.emit('close', code)

View File

@ -27,6 +27,13 @@ const CHANNEL_CONTEXT_KEYS = [
module.exports.random = random; module.exports.random = random;
function getCurrentProgramAndTimeElapsed(date, channel) { function getCurrentProgramAndTimeElapsed(date, channel) {
// If seekPosition is not set, default to 0
function getSeek(program) {
return typeof program.seekPosition === 'number' ? program.seekPosition : 0;
}
function getEnd(program) {
return typeof program.endPosition === 'number' ? program.endPosition : null;
}
let channelStartTime = (new Date(channel.startTime)).getTime(); let channelStartTime = (new Date(channel.startTime)).getTime();
if (channelStartTime > date) { if (channelStartTime > date) {
let t0 = date; let t0 = date;
@ -44,23 +51,36 @@ function getCurrentProgramAndTimeElapsed(date, channel) {
let timeElapsed = (date - channelStartTime) % channel.duration let timeElapsed = (date - channelStartTime) % channel.duration
let currentProgramIndex = -1 let currentProgramIndex = -1
for (let y = 0, l2 = channel.programs.length; y < l2; y++) { for (let y = 0, l2 = channel.programs.length; y < l2; y++) {
let program = channel.programs[y] let program = channel.programs[y];
if (timeElapsed - program.duration < 0) { // Compute effective duration based on seek/end
let seek = getSeek(program);
let end = getEnd(program);
let effectiveDurationForProgram = (end !== null ? end : program.duration) - seek;
if (timeElapsed - effectiveDurationForProgram < 0) {
currentProgramIndex = y currentProgramIndex = y
if ( (program.duration > 2*SLACK) && (timeElapsed > program.duration - SLACK) ) { if ( ((end !== null ? end - seek : program.duration - seek) > 2*SLACK) && (timeElapsed > (end !== null ? end - seek : program.duration - seek) - SLACK) ) {
timeElapsed = 0; timeElapsed = 0;
currentProgramIndex = (y + 1) % channel.programs.length; currentProgramIndex = (y + 1) % channel.programs.length;
} }
break; break;
} else { } else {
timeElapsed -= program.duration timeElapsed -= (end !== null ? end - seek : program.duration - seek);
} }
} }
if (currentProgramIndex === -1) if (currentProgramIndex === -1)
throw new Error("No program found; find algorithm fucked up") throw new Error("No program found; find algorithm fucked up")
return { program: channel.programs[currentProgramIndex], timeElapsed: timeElapsed, programIndex: currentProgramIndex } // Attach seek/end for downstream use
let program = channel.programs[currentProgramIndex];
let seek = getSeek(program);
let end = getEnd(program);
let effectiveDurationForProgram = (end !== null ? end : program.duration) - seek;
return {
program: Object.assign({}, program, { seekPosition: seek, endPosition: end, effectiveDuration: effectiveDurationForProgram }),
timeElapsed: timeElapsed,
programIndex: currentProgramIndex
}
} }
function createLineup(programPlayTime, obj, channel, fillers, isFirst) { function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
@ -70,6 +90,9 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
// Helps prevents loosing first few seconds of an episode upon lineup change // Helps prevents loosing first few seconds of an episode upon lineup change
let activeProgram = obj.program let activeProgram = obj.program
let beginningOffset = 0; let beginningOffset = 0;
// Use seekPosition and endPosition for effective start and duration
let seek = typeof activeProgram.seekPosition === 'number' ? activeProgram.seekPosition : 0;
let end = typeof activeProgram.endPosition === 'number' ? activeProgram.endPosition : null;
let lineup = [] let lineup = []
@ -98,7 +121,7 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
if ( (channel.offlineMode === 'clip') && (channel.fallback.length != 0) ) { if ( (channel.offlineMode === 'clip') && (channel.fallback.length != 0) ) {
special = JSON.parse(JSON.stringify(channel.fallback[0])); special = JSON.parse(JSON.stringify(channel.fallback[0]));
} }
let randomResult = pickRandomWithMaxDuration(programPlayTime, channel, fillers, remaining + (isFirst? (7*24*60*60*1000) : 0) ); let randomResult = pickRandomWithMaxDuration(programPlayTime, channel, fillers, remaining + (isFirst? (7*24*60*60*1000) : 0) , isFirst );
filler = randomResult.filler; filler = randomResult.filler;
if (filler == null && (typeof(randomResult.minimumWait) !== undefined) && (remaining > randomResult.minimumWait) ) { if (filler == null && (typeof(randomResult.minimumWait) !== undefined) && (remaining > randomResult.minimumWait) ) {
remaining = randomResult.minimumWait; remaining = randomResult.minimumWait;
@ -114,8 +137,6 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
if (isSpecial) { if (isSpecial) {
if (filler.duration > remaining) { if (filler.duration > remaining) {
fillerstart = filler.duration - remaining; fillerstart = filler.duration - remaining;
} else {
ffillerstart = 0;
} }
} else if(isFirst) { } else if(isFirst) {
fillerstart = Math.max(0, filler.duration - remaining); fillerstart = Math.max(0, filler.duration - remaining);
@ -132,7 +153,7 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
file: filler.file, file: filler.file,
ratingKey: filler.ratingKey, ratingKey: filler.ratingKey,
start: fillerstart, start: fillerstart,
streamDuration: Math.max(1, Math.min(filler.duration - fillerstart, remaining) ), streamDuration: Math.max(1, Math.min(filler.duration - fillerstart, remaining + SLACK ) ),
duration: filler.duration, duration: filler.duration,
fillerId: filler.fillerId, fillerId: filler.fillerId,
beginningOffset: beginningOffset, beginningOffset: beginningOffset,
@ -161,26 +182,36 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
} }
beginningOffset = Math.max(0, originalTimeElapsed - timeElapsed); beginningOffset = Math.max(0, originalTimeElapsed - timeElapsed);
return [ { // Calculate effective start, duration, and streamDuration using seek/end
type: 'program', const effectiveSeek = seek;
title: activeProgram.title, const effectiveEnd = end !== null ? end : activeProgram.duration;
key: activeProgram.key, const effectiveDuration = effectiveEnd - effectiveSeek;
plexFile: activeProgram.plexFile, const effectiveTimeElapsed = Math.max(0, timeElapsed);
file: activeProgram.file, const effectiveStreamDuration = effectiveDuration - effectiveTimeElapsed;
ratingKey: activeProgram.ratingKey,
start: timeElapsed, return [{
streamDuration: activeProgram.duration - timeElapsed, type: 'program',
beginningOffset: beginningOffset, title: activeProgram.title,
duration: activeProgram.duration, key: activeProgram.key,
serverKey: activeProgram.serverKey plexFile: activeProgram.plexFile,
} ]; file: activeProgram.file,
ratingKey: activeProgram.ratingKey,
start: effectiveSeek + effectiveTimeElapsed, // playback should start at seek + elapsed
streamDuration: effectiveStreamDuration,
beginningOffset: beginningOffset,
duration: effectiveDuration,
originalDuration: activeProgram.duration,
serverKey: activeProgram.serverKey,
seekPosition: effectiveSeek,
endPosition: end
}];
} }
function weighedPick(a, total) { function weighedPick(a, total) {
return random.bool(a, total); return random.bool(a, total);
} }
function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuration) { function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuration, isFirst) {
let list = []; let list = [];
for (let i = 0; i < fillers.length; i++) { for (let i = 0; i < fillers.length; i++) {
list = list.concat(fillers[i].content); list = list.concat(fillers[i].content);
@ -190,7 +221,14 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
let t0 = (new Date()).getTime(); let t0 = (new Date()).getTime();
let minimumWait = 1000000000; let minimumWait = 1000000000;
const D = 7*24*60*60*1000; const D = 7*24*60*60*1000;
const E = 5*60*60*1000;
let minPick = null;
let minPickN = 0;
let minPickSet = 0;
let minPickPlayTime = t0 + 1;
let minPickFillerId = 0;
let pickLastPlayed = null;
if (typeof(channel.fillerRepeatCooldown) === 'undefined') { if (typeof(channel.fillerRepeatCooldown) === 'undefined') {
channel.fillerRepeatCooldown = 30*60*1000; channel.fillerRepeatCooldown = 30*60*1000;
} }
@ -234,7 +272,7 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
minimumWait = Math.min(minimumWait, w); minimumWait = Math.min(minimumWait, w);
} }
timeSince = 0; timeSince = 0;
//30 minutes is too little, don't repeat it at all //Can't pick from this filler list due to cooldown
} else if (!pickedList) { } else if (!pickedList) {
let t1 = channelCache.getFillerLastPlayTime( channel.number, fillers[j].id ); let t1 = channelCache.getFillerLastPlayTime( channel.number, fillers[j].id );
let timeSince = ( (t1 == 0) ? D : (t0 - t1) ); let timeSince = ( (t1 == 0) ? D : (t0 - t1) );
@ -260,12 +298,26 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
if (timeSince <= 0) { if (timeSince <= 0) {
continue; continue;
} }
let s = norm_s( (timeSince >= E) ? E : timeSince ); minPickSet += 1;
if (t1 < minPickPlayTime) {
// new minimum
minPickN = 0;
minPickPlayTime = t1;
}
if (t1 == minPickPlayTime) {
// tie
minPickN += 1;
if ( (minPickN == 1) || weighedPick(1,minPickN)) {
minPick = clip;
minPickFillerId = fillers[j].id;
}
}
let d = norm_d( clip.duration); let d = norm_d( clip.duration);
let w = s + d; let w = d;
n += w; n += w;
if (weighedPick(w,n)) { if (weighedPick(w,n)) {
pick1 = clip; pick1 = clip;
pickLastPlayed = t1;
} }
} }
} }
@ -275,6 +327,11 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
} }
} }
let pick = pick1; let pick = pick1;
let Q = Math.max(30, Math.ceil(10* Math.log(minPickSet) / Math.log(2) ) );
if (!isFirst && (minPick != null) && weighedPick(10,Q) ) {
pick = minPick;
pick.fillerId = minPickFillerId;
}
if (pick != null) { if (pick != null) {
pick = JSON.parse( JSON.stringify(pick) ); pick = JSON.parse( JSON.stringify(pick) );
pick.fillerId = fillerId; pick.fillerId = fillerId;
@ -288,18 +345,7 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
} }
function norm_d(x) { function norm_d(x) {
x /= 60 * 1000; return 1 + Math.ceil( Math.log(x+1) / Math.log(2) )
if (x >= 3.0) {
x = 3.0 + Math.log(x);
}
let y = 10000 * ( Math.ceil(x * 1000) + 1 );
return Math.ceil(y / 1000000) + 1;
}
function norm_s(x) {
let y = Math.ceil(x / 600) + 1;
y = y*y;
return Math.ceil(y / 1000000) + 1;
} }

View File

@ -64,24 +64,66 @@ class PlexPlayer {
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
ffmpeg.setAudioOnly( this.context.audioOnly ); ffmpeg.setAudioOnly( this.context.audioOnly );
this.ffmpeg = ffmpeg; this.ffmpeg = ffmpeg;
let streamDuration;
if (typeof(lineupItem.streamDuration)!=='undefined') {
if (lineupItem.start + lineupItem.streamDuration + constants.SLACK < lineupItem.duration) {
streamDuration = lineupItem.streamDuration / 1000;
}
}
let deinterlace = ffmpegSettings.enableFFMPEGTranscoding; //for now it will always deinterlace when transcoding is enabled but this is sub-optimal
// Get basic parameters
let seek = typeof lineupItem.seekPosition === 'number' ? lineupItem.seekPosition : 0;
let end = typeof lineupItem.endPosition === 'number' ? lineupItem.endPosition : null;
let currentElapsed = typeof lineupItem.start === 'number' ? lineupItem.start : 0;
let programEnd = end !== null ? end : lineupItem.duration;
let deinterlace = ffmpegSettings.enableFFMPEGTranscoding;
// Get stream first so we can handle direct play correctly
let stream = await plexTranscoder.getStream(deinterlace); let stream = await plexTranscoder.getStream(deinterlace);
if (this.killed) { if (this.killed) {
return; return;
} }
//let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined; // Calculate parameters differently for direct play vs transcoded mode
//let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : lineupItem.start; let streamDuration;
let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined; let streamStart;
if (stream.directPlay) {
// DIRECT PLAY:
// 1. Calculate duration from endPos to currentElapsed (not from seek to endPos)
streamDuration = Math.max(0, programEnd - currentElapsed) / 1000;
// 2. Start should be ONLY currentElapsed
streamStart = currentElapsed / 1000;
console.log(`[PLEX-PLAYER] Direct Play: Using duration=${streamDuration}s (from currentElapsed=${currentElapsed/1000}s to endPos=${programEnd/1000}s)`);
// For direct play, ignore the streamDuration override with custom end times
if (end !== null && typeof(lineupItem.streamDuration) !== 'undefined') {
// Store original value for reference
stream.streamStats.originalDuration = lineupItem.streamDuration;
stream.streamStats.duration = Math.max(streamDuration * 1000, 60000);
console.log(`[PLEX-PLAYER] Direct Play: Custom end time detected, ignoring streamDuration override: ${lineupItem.streamDuration/1000}s`);
lineupItem.streamDuration = undefined;
}
} else {
// TRANSCODED: Keep existing behavior
streamStart = undefined; // Plex handles this internally for transcoded streams
// Calculate duration based on programEnd and seek
streamDuration = Math.max(0, programEnd - seek) / 1000;
// Apply streamDuration override if present - only for transcoded streams
if (typeof(lineupItem.streamDuration) !== 'undefined') {
streamDuration = lineupItem.streamDuration / 1000;
console.log(`[PLEX-PLAYER] Transcoding: Using override streamDuration: ${streamDuration}s`);
}
console.log(`[PLEX-PLAYER] Transcoding: Using duration=${streamDuration}s (seek=${seek/1000}s, end=${programEnd/1000}s)`);
}
let streamStats = stream.streamStats; let streamStats = stream.streamStats;
streamStats.duration = lineupItem.streamDuration;
// Ensure we have a valid duration for error handling
if (!streamStats.duration) {
streamStats.duration = Math.max(streamDuration * 1000, 60000);
}
let emitter = new EventEmitter(); let emitter = new EventEmitter();
//setTimeout( () => { //setTimeout( () => {

View File

@ -59,8 +59,16 @@ class ChannelService extends events.EventEmitter {
function cleanUpProgram(program) { function cleanUpProgram(program) {
delete program.start if (program.startPosition != null && program.startPosition !== '') {
delete program.stop // Convert startPosition to seekPosition for consistency
program.seekPosition = parseInt(program.startPosition, 10);
delete program.startPosition;
}
if (program.endPosition != null && program.endPosition !== '') {
program.endPosition = parseInt(program.endPosition, 10);
}
delete program.streams; delete program.streams;
delete program.durationStr; delete program.durationStr;
delete program.commercials; delete program.commercials;
@ -91,12 +99,23 @@ function cleanUpChannel(channel) {
delete channel.fillerContent; delete channel.fillerContent;
delete channel.filler; delete channel.filler;
channel.fallback = channel.fallback.flatMap( cleanUpProgram ); channel.fallback = channel.fallback.flatMap( cleanUpProgram );
// Set default for mergeAdjacentPrograms if not already defined
if (typeof channel.mergeAdjacentPrograms === 'undefined') {
channel.mergeAdjacentPrograms = false; // Disabled by default for backward compatibility
}
// Calculate total channel duration using effective durations
channel.duration = 0; channel.duration = 0;
for (let i = 0; i < channel.programs.length; i++) { for (let i = 0; i < channel.programs.length; i++) {
channel.duration += channel.programs[i].duration; let program = channel.programs[i];
let seek = typeof program.seekPosition === 'number' ? program.seekPosition : 0;
let end = typeof program.endPosition === 'number' ? program.endPosition : null;
let effectiveDuration = (end !== null ? end : program.duration) - seek;
channel.duration += effectiveDuration;
} }
return channel; return channel;
} }

View File

@ -4,11 +4,8 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
class FfmpegSettingsService { class FfmpegSettingsService {
constructor(db, unlock) { constructor(db) {
this.db = db; this.db = db;
if (unlock) {
this.unlock();
}
} }
get() { get() {
@ -21,13 +18,6 @@ class FfmpegSettingsService {
return ffmpeg; return ffmpeg;
} }
unlock() {
let ffmpeg = this.getCurrentState();
console.log("ffmpeg path UI unlocked for another day...");
ffmpeg.ffmpegPathLockDate = new Date().getTime() + DAY_MS;
this.db['ffmpeg-settings'].update({ _id: ffmpeg._id }, ffmpeg)
}
update(attempt) { update(attempt) {
let ffmpeg = this.getCurrentState(); let ffmpeg = this.getCurrentState();
@ -62,7 +52,6 @@ class FfmpegSettingsService {
} }
reset() { reset() {
// Even if reseting, it's impossible to unlock the ffmpeg path
let ffmpeg = databaseMigration.defaultFFMPEG() ; let ffmpeg = databaseMigration.defaultFFMPEG() ;
this.update(ffmpeg); this.update(ffmpeg);
return this.get(); return this.get();

View File

@ -0,0 +1,150 @@
const events = require('events')
const FILLER_UPDATE = 30 * 60 * 1000; //30 minutes might be too aggressive
//this will be configurable one day.
class FillerService extends events.EventEmitter {
constructor(fillerDB, plexProxyService, channelService) {
super();
this.fillerDB = fillerDB;
this.plexProxyService = plexProxyService;
this.channelService = channelService;
}
async saveFiller(id, body) {
body = await this.prework(body);
return this.fillerDB.saveFiller(id, body);
}
async createFiller(body) {
body = await this.prework(body);
return this.fillerDB.createFiller(body);
}
async getFillerChannels(id) {
let numbers = await this.channelService.getAllChannelNumbers();
let channels = [];
await Promise.all( numbers.map( async(number) => {
let ch = await this.channelService.getChannel(number);
let name = ch.name;
let fillerCollections = ch.fillerCollections;
for (let i = 0 ; i < fillerCollections.length; i++) {
if (fillerCollections[i].id === id) {
channels.push( {
number: number,
name : name,
} );
break;
}
}
ch = null;
} ) );
return channels;
}
async deleteFiller(id) {
try {
let channels = await this.getFillerChannels(id);
await Promise.all( channels.map( async(channel) => {
console.log(`Updating channel ${channel.number} , remove filler: ${id}`);
let json = await channelService.getChannel(channel.number);
json.fillerCollections = json.fillerCollections.filter( (col) => {
return col.id != id;
} );
await this.channelService.saveChannel( channel.number, json );
} ) );
} finally {
await this.fillerDB.deleteFiller(id);
}
}
async prework(body) {
if (body.mode === "import") {
body.content = await this.getContents(body);
body.import.lastRefreshTime = new Date().getTime();
} else {
delete body.import;
}
return body;
}
async getContents(body) {
let serverKey = body.import.serverName;
let key = body.import.key;
let content = await this.plexProxyService.getKeyMediaContents(serverKey, key);
console.log(JSON.stringify(content));
return content;
}
async getFillersFromChannel(channel) {
let loadChannelFiller = async(fillerEntry) => {
let content = [];
try {
let filler = await this.fillerDB.getFiller(fillerEntry.id);
await this.fillerUsageWatcher(fillerEntry.id, filler);
content = filler.content;
} catch(e) {
console.error(`Channel #${channel.number} - ${channel.name} references an unattainable filler id: ${fillerEntry.id}`, e);
}
return {
id: fillerEntry.id,
content: content,
weight: fillerEntry.weight,
cooldown: fillerEntry.cooldown,
}
};
return await Promise.all(
channel.fillerCollections.map(loadChannelFiller)
);
}
async fillerUsageWatcher(id, filler) {
if (filler.mode === "import") {
//I need to upgrade nodejs version ASAP
let lastTime = 0;
if (
(typeof(filler.import) !== "undefined")
&&
!isNaN(filler.import.lastRefreshTime)
) {
lastTime = filler.import.lastRefreshTime;
}
let t = new Date().getTime();
if ( t - lastTime >= FILLER_UPDATE) {
//time to do an update.
if ( (typeof(filler.content) === "undefined")
|| (filler.content.length == 0)
) {
//It should probably be an sync update...
await this.refreshFiller(id);
} else {
this.refreshFiller(id);
}
}
}
}
async refreshFiller(id) {
let t0 = new Date().getTime();
console.log(`Refreshing filler with id=${id}`);
try {
let filler = await this.fillerDB.getFiller(id);
await this.saveFiller(id, filler);
} catch (err) {
console.log(`Unable to update filler: ${id}`, err);
} finally {
let t1 = new Date().getTime();
console.log(`Refreshed filler with id=${id} in ${t1-t0}ms`);
}
}
}
module.exports = FillerService

View File

@ -0,0 +1,94 @@
const Plex = require('../plex.js')
const events = require('events')
class PlexProxyService extends events.EventEmitter {
constructor(plexServerDB) {
super();
this.plexServerDB = plexServerDB;
}
async get(serverName64, path) {
let plexServer = await getPlexServer64(this.plexServerDB, serverName64);
// A potential area of improvement is to reuse the client when possible
let client = new Plex(plexServer);
return { MediaContainer: await client.Get("/" + path) };
}
async getKeyMediaContents(serverName, key) {
let plexServer = await getPlexServer(this.plexServerDB, serverName);
let client = new Plex(plexServer);
let obj = { MediaContainer: await client.Get(key) };
let metadata = obj.MediaContainer.Metadata;
if ( typeof(metadata) !== "object") {
return [];
}
metadata = metadata.map( (item) => fillerMapper(serverName, item) );
return metadata;
}
}
function fillerMapper(serverName, plexMetadata) {
let image = {};
if ( (typeof(plexMetadata.Image) === "object")
&& (typeof(plexMetadata.Image[0]) === "object")
) {
image = plexMetadata.Image[0];
}
let media = {};
if ( (typeof(plexMetadata.Media) === "object")
&& (typeof(plexMetadata.Media[0]) === "object")
) {
media = plexMetadata.Media[0];
}
let part = {};
if ( (typeof(media.Part) === "object")
&& (typeof(media.Part[0]) === "object")
) {
part = media.Part[0];
}
return {
title : plexMetadata.title,
key : plexMetadata.key,
ratingKey: plexMetadata.ratingKey,
icon : image.url,
type : plexMetadata.type,
duration : part.duration,
durationStr : undefined,
summary : "",
date : "",
year : plexMetadata.year,
plexFile : part.key,
file : part.file,
showTitle: plexMetadata.title,
episode : 1,
season : 1,
serverKey: serverName,
commercials: [],
}
}
async function getPlexServer(plexServerDB, serverKey) {
let server = await plexServerDB.getPlexServerByName(serverKey);
if (server == null) {
throw Error("server not found");
}
return server;
}
async function getPlexServer64(plexServerDB, serverName64) {
let serverKey = Buffer.from(serverName64, 'base64').toString('utf-8');
return await getPlexServer(plexServerDB, serverKey);
}
module.exports = PlexProxyService

View File

@ -1,6 +1,6 @@
const events = require('events') const events = require('events')
const constants = require("../constants"); const constants = require("../constants");
const FALLBACK_ICON = "https://raw.githubusercontent.com/vexorain/dizquetv/main/resources/dizquetv.png"; const FALLBACK_ICON = "https://raw.githubusercontent.com/vexorian/dizquetv/main/resources/dizquetv.png";
const throttle = require('./throttle'); const throttle = require('./throttle');
class TVGuideService extends events.EventEmitter class TVGuideService extends events.EventEmitter
@ -83,7 +83,12 @@ class TVGuideService extends events.EventEmitter
let arr = new Array( channel.programs.length + 1); let arr = new Array( channel.programs.length + 1);
arr[0] = 0; arr[0] = 0;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
let d = channel.programs[i].duration; // Calculate effective duration based on seekPosition and endPosition
let program = channel.programs[i];
let seek = typeof program.seekPosition === 'number' ? program.seekPosition : 0;
let end = typeof program.endPosition === 'number' ? program.endPosition : null;
let d = (end !== null ? end : program.duration) - seek;
if (d == 0) { if (d == 0) {
console.log("Found program with duration 0, correcting it"); console.log("Found program with duration 0, correcting it");
d = 1; d = 1;
@ -92,7 +97,6 @@ class TVGuideService extends events.EventEmitter
console.log( `Found program in channel ${channel.number} with non-integer duration ${d}, correcting it`); console.log( `Found program in channel ${channel.number} with non-integer duration ${d}, correcting it`);
d = Math.ceil(d); d = Math.ceil(d);
} }
channel.programs[i].duration = d;
arr[i+1] = arr[i] + d; arr[i+1] = arr[i] + d;
await this._throttle(); await this._throttle();
} }
@ -361,9 +365,69 @@ class TVGuideService extends events.EventEmitter
} }
} }
// Only merge programs if enabled in channel settings
if (channel.mergeAdjacentPrograms === true) {
result.programs = this.mergeAdjacentSamePrograms(result.programs);
}
return result; return result;
} }
// Merge adjacent programs that have the same ratingKey
mergeAdjacentSamePrograms(programs) {
if (!programs || programs.length <= 1) {
return programs;
}
console.log(`Before merging: ${programs.length} programs`);
// Debug: Check how many programs have ratingKeys
const programsWithRatingKey = programs.filter(p => p.ratingKey);
console.log(`Programs with ratingKey: ${programsWithRatingKey.length}`);
const mergedPrograms = [];
let i = 0;
while (i < programs.length) {
const currentProgram = programs[i];
// Skip if this is a flex/placeholder program with no ratingKey
if (!currentProgram.ratingKey) {
mergedPrograms.push(currentProgram);
i++;
continue;
}
// Look ahead to see if there are adjacent programs with the same ratingKey
let j = i + 1;
while (j < programs.length &&
programs[j].ratingKey &&
programs[j].ratingKey === currentProgram.ratingKey) {
j++;
}
if (j > i + 1) {
// We found programs to merge
console.log(`Merging ${j-i} programs with ratingKey ${currentProgram.ratingKey}`);
const mergedProgram = {...currentProgram};
mergedProgram.stop = programs[j-1].stop;
mergedPrograms.push(mergedProgram);
// Skip all the programs we just merged
i = j;
} else {
// No programs to merge, just add the current one
mergedPrograms.push(currentProgram);
i++;
}
}
console.log(`After merging: ${mergedPrograms.length} programs`);
return mergedPrograms;
}
async buildItManaged() { async buildItManaged() {
let t0 = this.currentUpdate; let t0 = this.currentUpdate;
let t1 = this.currentLimit; let t1 = this.currentLimit;
@ -580,6 +644,7 @@ function makeEntry(channel, x) {
icon: icon, icon: icon,
title: title, title: title,
sub: sub, sub: sub,
ratingKey: x.program.ratingKey // Add ratingKey to preserve it for merging
} }
} }

View File

@ -18,17 +18,18 @@ async function shutdown() {
stopPlayback = true; stopPlayback = true;
} }
function video( channelService, fillerDB, db, programmingService, activeChannelService, programPlayTimeDB ) { function video( channelService, fillerService, db, programmingService, activeChannelService, programPlayTimeDB, ffmpegInfo ) {
var router = express.Router() var router = express.Router()
router.get('/setup', (req, res) => { router.get('/setup', async (req, res) => {
let ffmpegSettings = db['ffmpeg-settings'].find()[0] let ffmpegSettings = db['ffmpeg-settings'].find()[0]
// Check if ffmpeg path is valid // Check if ffmpeg path is valid
if (!fs.existsSync(ffmpegSettings.ffmpegPath)) { let ffmpegPath = await ffmpegInfo.getPath();
res.status(500).send("FFMPEG path is invalid. The file (executable) doesn't exist.") if (ffmpegPath == null) {
console.error("The FFMPEG Path is invalid. Please check your configuration.") res.status(500).send("Missing FFmpeg.")
return return
} }
ffmpegSettings.ffmpegPath = ffmpegPath;
console.log(`\r\nStream starting. Channel: 1 (dizqueTV)`) console.log(`\r\nStream starting. Channel: 1 (dizqueTV)`)
@ -72,14 +73,14 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS
return return
} }
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
// Check if ffmpeg path is valid // Check if ffmpeg path is valid
if (!fs.existsSync(ffmpegSettings.ffmpegPath)) { let ffmpegSettings = db['ffmpeg-settings'].find()[0]
res.status(500).send("FFMPEG path is invalid. The file (executable) doesn't exist.") let ffmpegPath = await ffmpegInfo.getPath();
console.error("The FFMPEG Path is invalid. Please check your configuration.") if (ffmpegPath == null) {
res.status(500).send("Missing FFmpeg.")
return return
} }
ffmpegSettings.ffmpegPath = ffmpegPath;
if (step == 0) { if (step == 0) {
res.writeHead(200, { res.writeHead(200, {
@ -174,14 +175,14 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS
let isBetween = ( (typeof req.query.between !== 'undefined') && (req.query.between=='1') ); let isBetween = ( (typeof req.query.between !== 'undefined') && (req.query.between=='1') );
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
// Check if ffmpeg path is valid // Check if ffmpeg path is valid
if (!fs.existsSync(ffmpegSettings.ffmpegPath)) { let ffmpegSettings = db['ffmpeg-settings'].find()[0]
res.status(500).send("FFMPEG path is invalid. The file (executable) doesn't exist.") let ffmpegPath = await ffmpegInfo.getPath();
console.error("The FFMPEG Path is invalid. Please check your configuration.") if (ffmpegPath == null) {
res.status(500).send("Missing FFmpeg.")
return return
} }
ffmpegSettings.ffmpegPath = ffmpegPath;
if (ffmpegSettings.disablePreludes === true) { if (ffmpegSettings.disablePreludes === true) {
//disable the preludes //disable the preludes
@ -303,7 +304,7 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS
if ( (prog == null) || (typeof(prog) === 'undefined') || (prog.program == null) || (typeof(prog.program) == "undefined") ) { if ( (prog == null) || (typeof(prog) === 'undefined') || (prog.program == null) || (typeof(prog.program) == "undefined") ) {
throw "No video to play, this means there's a serious unexpected bug or the channel db is corrupted." throw "No video to play, this means there's a serious unexpected bug or the channel db is corrupted."
} }
let fillers = await fillerDB.getFillersFromChannel(brandChannel); let fillers = await fillerService.getFillersFromChannel(brandChannel);
try { try {
let lineup = helperFuncs.createLineup(programPlayTimeDB, prog, brandChannel, fillers, isFirst) let lineup = helperFuncs.createLineup(programPlayTimeDB, prog, brandChannel, fillers, isFirst)
lineupItem = lineup.shift(); lineupItem = lineup.shift();
@ -332,9 +333,9 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS
if (typeof(u) !== 'undefined') { if (typeof(u) !== 'undefined') {
let u2 = upperBound; let u2 = upperBound;
if ( typeof(lineupItem.streamDuration) !== 'undefined') { if ( typeof(lineupItem.streamDuration) !== 'undefined') {
u2 = Math.min(u2, lineupItem.streamDuration); u2 = Math.min(u2 , lineupItem.streamDuration);
} }
lineupItem.streamDuration = Math.min(u2, u); lineupItem.streamDuration = Math.min(lineupItem.streamDuration, Math.min(u2, u) + constants.SLACK);
upperBound = lineupItem.streamDuration; upperBound = lineupItem.streamDuration;
} }
channelCache.recordPlayback( programPlayTimeDB, redirectChannels[i].number, t0, lineupItem ); channelCache.recordPlayback( programPlayTimeDB, redirectChannels[i].number, t0, lineupItem );

View File

@ -964,9 +964,24 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
scope.hasFlex = false; scope.hasFlex = false;
for (let i = 0, l = scope.channel.programs.length; i < l; i++) { for (let i = 0, l = scope.channel.programs.length; i < l; i++) {
// Calculate effective duration using seekPosition and endPosition
let program = scope.channel.programs[i];
let seek = typeof program.seekPosition === 'number' ? program.seekPosition : 0;
let end = typeof program.endPosition === 'number' ? program.endPosition : null;
let effectiveDuration = (end !== null ? end : program.duration) - seek;
// Store effective values for consistency
program.effectiveStart = seek;
program.effectiveDuration = effectiveDuration;
// Set start time based on accumulated duration
scope.channel.programs[i].start = new Date(scope.channel.startTime.valueOf() + scope.channel.duration) scope.channel.programs[i].start = new Date(scope.channel.startTime.valueOf() + scope.channel.duration)
scope.channel.programs[i].$index = i; scope.channel.programs[i].$index = i;
scope.channel.duration += scope.channel.programs[i].duration
// Use effectiveDuration for timeline calculation
scope.channel.duration += effectiveDuration;
// Set stop time using the updated duration
scope.channel.programs[i].stop = new Date(scope.channel.startTime.valueOf() + scope.channel.duration) scope.channel.programs[i].stop = new Date(scope.channel.startTime.valueOf() + scope.channel.duration)
if (scope.channel.programs[i].isOffline) { if (scope.channel.programs[i].isOffline) {
scope.hasFlex = true; scope.hasFlex = true;
@ -1014,9 +1029,6 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
} else if (channel.overlayIcon && !validURL(channel.icon)) { } else if (channel.overlayIcon && !validURL(channel.icon)) {
scope.error.icon = "Please enter a valid image URL. Cant overlay an invalid image." scope.error.icon = "Please enter a valid image URL. Cant overlay an invalid image."
scope.error.tab = "basic"; scope.error.tab = "basic";
} else if (now < channel.startTime) {
scope.error.startTime = "Start time must not be set in the future."
scope.error.tab = "programming";
} else if (channel.programs.length === 0) { } else if (channel.programs.length === 0) {
scope.error.programs = "No programs have been selected. Select at least one program." scope.error.programs = "No programs have been selected. Select at least one program."
scope.error.tab = "programming"; scope.error.tab = "programming";

View File

@ -6,7 +6,14 @@ module.exports = function (dizquetv, resolutionOptions) {
scope: { scope: {
}, },
link: function (scope, element, attrs) { link: function (scope, element, attrs) {
//add validations to ffmpeg settings, speciall commas in codec name
scope.ffmpegPathLoading = true;
scope.ffmpegPath = ""
dizquetv.getFFMpegPath().then( (fpath) => {
scope.ffmpegPath = fpath.ffmpegPath;
scope.ffmpegPathLoading = false;
});
//add validations to ffmpeg settings, special commas in codec name
dizquetv.getFfmpegSettings().then((settings) => { dizquetv.getFfmpegSettings().then((settings) => {
scope.settings = settings scope.settings = settings
}) })

View File

@ -1,4 +1,4 @@
module.exports = function ($timeout, commonProgramTools, getShowData) { module.exports = function ($timeout, dizquetv, commonProgramTools, getShowData) {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'templates/filler-config.html', templateUrl: 'templates/filler-config.html',
@ -13,6 +13,16 @@ module.exports = function ($timeout, commonProgramTools, getShowData) {
scope.content = []; scope.content = [];
scope.visible = false; scope.visible = false;
scope.error = undefined; scope.error = undefined;
scope.modes = [ {
name: "import",
description: "Collection/Playlist from Plex",
}, {
name: "custom",
description: "Custom List of Clips",
} ];
scope.servers = [];
scope.libraries = [];
scope.sources = [];
function refreshContentIndexes() { function refreshContentIndexes() {
for (let i = 0; i < scope.content.length; i++) { for (let i = 0; i < scope.content.length; i++) {
@ -47,20 +57,173 @@ module.exports = function ($timeout, commonProgramTools, getShowData) {
console.log("movedFunction(" + index + ")"); console.log("movedFunction(" + index + ")");
} }
scope.serverChanged = async () => {
if (scope.server === "") {
scope.libraryKey = "";
return;
}
scope.loadingLibraries = true;
try {
let libraries = (await dizquetv.getFromPlexProxy(scope.server, "/library/sections")).Directory;
if ( typeof(libraries) === "undefined") {
libraries = []
}
let officialLibraries = libraries.map( (library) => {
return {
"key" : library.key,
"description" : library.title,
}
} );
let defaultLibrary = {
"key": "",
"description" : "Select a Library...",
}
let playlists = [
{
"key": "$PLAYLISTS",
"description" : "Playlists",
}
];
let combined = officialLibraries.concat(playlists);
if (! combined.some( (library) => library.key === scope.libraryKey) ) {
scope.libraryKey = "";
scope.libraries = [defaultLibrary].concat(combined);
} else {
scope.libraries = combined;
}
} catch (err) {
scope.libraries = [ { name: "", description: "Unable to load libraries"} ];
scope.libraryKey = ""
throw err;
} finally {
scope.loadingLibraries = false;
$timeout( () => {}, 0);
}
}
scope.linker( (filler) => { scope.libraryChanged = async () => {
if (scope.libraryKey == null) {
throw Error(`null libraryKey? ${scope.libraryKey} ${new Date().getTime()} `);
}
if (scope.libraryKey === "") {
scope.sourceKey = "";
return;
}
scope.loadingCollections = true;
try {
let collections;
if (scope.libraryKey === "$PLAYLISTS") {
collections = (await dizquetv.getFromPlexProxy(scope.server, `/playlists`)).Metadata;
} else {
collections = (await dizquetv.getFromPlexProxy(scope.server, `/library/sections/${scope.libraryKey}/collections`));
collections = collections.Metadata
}
if (typeof(collections) === "undefined") {
//when the library has no collections it returns size=0
//and no array
collections = [];
}
let officialCollections = collections.map( (col) => {
return {
"key" : col.key,
"description" : col.title,
}
} );
let defaultSource = {
"key": "",
"description" : "Select a Source...",
};
if (officialCollections.length == 0) {
defaultSource = {
"key": "",
"description" : "(No collections/lists found)",
}
}
if (! officialCollections.some( (col) => col.key === scope.sourceKey ) ) {
scope.sourceKey = "";
scope.sources = [defaultSource].concat(officialCollections);
} else {
scope.sources = officialCollections;
}
} catch (err) {
scope.sources = [ { name: "", description: "Unable to load collections"} ];
scope.sourceKey = "";
throw err;
} finally {
scope.loadingCollections = false;
$timeout( () => {}, 0);
}
}
let reloadServers = async() => {
scope.loadingServers = true;
try {
let servers = await dizquetv.getPlexServers();
scope.servers = servers.map( (s) => {
return {
"name" : s.name,
"description" : `Plex - ${s.name}`,
}
} );
let defaultServer = {
name: "",
description: "Select a Plex server..."
};
if (! scope.servers.some( (server) => server.name === scope.server) ) {
scope.server = "";
scope.servers = [defaultServer].concat(scope.servers);
}
} catch (err) {
scope.server = "";
scope.servers = [ {name:"", description:"Could not load servers"} ];
throw err;
} finally {
scope.loadingServers = false;
$timeout( () => {}, 0);
}
await scope.serverChanged();
await scope.libraryChanged();
};
scope.linker( async (filler) => {
if ( typeof(filler) === 'undefined') { if ( typeof(filler) === 'undefined') {
scope.name = ""; scope.name = "";
scope.content = []; scope.content = [];
scope.id = undefined; scope.id = undefined;
scope.title = "Create Filler List"; scope.title = "Create Filler List";
scope.mode = "import";
scope.server = "";
scope.libraryKey = "";
scope.sourceKey = "";
} else { } else {
scope.name = filler.name; scope.name = filler.name;
scope.content = filler.content; scope.content = filler.content;
scope.id = filler.id; scope.id = filler.id;
scope.title = "Edit Filler List"; scope.title = "Edit Filler List";
scope.mode = filler.mode;
scope.server = filler?.import?.serverName;
if ( typeof(scope.server) !== "string" ) {
scope.server = "";
}
scope.libraryKey = filler?.import?.meta?.libraryKey;
if ( typeof(scope.libraryKey) !== "string" ) {
scope.libraryKey = "";
}
scope.sourceKey = filler?.import?.key;
if ( typeof(scope.sourceKey) !== "string" ) {
scope.sourceKey = "";
}
} }
await reloadServers();
scope.source = "";
refreshContentIndexes(); refreshContentIndexes();
scope.visible = true; scope.visible = true;
} ); } );
@ -73,8 +236,17 @@ module.exports = function ($timeout, commonProgramTools, getShowData) {
if ( (typeof(scope.name) === 'undefined') || (scope.name.length == 0) ) { if ( (typeof(scope.name) === 'undefined') || (scope.name.length == 0) ) {
scope.error = "Please enter a name"; scope.error = "Please enter a name";
} }
if ( scope.content.length == 0) { if ( scope?.mode === "import" ) {
scope.error = "Please add at least one clip."; if ( (typeof(scope?.server) !== "string" ) || (scope?.server === "") ) {
scope.error = "Please select a server"
}
if ( (typeof(scope?.source) !== "string" ) && (scope?.source === "") ) {
scope.error = "Please select a source."
}
} else {
if ( scope.content.length == 0) {
scope.error = "Please add at least one clip.";
}
} }
if (typeof(scope.error) !== 'undefined') { if (typeof(scope.error) !== 'undefined') {
$timeout( () => { $timeout( () => {
@ -83,14 +255,30 @@ module.exports = function ($timeout, commonProgramTools, getShowData) {
return; return;
} }
scope.visible = false; scope.visible = false;
scope.onDone( { let object = {
name: scope.name, name: scope.name,
content: scope.content.map( (c) => { content: scope.content.map( (c) => {
delete c.$index delete c.$index
return c; return c;
} ), } ),
id: scope.id, id: scope.id,
} ); mode: scope.mode,
};
if (object.mode === "import") {
object.content = [];
//In reality dizqueTV only needs to know the server name
//and the source key, the meta object is for extra data
//that is useful for external things like this UI.
object.import = {
serverName : scope.server,
key: scope.sourceKey,
meta: {
libraryKey : scope.libraryKey,
}
}
}
scope.onDone( object );
} }
scope.getText = (clip) => { scope.getText = (clip) => {
let show = getShowData(clip); let show = getShowData(clip);

View File

@ -31,13 +31,7 @@ module.exports = function (plex, dizquetv, $timeout, commonProgramTools) {
}); });
} }
scope.selectOrigin = function (origin) { scope.selectOrigin = function (origin) {
if ( origin.type === 'plex' ) { updateLibrary(origin);
scope.plexServer = origin.server;
updateLibrary(scope.plexServer);
} else {
scope.plexServer = undefined;
updateCustomShows();
}
} }
scope._onFinish = (s, insertPoint) => { scope._onFinish = (s, insertPoint) => {
if (s.length > scope.limit) { if (s.length > scope.limit) {
@ -99,20 +93,31 @@ module.exports = function (plex, dizquetv, $timeout, commonProgramTools) {
"type" : "plex", "type" : "plex",
"name" : `Plex - ${s.name}`, "name" : `Plex - ${s.name}`,
"server": s, "server": s,
"loaded" : false,
} }
} ); } );
scope.currentOrigin = scope.origins[0];
scope.plexServer = scope.currentOrigin.server;
scope.origins.push( { scope.origins.push( {
"type": "dizquetv", "type": "dizquetv",
"name" : "dizqueTV - Custom Shows", "name" : "dizqueTV - Custom Shows",
"loaded" : false,
} ); } );
updateLibrary(scope.plexServer) updateLibrary(scope.origins[0])
}) })
let updateLibrary = async(server) => { let updateLibrary = async(origin) => {
scope.currentOrigin = origin;
origin.loaded = false;
if ( origin.type !== 'plex' ) {
scope.plexServer = undefined;
await updateCustomShows();
origin.loaded = true;
return;
}
let server = scope.currentOrigin.server;
let lib = await plex.getLibrary(server); let lib = await plex.getLibrary(server);
let play = await plex.getPlaylists(server); let play = await plex.getPlaylists(server);
scope.currentOrigin.loaded = true;
scope.plexServer = server;
play.forEach( p => { play.forEach( p => {
p.type = "playlist"; p.type = "playlist";

View File

@ -20,12 +20,9 @@ module.exports = function (plex, dizquetv, $timeout) {
scope.servers = servers; scope.servers = servers;
if(servers) { if(servers) {
for (let i = 0; i < scope.servers.length; i++) { for (let i = 0; i < scope.servers.length; i++) {
scope.servers[i].uiStatus = 0;
scope.servers[i].backendStatus = 0; scope.servers[i].backendStatus = 0;
let t = (new Date()).getTime(); let t = (new Date()).getTime();
scope.servers[i].uiPending = t;
scope.servers[i].backendPending = t; scope.servers[i].backendPending = t;
scope.refreshUIStatus(t, i);
scope.refreshBackendStatus(t, i); scope.refreshBackendStatus(t, i);
} }
} }
@ -51,22 +48,6 @@ module.exports = function (plex, dizquetv, $timeout) {
scope.refreshServerList(); scope.refreshServerList();
} }
scope.isAnyUIBad = () => {
let t = (new Date()).getTime();
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;
};
scope.isAnyBackendBad = () => { scope.isAnyBackendBad = () => {
let t = (new Date()).getTime(); let t = (new Date()).getTime();
if(scope.servers) { if(scope.servers) {
@ -84,15 +65,6 @@ module.exports = function (plex, dizquetv, $timeout) {
}; };
scope.refreshUIStatus = async (t, i) => {
let s = await plex.check(scope.servers[i]);
if (scope.servers[i].uiPending == t) {
// avoid updating for a previous instance of the row
scope.servers[i].uiStatus = s;
}
scope.$apply();
};
scope.refreshBackendStatus = async (t, i) => { scope.refreshBackendStatus = async (t, i) => {
let s = await dizquetv.checkExistingPlexServer(scope.servers[i].name); let s = await dizquetv.checkExistingPlexServer(scope.servers[i].name);
if (scope.servers[i].backendPending == t) { if (scope.servers[i].backendPending == t) {

View File

@ -9,29 +9,138 @@ module.exports = function ($timeout) {
onDone: "=onDone" onDone: "=onDone"
}, },
link: function (scope, element, attrs) { link: function (scope, element, attrs) {
scope.finished = (prog) => { // Conversion functions remain the same (using NaN for invalid)
if (prog.title === "") scope.msToTimeString = function(ms) {
scope.error = { title: 'You must set a program title.' } if (typeof ms !== 'number' || isNaN(ms) || ms < 0) { return ''; }
else if (prog.type === "episode" && prog.showTitle == "") let totalS = Math.floor(ms / 1000);
scope.error = { showTitle: 'You must set a show title when the program type is an episode.' } let s = totalS % 60;
else if (prog.type === "episode" && (prog.season == null)) let m = Math.floor(totalS / 60);
scope.error = { season: 'You must set a season number when the program type is an episode.' } return m + ":" + ( (s < 10) ? ("0" + s) : s );
else if (prog.type === "episode" && prog.season <= 0) };
scope.error = { season: 'Season number musat be greater than 0' }
else if (prog.type === "episode" && (prog.episode == null))
scope.error = { episode: 'You must set a episode number when the program type is an episode.' }
else if (prog.type === "episode" && prog.episode <= 0)
scope.error = { episode: 'Episode number musat be greater than 0' }
if (scope.error != null) { scope.timeStringToMs = function(timeString) {
$timeout(() => { if (timeString == null || timeString.trim() === '') { return 0; } // Empty is 0ms
scope.error = null let parts = timeString.split(':');
}, 3500) if (parts.length !== 2) { return NaN; } // Invalid format
return let min = parseInt(parts[0], 10);
let sec = parseInt(parts[1], 10);
if (isNaN(min) || isNaN(sec) || sec < 0 || sec >= 60 || min < 0) { return NaN; } // Invalid numbers
return (min * 60 + sec) * 1000;
};
// Intermediate model for UI binding
scope.timeInput = {
seek: '',
end: ''
};
let initialProgramLoad = true; // Flag for first load
// Watch program to initialize/reset intermediate model ONLY
scope.$watch('program', function(newProgram) {
if (newProgram) {
console.log("Program loaded/changed. Initializing timeInput.");
// Initialize timeInput from program data
let initialSeekMs = newProgram.seekPosition;
let initialEndMs = newProgram.endPosition;
scope.timeInput.seek = scope.msToTimeString( (typeof initialSeekMs === 'number' && !isNaN(initialSeekMs)) ? initialSeekMs : 0 );
scope.timeInput.end = (typeof initialEndMs === 'number' && !isNaN(initialEndMs) && initialEndMs > 0) ? scope.msToTimeString(initialEndMs) : '';
initialProgramLoad = false; // Mark initial load complete
} else {
// Clear inputs if program is removed
scope.timeInput.seek = '';
scope.timeInput.end = '';
initialProgramLoad = true; // Reset flag if program is cleared
}
});
scope.finished = (prog) => {
// prog here is the original program object passed to the directive
// We need to validate and apply changes from scope.timeInput
let currentError = null;
// --- Validate Time Inputs ---
let seekInputString = scope.timeInput.seek;
let endInputString = scope.timeInput.end;
let seekMs = scope.timeStringToMs(seekInputString);
let endMs = scope.timeStringToMs(endInputString); // Will be 0 if empty, NaN if invalid
// Check for invalid formats (NaN)
if (isNaN(seekMs)) {
currentError = { seekPosition: 'Invalid start time format. Use MM:SS.' };
} else if (isNaN(endMs) && endInputString && endInputString.trim() !== '') {
// Only error on endMs if it's not empty but is invalid
currentError = { endPosition: 'Invalid end time format. Use MM:SS.' };
} else {
// Format is valid or empty, now check relationship
// Treat endMs === 0 (from empty input) as 'undefined' for comparison
let effectiveEndMs = (endMs === 0 && (endInputString == null || endInputString.trim() === '')) ? undefined : endMs;
// Validate Seek Position against Duration first
if (prog.duration && seekMs >= (prog.duration - 1000)) {
currentError = currentError || {};
currentError.seekPosition = 'Start time must be at least 1 second before the program ends.';
}
// Then validate End Position if specified
else if (typeof effectiveEndMs === 'number') {
if (effectiveEndMs <= seekMs) {
currentError = currentError || {};
currentError.endPosition = 'End position must be greater than start position.';
} else if (prog.duration && effectiveEndMs > prog.duration) {
// Error if end time EXCEEDS program duration
currentError = currentError || {};
currentError.endPosition = 'End position cannot exceed program duration (' + scope.msToTimeString(prog.duration) + ').';
}
// Check if start/end combination is valid (at least 1s apart)
else if ((effectiveEndMs - seekMs) < 1000) {
currentError = currentError || {};
// Apply error to the field being edited or a general one if needed
currentError.endPosition = 'Effective program length must be at least 1 second.';
}
}
} }
scope.onDone(JSON.parse(angular.toJson(prog))) // --- Standard Validation (on original prog object) ---
scope.program = null if (!currentError) { // Only proceed if time validation passed
if (!prog.title) { currentError = { title: 'You must set a program title.' }; }
else if (prog.type === "episode" && !prog.showTitle) { currentError = { showTitle: 'You must set a show title when the program type is an episode.' }; }
else if (prog.type === "episode" && (prog.season == null || prog.season <= 0)) { currentError = { season: 'Season number must be greater than 0.' }; }
else if (prog.type === "episode" && (prog.episode == null || prog.episode <= 0)) { currentError = { episode: 'Episode number must be greater than 0.' }; }
// Add any other existing standard validations here, setting currentError
}
// --- Error Handling ---
if (currentError && Object.keys(currentError).length !== 0) {
scope.error = currentError;
$timeout(() => { scope.error = null }, 3500);
return; // Stop execution
}
// --- Prepare Final Object ---
// Create a clean object based on the original prog and validated time inputs
// Ensure seekMs is a valid number before assigning
let finalSeekMs = isNaN(seekMs) ? 0 : seekMs;
// Ensure endMs is valid number > 0 or undefined
let finalEndMs = (typeof endMs === 'number' && !isNaN(endMs) && endMs > 0) ? endMs : undefined;
let finalProgData = {
...prog, // Copy original properties
seekPosition: finalSeekMs,
endPosition: finalEndMs
};
// Explicitly remove endPosition if undefined
if (finalProgData.endPosition === undefined) {
delete finalProgData.endPosition;
}
console.log("Validation passed. Calling onDone with:", finalProgData);
scope.onDone(JSON.parse(angular.toJson(finalProgData)));
scope.program = null;
} }
} }
}; };

View File

@ -19,12 +19,10 @@
"minutes_to_sign_plex": "You have 2 minutes to sign into your Plex Account.", "minutes_to_sign_plex": "You have 2 minutes to sign into your Plex Account.",
"name": "Name", "name": "Name",
"uri": "URI", "uri": "URI",
"ui_route": "UI Route", "routeStatus": "Route Status",
"backend_route": "Backend Route",
"ok": "ok", "ok": "ok",
"error": "error", "error": "error",
"ui_bad": "If a Plex server configuration has problems with the UI route, the channel editor won't be able to access its content.", "backend_bad": "A route problem means the dizqueTV server can't establish a connection with the configured Plex server.",
"backend_bad": "If a Plex server configuration has problems with the backend route, dizqueTV won't be able to play its content.",
"plex_transcoder_settings": "Plex Transcoder Settings", "plex_transcoder_settings": "Plex Transcoder Settings",
"update": "Update", "update": "Update",
"reset_options": "Reset Options", "reset_options": "Reset Options",

View File

@ -727,6 +727,15 @@
<span class='text-muted' id="stealthHelp">(This will hide the channel from TV guides, spoofed HDHR, m3u playlist... The channel can still be streamed directly or be used as a redirect target.)</span> <span class='text-muted' id="stealthHelp">(This will hide the channel from TV guides, spoofed HDHR, m3u playlist... The channel can still be streamed directly or be used as a redirect target.)</span>
</div> </div>
<div class='form-group' ng-show='! channel.stealth'>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="mergeAdjacentPrograms" ng-model="channel.mergeAdjacentPrograms">
<label class="form-check-label" for="mergeAdjacentPrograms">Merge adjacent programs with same content</label>
<span class='text-muted' id="mergeAdjacentProgramsHelp">(When enabled, adjacent programs with the same content ID will appear as a single program in the guide. This is useful for shows split by commercials or bumpers.)</span>
</div>
</div>
<br></br> <br></br>
<div class='form-group' ng-show='! channel.stealth'> <div class='form-group' ng-show='! channel.stealth'>
<label class='form-label' >Placeholder program title:</label> <label class='form-label' >Placeholder program title:</label>

View File

@ -15,40 +15,20 @@
<hr></hr> <hr></hr>
<h6>FFMPEG Executable Path</h6> <h6>FFMPEG Executable Path</h6>
<div class="row" ng-show="settings.lock !== true">
<div class="row">
<div class="col-sm-9"> <div class="col-sm-9">
<div class="form-group"> <div class="form-group">
<div class="form-group"> <div class="form-group">
<label>Path</label> <label>Path</label>
<input id="ffmpegPath" ria-describedby="ffmpegHelp" type="text" class="form-control form-control-sm" ng-model="settings.ffmpegPath"></input> <div class='loader' ng-show='ffmpegPathLoading'></div>
<small class="form-text text-muted"> <input id="ffmpegPath" ria-describedby="ffmpegHelp" type="text" class="form-control form-control-sm" ng-model="ffmpegPath" readonly ng-hide="ffmpegPathLoading"></input>
The path to the ffmpeg executable. (e.g: /usr/bin/ffmpeg or C:\ffmpeg\bin\ffmpeg.exe) FFMPEG version 4.2+ required. Check by opening the version tab. <small class="form-text text-muted" ng-show="ffmpegPath != null">
The path to the ffmpeg executable. Please check the instructions if you need to change it.
</small> </small>
<small class="form-text text-muted" ng-show="ffmpegPath == null">
</div> dizqueTV uses FFmpeg to create video streams. Please check the instructions for info about how to set this up.
</div>
</div>
<div class="col-sm-9">
<div class="form-group">
<input id="lockFfmpeg" type="checkbox" ng-model="settings.addLock"></input>
<label for="lockFfmpeg">Lock ffmpeg path setting</label>
<small class="form-text text-muted">This will lock the ffmpeg path setting so that it is no longer editable from UI. Even if you don't toggle this option, the setting will get locked in 24 hours.</small>
</div>
</div>
</div>
<div class="row" ng-show="settings.lock === true">
<div class="col-sm-9">
<div class="form-group">
<div class="form-group">
<label>Path</label>
<input id="ffmpegPath" ria-describedby="ffmpegHelp" type="text" class="form-control form-control-sm" ng-model="settings.ffmpegPath" readonly></input>
<small class="form-text text-muted">
The ffmpeg path setting is currently locked and can't be edited from the UI. It's not usually necessary to update this path once it's known to be working. Run dizquetv with the <b>--unlock</b> command line argument to enable editing it again.
</small> </small>
</div> </div>
</div> </div>
</div> </div>

View File

@ -11,8 +11,52 @@
<div class="form-group"> <div class="form-group">
<label for="name">Filler Name:</label> <label for="name">Filler Name:</label>
<input type="text" class="form-control" id="name" placeholder="Filler Name" ng-model="name" ></input> <input type="text" class="form-control" id="name" placeholder="Filler Name" ng-model="name" ></input>
<div>Mode:</div>
<select class="custom-select"
ng-model="mode"
ng-options="theMode.name as theMode.description for theMode in modes" ></select>
</div> </div>
<div ng-show="mode==='import'">
<div class="form-group">
<label for="server">Server:</label>
<div class="loader" ng-show="loadingServers === true" ></div>
<select
ng-show="loadingServers !== true"
id="server"
class="custom-select"
ng-change="serverChanged()"
ng-model="server"
ng-options="x.name as x.description for x in servers" ></select>
</div>
<div ng-show="server !==''">
<label for="library">Library:</label>
<div class="loader" ng-show="loadingLibraries === true" ></div>
<select
ng-show="loadingLibraries !== true"
id="library"
class="custom-select"
ng-change="libraryChanged()"
ng-model="libraryKey"
ng-options="x.key as x.description for x in libraries" ></select>
</div>
<div ng-show="libraryKey !==''">
<label for="source">Source:</label>
<div class="loader" ng-show="loadingCollections === true" ></div>
<select
ng-show="loadingCollections !== true"
id="source"
class="custom-select"
ng-model="sourceKey"
ng-options="x.key as x.description for x in sources" ></select>
</div>
</div>
<div ng-show="mode==='custom'">
<h6 style="margin-top: 10px;">Clips</h6> <h6 style="margin-top: 10px;">Clips</h6>
<div class="flex-container"> <div class="flex-container">
@ -65,10 +109,11 @@
<div ng-show="content.length === 0"> <div ng-show="content.length === 0">
<p class="text-center text-info">Click the <span class="fa fa-plus"></span> to import filler content from your Plex server(s).</p> <p class="text-center text-info">Click the <span class="fa fa-plus"></span> to import filler content from your Plex server(s).</p>
</div> </div>
</div>
</div> </div>
</div> </div>
<div vs-repeat class="modal-body container list-group list-group-root filler-list" <div ng-show="mode==='custom'" vs-repeat class="modal-body container list-group list-group-root filler-list"
dnd-list="content" ng-if="showList()" dnd-list="content" ng-if="showList()"
vs-repeat-reinitialized="vsReinitialized(event, startIndex, endIndex)" vs-repeat-reinitialized="vsReinitialized(event, startIndex, endIndex)"
ng-init="setUpWatcher()" ng-init="setUpWatcher()"
@ -77,7 +122,6 @@
> >
<div class="list-group-item flex-container filler-row" style="cursor: default; height:1.1em; overflow:hidden" ng-repeat="x in content" track-by="x.$index" dnd-draggable="x" <div class="list-group-item flex-container filler-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-effect-allowed="move"
dnd-moved="movedFunction(x.$index)" dnd-moved="movedFunction(x.$index)"
> >

View File

@ -43,7 +43,13 @@
</button> </button>
Content: Content:
</label> </label>
<ul ng-show="currentOrigin.type=='plex' " class="list-group list-group-root plex-panel" ng-init="setHeight = {'height': height + 'px'}" ng-style="setHeight" lazy-img-container>
<ul ng-show="currentOrigin.loaded!==true" 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"><div class="loader"></div> <div>Loading Library Contents...</div></li>
</ul>
<ul ng-show="currentOrigin.type=='plex' && currentOrigin.loaded===true " 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"> <li class="list-group-item" ng-repeat="a in libraries">
<div class="flex-container library-item-hover {{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a, true);"> <div class="flex-container library-item-hover {{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a, true);">
<span class="fa {{ a.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span> <span class="fa {{ a.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
@ -122,7 +128,7 @@
</ul> </ul>
</li> </li>
</ul> </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> <ul ng-show="currentOrigin.type=='dizquetv' && currentOrigin.loaded===true" 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"> <li class="list-group-item" ng-repeat="x in customShows">
<div class="flex-container" ng-click="addCustomShow(x);"> <div class="flex-container" ng-click="addCustomShow(x);">
<span class="fa fa-plus-circle tab"></span> <span class="fa fa-plus-circle tab"></span>

View File

@ -16,8 +16,7 @@
<tr> <tr>
<th>{{'settings_server.name' | i18next}}</th> <th>{{'settings_server.name' | i18next}}</th>
<th>{{'settings_server.uri' | i18next}}</th> <th>{{'settings_server.uri' | i18next}}</th>
<th>{{'settings_server.ui_route' | i18next}}</th> <th>{{'settings_server.routeStatus' | i18next}}</th>
<th>{{'settings_server.backend_route' | i18next}}</th>
<th></th> <th></th>
</tr> </tr>
<tr ng-if="servers.length === 0"> <tr ng-if="servers.length === 0">
@ -31,11 +30,6 @@
<tr ng-repeat="x in servers" ng-hide="serversPending" > <tr ng-repeat="x in servers" ng-hide="serversPending" >
<td>{{ x.name }}</td> <td>{{ x.name }}</td>
<td><span class='text-secondary text-small'>{{ x.uri }}</span></td> <td><span class='text-secondary text-small'>{{ x.uri }}</span></td>
<td>
<div class='loader' ng-if="x.uiStatus == 0"></div>
<div class='text-success' ng-if="x.uiStatus == 1"><i class='fa fa-check'></i>{{'settings_server.ok' | i18next}}</div>
<div class='text-danger' ng-if="x.uiStatus == -1"><i class='fa fa-exclamation-triangle'></i>{{'settings_server.error' | i18next}}</div>
</td>
<td> <td>
<div class='loader' ng-if="x.backendStatus == 0"></div> <div class='loader' ng-if="x.backendStatus == 0"></div>
<div class='text-success' ng-if="x.backendStatus == 1"><i class='fa fa-check'></i>{{'settings_server.ok' | i18next}}</div> <div class='text-success' ng-if="x.backendStatus == 1"><i class='fa fa-check'></i>{{'settings_server.ok' | i18next}}</div>
@ -51,11 +45,6 @@
<p class="text-center text-danger small">{{serverError}}</p> <p class="text-center text-danger small">{{serverError}}</p>
</td> </td>
</tr> </tr>
<tr ng-if="isAnyUIBad()">
<td colspan="5">
<p class="text-center text-danger small">{{'settings_server.ui_bad' | i18next}}</p>
</td>
</tr>
<tr ng-if="isAnyBackendBad()"> <tr ng-if="isAnyBackendBad()">
<td colspan="5"> <td colspan="5">
<p class="text-center text-danger small">{{'settings_server.server_bad' | i18next}}</p> <p class="text-center text-danger small">{{'settings_server.server_bad' | i18next}}</p>

View File

@ -30,6 +30,33 @@
<div class="text-center"> <div class="text-center">
<img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"></img> <img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"></img>
</div> </div>
<div class="card mt-3">
<div class="card-header" ng-click="trackAdvancedOpen = !trackAdvancedOpen" style="cursor: pointer;">
<h6 class="mb-0">Advanced Options <i class="float-right" ng-class="{'fa fa-chevron-down': !trackAdvancedOpen, 'fa fa-chevron-up': trackAdvancedOpen}"></i></h6>
</div>
<div class="collapse" ng-class="{'show': trackAdvancedOpen}">
<div class="card-body">
<label>Custom Start Time (optional)</label>
<div class="form-row mb-3">
<div class="col">
<input class="form-control form-control-sm" type="text" ng-model="timeInput.seek" placeholder="MM:SS (leave blank for start)">
<small class="form-text text-muted">Format: minutes:seconds (e.g. 5:30)</small>
<div ng-show="error && error.seekPosition" class="text-danger">{{error.seekPosition}}</div>
</div>
</div>
<label>Custom End Time (optional)
<span class="text-danger pull-right">{{error.endPosition}}</span>
</label>
<div class="form-row mb-3">
<div class="col">
<input class="form-control form-control-sm" type="text" ng-model="timeInput.end" placeholder="MM:SS (leave blank for end)">
<small class="form-text text-muted">Format: minutes:seconds (e.g. 10:45)</small>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div ng-if="program.type === 'movie'"> <div ng-if="program.type === 'movie'">
<label>Movie Title <label>Movie Title
@ -48,6 +75,33 @@
<div class="text-center"> <div class="text-center">
<img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"></img> <img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"></img>
</div> </div>
<div class="card mt-3">
<div class="card-header" ng-click="movieAdvancedOpen = !movieAdvancedOpen" style="cursor: pointer;">
<h6 class="mb-0">Advanced Options <i class="float-right" ng-class="{'fa fa-chevron-down': !movieAdvancedOpen, 'fa fa-chevron-up': movieAdvancedOpen}"></i></h6>
</div>
<div class="collapse" ng-class="{'show': movieAdvancedOpen}">
<div class="card-body">
<label>Custom Start Time (optional)</label>
<div class="form-row mb-3">
<div class="col">
<input class="form-control form-control-sm" type="text" ng-model="timeInput.seek" placeholder="MM:SS (leave blank for start)">
<small class="form-text text-muted">Format: minutes:seconds (e.g. 5:30)</small>
<div ng-show="error && error.seekPosition" class="text-danger">{{error.seekPosition}}</div>
</div>
</div>
<label>Custom End Time (optional)
<span class="text-danger pull-right">{{error.endPosition}}</span>
</label>
<div class="form-row mb-3">
<div class="col">
<input class="form-control form-control-sm" type="text" ng-model="timeInput.end" placeholder="MM:SS (leave blank for end)">
<small class="form-text text-muted">Format: minutes:seconds (e.g. 10:45)</small>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div ng-if="program.type === 'episode'"> <div ng-if="program.type === 'episode'">
<label>Show Title <label>Show Title
@ -94,6 +148,33 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header" ng-click="episodeAdvancedOpen = !episodeAdvancedOpen" style="cursor: pointer;">
<h6 class="mb-0">Advanced Options <i class="float-right" ng-class="{'fa fa-chevron-down': !episodeAdvancedOpen, 'fa fa-chevron-up': episodeAdvancedOpen}"></i></h6>
</div>
<div class="collapse" ng-class="{'show': episodeAdvancedOpen}">
<div class="card-body">
<label>Custom Start Time (optional)</label>
<div class="form-row mb-3">
<div class="col">
<input class="form-control form-control-sm" type="text" ng-model="timeInput.seek" placeholder="MM:SS (leave blank for start)">
<small class="form-text text-muted">Format: minutes:seconds (e.g. 5:30)</small>
<div ng-show="error && error.seekPosition" class="text-danger">{{error.seekPosition}}</div>
</div>
</div>
<label>Custom End Time (optional)
<span class="text-danger pull-right">{{error.endPosition}}</span>
</label>
<div class="form-row mb-3">
<div class="col">
<input class="form-control form-control-sm" type="text" ng-model="timeInput.end" placeholder="MM:SS (leave blank for end)">
<small class="form-text text-muted">Format: minutes:seconds (e.g. 10:45)</small>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -210,12 +210,16 @@ module.exports = function (getShowData) {
background = "repeating-linear-gradient( " + angle + "deg, " + rgb1 + ", " + rgb1 + " " + w + "px, " + rgb2 + " " + w + "px, " + rgb2 + " " + (w*2) + "px)"; background = "repeating-linear-gradient( " + angle + "deg, " + rgb1 + ", " + rgb1 + " " + w + "px, " + rgb2 + " " + w + "px, " + rgb2 + " " + (w*2) + "px)";
} }
// Width calculation based on effective duration
let seek = typeof program.seekPosition === 'number' ? program.seekPosition : 0;
let end = typeof program.endPosition === 'number' ? program.endPosition : null;
let effectiveDuration = (end !== null ? end : program.duration) - seek;
let f = interpolate; let f = interpolate;
let w = 15.0; let w = 15.0;
let t = 4*60*60*1000; let t = 4*60*60*1000;
//let d = Math.log( Math.min(t, program.duration) ) / Math.log(2); let a = (f(effectiveDuration) *w) / f(t);
//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) ); a = Math.min( w, Math.max(0.3, a) );
b = w - a + 0.01; b = w - a + 0.01;

View File

@ -3,8 +3,8 @@ module.exports = function ($http, $q) {
getVersion: () => { getVersion: () => {
return $http.get('/api/version').then((d) => { return d.data }) return $http.get('/api/version').then((d) => { return d.data })
}, },
getPlexServers: () => { getPlexServers: async () => {
return $http.get('/api/plex-servers').then((d) => { return d.data }) return (await $http.get('/api/plex-servers')).data;
}, },
addPlexServer: (plexServer) => { addPlexServer: (plexServer) => {
return $http({ return $http({
@ -49,6 +49,15 @@ module.exports = function ($http, $q) {
}); });
return d.data; return d.data;
}, },
getFromPlexProxy: async (serverName, path) => {
let serverName64 = Buffer.from(serverName, 'utf-8').toString('base64');
let tmp = await ($http({
method: 'GET',
url : `api/plex-server/${serverName64}${path}`,
headers: { "Cache-Control": "no-cache"},
}))
return tmp.data.MediaContainer;
},
getPlexSettings: () => { getPlexSettings: () => {
return $http.get('/api/plex-settings').then((d) => { return d.data }) return $http.get('/api/plex-settings').then((d) => { return d.data })
}, },
@ -87,6 +96,9 @@ module.exports = function ($http, $q) {
headers: { 'Content-Type': 'application/json; charset=utf-8' } headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data }) }).then((d) => { return d.data })
}, },
getFFMpegPath: () => {
return $http.get('/api/ffmpeg-info').then((d) => { return d.data })
},
getXmltvSettings: () => { getXmltvSettings: () => {
return $http.get('/api/xmltv-settings').then((d) => { return d.data }) return $http.get('/api/xmltv-settings').then((d) => { return d.data })
}, },

View File

@ -1,6 +1,6 @@
const Plex = require('../../src/plex'); const Plex = require('../../src/plex');
module.exports = function ($http, $window, $interval) { module.exports = function (dizquetv, $http, $window, $interval) {
let exported = { let exported = {
login: async () => { login: async () => {
const headers = { const headers = {
@ -108,14 +108,13 @@ module.exports = function ($http, $window, $interval) {
}, },
getLibrary: async (server) => { getLibrary: async (server) => {
var client = new Plex(server) const res = await dizquetv.getFromPlexProxy(server.name, '/library/sections')
const res = await client.Get('/library/sections')
var sections = [] var sections = []
for (let i = 0, l = typeof res.Directory !== 'undefined' ? res.Directory.length : 0; i < l; i++) 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' || res.Directory[i].type === 'artist' ) { if (res.Directory[i].type === 'movie' || res.Directory[i].type === 'show' || res.Directory[i].type === 'artist' ) {
var genres = [] var genres = []
if (res.Directory[i].type === 'movie') { if (res.Directory[i].type === 'movie') {
const genresRes = await client.Get(`/library/sections/${res.Directory[i].key}/genre`) const genresRes = await dizquetv.getFromPlexProxy(server.name,`/library/sections/${res.Directory[i].key}/genre`)
for (let q = 0, k = typeof genresRes.Directory !== 'undefined' ? genresRes.Directory.length : 0; q < k; q++) { for (let q = 0, k = typeof genresRes.Directory !== 'undefined' ? genresRes.Directory.length : 0; q < k; q++) {
if (genresRes.Directory[q].type === 'genre') { if (genresRes.Directory[q].type === 'genre') {
genres.push({ genres.push({
@ -138,8 +137,7 @@ module.exports = function ($http, $window, $interval) {
return sections return sections
}, },
getPlaylists: async (server) => { getPlaylists: async (server) => {
var client = new Plex(server) const res = await dizquetv.getFromPlexProxy(server.name, '/playlists');
const res = await client.Get('/playlists')
var playlists = [] var playlists = []
for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++) for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++)
if ( if (
@ -157,8 +155,7 @@ module.exports = function ($http, $window, $interval) {
return playlists return playlists
}, },
getStreams: async (server, key) => { getStreams: async (server, key) => {
var client = new Plex(server) return dizquetv.getFromPlexProxy(server.name, key).then((res) => {
return client.Get(key).then((res) => {
let streams = res.Metadata[0].Media[0].Part[0].Stream let streams = res.Metadata[0].Media[0].Part[0].Stream
for (let i = 0, l = streams.length; i < l; i++) { for (let i = 0, l = streams.length; i < l; i++) {
if (typeof streams[i].key !== 'undefined') { if (typeof streams[i].key !== 'undefined') {
@ -169,9 +166,9 @@ module.exports = function ($http, $window, $interval) {
}) })
}, },
getNested: async (server, lib, includeCollections, errors) => { getNested: async (server, lib, includeCollections, errors) => {
var client = new Plex(server)
const key = lib.key const key = lib.key
const res = await client.Get(key) const res = await dizquetv.getFromPlexProxy(server.name, key)
const size = (typeof(res.Metadata) !== 'undefined') ? res.Metadata.length : 0; const size = (typeof(res.Metadata) !== 'undefined') ? res.Metadata.length : 0;
var nested = [] var nested = []
@ -191,7 +188,8 @@ module.exports = function ($http, $window, $interval) {
albumKeys = Object.keys( albumKeys ); albumKeys = Object.keys( albumKeys );
await Promise.all( albumKeys.map( async(albumKey) => { await Promise.all( albumKeys.map( async(albumKey) => {
try { try {
let album = await client.Get(albumKey); let album = await dizquetv.getFromPlexProxy(
server.name, albumKey);
if ( (typeof(album)!=='undefined') && album.size == 1) { if ( (typeof(album)!=='undefined') && album.size == 1) {
album = album.Metadata[0]; album = album.Metadata[0];
} }
@ -287,7 +285,7 @@ module.exports = function ($http, $window, $interval) {
let k = res.librarySectionID; let k = res.librarySectionID;
k = `/library/sections/${k}/collections`; k = `/library/sections/${k}/collections`;
let collections = await client.Get(k); let collections = await dizquetv.getFromPlexProxy(server.name, k);
if ( typeof(collections.Metadata) === 'undefined') { if ( typeof(collections.Metadata) === 'undefined') {
collections.Metadata = []; collections.Metadata = [];
} }