From b65eaae38acd89450e1bddf2e50554c8fb74eee3 Mon Sep 17 00:00:00 2001 From: tim000x3 Date: Wed, 16 Apr 2025 04:24:23 -0400 Subject: [PATCH] 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 --- src/helperFuncs.js | 69 ++++++++--- src/plex-player.js | 68 +++++++++-- src/services/channel-service.js | 30 ++++- src/services/tv-guide-service.js | 69 ++++++++++- web/directives/channel-config.js | 17 ++- web/directives/program-config.js | 149 ++++++++++++++++++++--- web/public/templates/channel-config.html | 9 ++ web/public/templates/program-config.html | 81 ++++++++++++ web/services/common-program-tools.js | 10 +- 9 files changed, 441 insertions(+), 61 deletions(-) diff --git a/src/helperFuncs.js b/src/helperFuncs.js index 45f28bf..dd05edb 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 = [] @@ -159,19 +182,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 a97a5b7..0d5bb6a 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 ed81e9a..8383404 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.) + +
+
+ + + + (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.) +
+


diff --git a/web/public/templates/program-config.html b/web/public/templates/program-config.html index 5ba9c7f..b86ebb8 100644 --- a/web/public/templates/program-config.html +++ b/web/public/templates/program-config.html @@ -30,6 +30,33 @@
+ +
+
+
Advanced Options
+
+
+
+ +
+
+ + Format: minutes:seconds (e.g. 5:30) +
{{error.seekPosition}}
+
+
+ +
+
+ + Format: minutes:seconds (e.g. 10:45) +
+
+
+
+
+ +
+
+
Advanced Options
+
+
+
+ +
+
+ + Format: minutes:seconds (e.g. 5:30) +
{{error.seekPosition}}
+
+
+ +
+
+ + Format: minutes:seconds (e.g. 10:45) +
+
+
+
+