Compare commits

..

4 Commits

Author SHA1 Message Date
vexorian
449ebc1478 nvidia 2025-12-01 09:32:19 -04:00
vexorian
94dff04786 Intel quicksync support 2025-12-01 09:16:42 -04:00
vexorian
11cf105ea5 tem 2025-11-07 23:27:31 -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
18 changed files with 538 additions and 153 deletions

View File

@ -1,13 +0,0 @@
# The workflow
edge main
^ \ ^
| \------2--\ |
1 \ 3
| \ |
development <-4-- patch
1. Releasing a new 'edge' version.
2. Moving an 'edge' version to stable.
3. Releasing a stable version
4. Aligning bug fixes with the new features.

View File

@ -3,7 +3,7 @@ name: Development Binaries
on:
push:
branches:
- development
- dev/1.5.x
jobs:
binaries:

View File

@ -3,7 +3,7 @@ name: Development Tag
on:
push:
branches:
- development
- dev/1.5.x
jobs:
docker:

View File

@ -2,64 +2,73 @@
## Our Pledge
We pledge to make our community welcoming, safe, and equitable for all.
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
## Encouraged Behaviors
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language.
Examples of unacceptable behavior by participants include:
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
2. Engaging **kindly and honestly** with others.
3. Respecting **different viewpoints** and experiences.
4. **Taking responsibility** for our actions and contributions.
5. Gracefully giving and accepting **constructive feedback**.
6. Committing to **repairing harm** when it occurs.
7. Behaving in other ways that promote and sustain the **well-being of our community**.
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
## Restricted Behaviors
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct.
## Scope
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop.
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people.
3. **Stereotyping or discrimination.** Characterizing anyones personality or behavior on the basis of immutable identities or traits.
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community.
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission.
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
7. Behaving in other ways that **threaten the well-being** of our community.
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
### Other Restrictions
## Enforcement
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions.
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community.
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at vexorian@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
## 'AI' policy
There are ways in which a LLM-based tool can be helpful such as when searching the web or for learning. More so, tools like github and google themselves are being modified to railroad users into using LLM-based tools. It'd be really impractical and neigh-impossible to ban 'AI' altogether from being used during the development of a contribution and that's not really the purpose of this policy.
HOWEVER, from a legal standpoint, it is too difficult to know the origin of LLM-generated code so there are risks of it infringing on copyright. And from a pragmatic stand point, we want to be able to trust the quality of the code. That is to say, we do not want contributions where the bulk of the code was AI-generated or or where the contributors themselves do not understand the code being pushed. This is also in relation to items 4 and 5 of the encouraged behaviors. We want contributors that can vouch for the code they contribute and can receive and act on constructive feedback to it.
Note that this is only a policy affecting Pull Requests to this project. This project is released under a permissive FOSS licence, so there's nothing really that can stop forks from having a different policy on this and any individual is allowed to create such a fork should they want to.
## Reporting an Issue
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm.
When an incident does occur, it is important to report it promptly. To report a possible violation, may be
reported by contacting the project team at vexorian@gmail.com
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 3.0,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html . The AI policy is a modification.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org

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

@ -7,8 +7,8 @@
* [ ] I have read the code of conduct.
* [ ] I am submitting to the correct base branch
<!--
* Bug fixes for 'stable' versions must go to `patch`.
* New features and fixes for 'edge' version must go to `development`.
* Bug fixes must go to `dev/1.4.x`.
* New features must go to `dev/1.5.x`.
-->
### Changes that modify the db structure
@ -19,7 +19,3 @@
* [ ] I understand that the feature may not be accepted if it doesn't fit the upstream app's planned design direction. But that in this case I am encouraged to share this as an available modification other users can use if they want.
### Code Standards
* [ ] I understand the code being contributed and it's purpose. <!-- Please read CODE_OF_CONDUCT for more info. -->

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);
@ -124,6 +145,7 @@ function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
let more = Math.max(0, filler.duration - fillerstart - 15000 - SLACK);
fillerstart += random.integer(0, more);
}
console.log(`${remaining} ${filler.duration} ${isFirst} ${fillerstart}`);
lineup.push({ // just add the video, starting at 0, playing the entire duration
type: 'commercial',
title: filler.title,
@ -132,7 +154,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 +183,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 +222,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 +273,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 +299,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 +328,13 @@ function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuratio
}
}
let pick = pick1;
let Q = Math.max(30, Math.ceil(10* Math.log(minPickSet) / Math.log(2) ) );
console.log( minPickPlayTime + " : " + Q + " ! " + pickLastPlayed );
if (!isFirst && (minPick != null) && weighedPick(10,Q) ) {
console.log( minPickPlayTime + " : " + minPickFillerId + " : " + JSON.stringify(minPick) );
pick = minPick;
pick.fillerId = minPickFillerId;
}
if (pick != null) {
pick = JSON.parse( JSON.stringify(pick) );
pick.fillerId = fillerId;
@ -288,18 +348,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

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

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;