diff --git a/src/helperFuncs.js b/src/helperFuncs.js index 1d905f9..453dd57 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -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 = [] @@ -161,19 +184,29 @@ 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) { diff --git a/src/plex-player.js b/src/plex-player.js index 9a75f84..2f20ae1 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -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( () => { diff --git a/src/services/channel-service.js b/src/services/channel-service.js index cffcf70..831675e 100644 --- a/src/services/channel-service.js +++ b/src/services/channel-service.js @@ -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; - } diff --git a/src/services/tv-guide-service.js b/src/services/tv-guide-service.js index 1b33c54..85bbe3e 100644 --- a/src/services/tv-guide-service.js +++ b/src/services/tv-guide-service.js @@ -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 } } diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index d8da0fe..adce35a 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -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; diff --git a/web/directives/program-config.js b/web/directives/program-config.js index f274eb5..0715b45 100644 --- a/web/directives/program-config.js +++ b/web/directives/program-config.js @@ -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; } } }; diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 081300d..7c60b99 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -727,6 +727,15 @@ (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.) + +