Merge pull request #491 from vexorian/dev/1.6.x

1.6.0
This commit is contained in:
vexorian 2025-12-01 11:12:52 -04:00 committed by GitHub
commit 483ace42d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 493 additions and 97 deletions

View File

@ -6,7 +6,7 @@ COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/
COPY . .
RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly
FROM jrottenberg/ffmpeg:4.3-ubuntu1804
FROM akashisn/ffmpeg:4.4.5
EXPOSE 8000
WORKDIR /home/node/app
ENTRYPOINT [ "./dizquetv" ]

View File

@ -6,7 +6,7 @@ COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/
COPY . .
RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly
FROM jrottenberg/ffmpeg:4.3-nvidia1804
FROM jrottenberg/ffmpeg:4.4.5-nvidia2204
EXPOSE 8000
WORKDIR /home/node/app
ENTRYPOINT [ "./dizquetv" ]

View File

@ -1,4 +1,4 @@
# dizqueTV 1.5.5
# dizqueTV 1.6.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)
Create live TV channel streams from media on your Plex servers.
@ -77,4 +77,13 @@ npm run dev-server
* Original pseudotv-Plex code was released under [MIT license (c) 2020 Dan Ferguson](https://github.com/DEFENDORe/pseudotv/blob/665e71e24ee5e93d9c9c90545addb53fdc235ff6/LICENSE)
* dizqueTV's improvements are released under zlib license (c) 2020 Victor Hugo Soliz Kuncar
* FontAwesome: [https://fontawesome.com/license/free](https://archive.fo/PRqis)
* Bootstrap: https://github.com/twbs/bootstrap/blob/v4.4.1/LICENSE
* Bootstrap: https://github.com/twbs/bootstrap/blob/v4.4.1/LICENSE
## Thanks
* DEFENDORe , George and everyone that worked on PseudoTV
* Ahmed Said Al-Busaidi , for reporting exploits in advance before publishing in exploit-db.
* Nathan for working on automation addons.
* Timebomb, Rafael for the contributions during dizqueTV's active days.

View File

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

View File

@ -163,10 +163,7 @@ class FillerDB {
}
async getFillersFromChannel(channel) {
let f = [];
if (typeof(channel.fillerCollections) !== 'undefined') {
f = channel.fillerContent;
}
let loadChannelFiller = async(fillerEntry) => {
let content = [];
try {

View File

@ -27,6 +27,13 @@ const CHANNEL_CONTEXT_KEYS = [
module.exports.random = random;
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();
if (channelStartTime > date) {
let t0 = date;
@ -44,23 +51,36 @@ function getCurrentProgramAndTimeElapsed(date, channel) {
let timeElapsed = (date - channelStartTime) % channel.duration
let currentProgramIndex = -1
for (let y = 0, l2 = channel.programs.length; y < l2; y++) {
let program = channel.programs[y]
if (timeElapsed - program.duration < 0) {
let program = channel.programs[y];
// 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
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;
currentProgramIndex = (y + 1) % channel.programs.length;
}
break;
} else {
timeElapsed -= program.duration
timeElapsed -= (end !== null ? end - seek : program.duration - seek);
}
}
if (currentProgramIndex === -1)
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) {
@ -70,6 +90,9 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
// Helps prevents loosing first few seconds of an episode upon lineup change
let activeProgram = obj.program
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 = []
@ -98,7 +121,7 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
if ( (channel.offlineMode === 'clip') && (channel.fallback.length != 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;
if (filler == null && (typeof(randomResult.minimumWait) !== undefined) && (remaining > randomResult.minimumWait) ) {
remaining = randomResult.minimumWait;
@ -114,8 +137,6 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
if (isSpecial) {
if (filler.duration > remaining) {
fillerstart = filler.duration - remaining;
} else {
ffillerstart = 0;
}
} else if(isFirst) {
fillerstart = Math.max(0, filler.duration - remaining);
@ -132,7 +153,7 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
file: filler.file,
ratingKey: filler.ratingKey,
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,
fillerId: filler.fillerId,
beginningOffset: beginningOffset,
@ -161,26 +182,36 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
}
beginningOffset = Math.max(0, originalTimeElapsed - timeElapsed);
return [ {
type: 'program',
title: activeProgram.title,
key: activeProgram.key,
plexFile: activeProgram.plexFile,
file: activeProgram.file,
ratingKey: activeProgram.ratingKey,
start: timeElapsed,
streamDuration: activeProgram.duration - timeElapsed,
beginningOffset: beginningOffset,
duration: activeProgram.duration,
serverKey: activeProgram.serverKey
} ];
// Calculate effective start, duration, and streamDuration using seek/end
const effectiveSeek = seek;
const effectiveEnd = end !== null ? end : activeProgram.duration;
const effectiveDuration = effectiveEnd - effectiveSeek;
const effectiveTimeElapsed = Math.max(0, timeElapsed);
const effectiveStreamDuration = effectiveDuration - effectiveTimeElapsed;
return [{
type: 'program',
title: activeProgram.title,
key: activeProgram.key,
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) {
return random.bool(a, total);
}
function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuration) {
function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuration, isFirst) {
let list = [];
for (let i = 0; i < fillers.length; i++) {
list = list.concat(fillers[i].content);
@ -190,7 +221,14 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
let t0 = (new Date()).getTime();
let minimumWait = 1000000000;
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') {
channel.fillerRepeatCooldown = 30*60*1000;
}
@ -234,7 +272,7 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
minimumWait = Math.min(minimumWait, w);
}
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) {
let t1 = channelCache.getFillerLastPlayTime( channel.number, fillers[j].id );
let timeSince = ( (t1 == 0) ? D : (t0 - t1) );
@ -260,12 +298,26 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
if (timeSince <= 0) {
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 w = s + d;
let w = d;
n += w;
if (weighedPick(w,n)) {
pick1 = clip;
pickLastPlayed = t1;
}
}
}
@ -275,6 +327,11 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
}
}
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) {
pick = JSON.parse( JSON.stringify(pick) );
pick.fillerId = fillerId;
@ -288,18 +345,7 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
}
function norm_d(x) {
x /= 60 * 1000;
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;
return 1 + Math.ceil( Math.log(x+1) / Math.log(2) )
}

View File

@ -64,24 +64,66 @@ class PlexPlayer {
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
ffmpeg.setAudioOnly( this.context.audioOnly );
this.ffmpeg = ffmpeg;
let streamDuration;
if (typeof(lineupItem.streamDuration)!=='undefined') {
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);
if (this.killed) {
return;
}
//let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined;
//let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : lineupItem.start;
let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined;
// Calculate parameters differently for direct play vs transcoded mode
let streamDuration;
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;
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();
//setTimeout( () => {

View File

@ -59,8 +59,19 @@ class ChannelService extends events.EventEmitter {
function cleanUpProgram(program) {
delete program.start
delete program.stop
if (program.startPosition != null && program.startPosition !== '') {
// 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);
}
if (program.start && program.stop) {
program.duration = new Date(program.stop) - new Date(program.start);
}
delete program.streams;
delete program.durationStr;
delete program.commercials;
@ -91,12 +102,23 @@ function cleanUpChannel(channel) {
delete channel.fillerContent;
delete channel.filler;
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;
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;
}

View File

@ -1,6 +1,6 @@
const events = require('events')
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');
class TVGuideService extends events.EventEmitter
@ -83,7 +83,12 @@ class TVGuideService extends events.EventEmitter
let arr = new Array( channel.programs.length + 1);
arr[0] = 0;
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) {
console.log("Found program with duration 0, correcting it");
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`);
d = Math.ceil(d);
}
channel.programs[i].duration = d;
arr[i+1] = arr[i] + d;
await this._throttle();
}
@ -360,10 +364,70 @@ class TVGuideService extends events.EventEmitter
result.programs.push( makeEntry(channel, programs[i] ) );
}
}
// Only merge programs if enabled in channel settings
if (channel.mergeAdjacentPrograms === true) {
result.programs = this.mergeAdjacentSamePrograms(result.programs);
}
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() {
let t0 = this.currentUpdate;
let t1 = this.currentLimit;
@ -580,6 +644,7 @@ function makeEntry(channel, x) {
icon: icon,
title: title,
sub: sub,
ratingKey: x.program.ratingKey // Add ratingKey to preserve it for merging
}
}

View File

@ -332,9 +332,9 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS
if (typeof(u) !== 'undefined') {
let u2 = upperBound;
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;
}
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;
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].$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)
if (scope.channel.programs[i].isOffline) {
scope.hasFlex = true;
@ -1014,9 +1029,6 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get
} else if (channel.overlayIcon && !validURL(channel.icon)) {
scope.error.icon = "Please enter a valid image URL. Cant overlay an invalid image."
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) {
scope.error.programs = "No programs have been selected. Select at least one program."
scope.error.tab = "programming";

View File

@ -9,29 +9,138 @@ module.exports = function ($timeout) {
onDone: "=onDone"
},
link: function (scope, element, attrs) {
scope.finished = (prog) => {
if (prog.title === "")
scope.error = { title: 'You must set a program title.' }
else if (prog.type === "episode" && prog.showTitle == "")
scope.error = { showTitle: 'You must set a show title when the program type is an episode.' }
else if (prog.type === "episode" && (prog.season == null))
scope.error = { season: 'You must set a season number when the program type is an episode.' }
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' }
// Conversion functions remain the same (using NaN for invalid)
scope.msToTimeString = function(ms) {
if (typeof ms !== 'number' || isNaN(ms) || ms < 0) { return ''; }
let totalS = Math.floor(ms / 1000);
let s = totalS % 60;
let m = Math.floor(totalS / 60);
return m + ":" + ( (s < 10) ? ("0" + s) : s );
};
scope.timeStringToMs = function(timeString) {
if (timeString == null || timeString.trim() === '') { return 0; } // Empty is 0ms
let parts = timeString.split(':');
if (parts.length !== 2) { return NaN; } // Invalid format
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
if (scope.error != null) {
$timeout(() => {
scope.error = null
}, 3500)
return
// 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)))
scope.program = null
// --- Standard Validation (on original prog object) ---
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

@ -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>
</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>
<div class='form-group' ng-show='! channel.stealth'>
<label class='form-label' >Placeholder program title:</label>

View File

@ -30,6 +30,33 @@
<div class="text-center">
<img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"></img>
</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 ng-if="program.type === 'movie'">
<label>Movie Title
@ -48,6 +75,33 @@
<div class="text-center">
<img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"></img>
</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 ng-if="program.type === 'episode'">
<label>Show Title
@ -94,6 +148,33 @@
</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 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)";
}
// 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 w = 15.0;
let t = 4*60*60*1000;
//let d = Math.log( Math.min(t, program.duration) ) / Math.log(2);
//let a = (d * Math.log(2) ) / Math.log(t);
let a = ( f(program.duration) *w) / f(t);
let a = (f(effectiveDuration) *w) / f(t);
a = Math.min( w, Math.max(0.3, a) );
b = w - a + 0.01;