mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: add mid-roll filler to slot schedulers
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -105,6 +105,7 @@ function channelProgramToLineupItem(p: ChannelProgram) {
|
||||
type: 'content',
|
||||
id: program.id,
|
||||
durationMs: program.duration,
|
||||
startOffsetMs: program.startOffsetMs,
|
||||
}))
|
||||
.with({ type: 'custom' }, (program) => ({
|
||||
type: 'content', // Custom program
|
||||
|
||||
@@ -15,11 +15,16 @@ import type { UpdateChannelProgrammingRequest } from '@tunarr/types/api';
|
||||
import type { ContentProgramType } from '@tunarr/types/schemas';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import type { MarkRequired } from 'ts-essentials';
|
||||
import { BasicChannelRepository } from './channel/BasicChannelRepository.ts';
|
||||
import { ChannelConfigRepository } from './channel/ChannelConfigRepository.ts';
|
||||
import { ChannelProgramRepository } from './channel/ChannelProgramRepository.ts';
|
||||
import { LineupRepository } from './channel/LineupRepository.ts';
|
||||
import type {
|
||||
Lineup,
|
||||
LineupItem,
|
||||
PendingProgram,
|
||||
} from './derived_types/Lineup.ts';
|
||||
import type { PageParams } from './interfaces/IChannelDB.ts';
|
||||
import type { Channel, ChannelOrm } from './schema/Channel.ts';
|
||||
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
|
||||
import type { ChannelSubtitlePreferences } from './schema/SubtitlePreferences.ts';
|
||||
@@ -33,11 +38,6 @@ import type {
|
||||
ProgramWithRelationsOrm,
|
||||
TvShowOrm,
|
||||
} from './schema/derivedTypes.ts';
|
||||
import { BasicChannelRepository } from './channel/BasicChannelRepository.ts';
|
||||
import { ChannelProgramRepository } from './channel/ChannelProgramRepository.ts';
|
||||
import { LineupRepository } from './channel/LineupRepository.ts';
|
||||
import { ChannelConfigRepository } from './channel/ChannelConfigRepository.ts';
|
||||
import type { PageParams } from './interfaces/IChannelDB.ts';
|
||||
|
||||
@injectable()
|
||||
export class ChannelDB implements IChannelDB {
|
||||
@@ -174,10 +174,7 @@ export class ChannelDB implements IChannelDB {
|
||||
return this.channelProgram.getChannelFallbackPrograms(uuid);
|
||||
}
|
||||
|
||||
replaceChannelPrograms(
|
||||
channelId: string,
|
||||
programIds: string[],
|
||||
): void {
|
||||
replaceChannelPrograms(channelId: string, programIds: string[]): void {
|
||||
this.channelProgram.replaceChannelPrograms(channelId, programIds);
|
||||
}
|
||||
|
||||
@@ -271,11 +268,7 @@ export class ChannelDB implements IChannelDB {
|
||||
startTime?: number,
|
||||
): Promise<ChannelOrm | null> {
|
||||
// TODO: Update LineupRepository.setChannelPrograms to return ChannelOrm
|
||||
return this.lineup.setChannelPrograms(
|
||||
channel,
|
||||
lineup,
|
||||
startTime,
|
||||
);
|
||||
return this.lineup.setChannelPrograms(channel, lineup, startTime);
|
||||
}
|
||||
|
||||
addPendingPrograms(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
||||
import { globalOptions } from '@/globals.js';
|
||||
import { FileSystemService } from '@/services/FileSystemService.js';
|
||||
import { KEYS } from '@/types/inject.js';
|
||||
import { typedProperty } from '@/types/path.js';
|
||||
import { jsonSchema } from '@/types/schemas.js';
|
||||
import { Nullable } from '@/types/util.js';
|
||||
import { Timer } from '@/util/Timer.js';
|
||||
@@ -17,9 +19,11 @@ import {
|
||||
ContentProgram,
|
||||
} from '@tunarr/types';
|
||||
import { UpdateChannelProgrammingRequest } from '@tunarr/types/api';
|
||||
import { and, eq, inArray, notInArray } from 'drizzle-orm';
|
||||
import { inject, injectable, interfaces } from 'inversify';
|
||||
import { Kysely } from 'kysely';
|
||||
import {
|
||||
chunk,
|
||||
drop,
|
||||
entries,
|
||||
filter,
|
||||
@@ -32,9 +36,9 @@ import {
|
||||
map,
|
||||
mapValues,
|
||||
nth,
|
||||
omit,
|
||||
omitBy,
|
||||
partition,
|
||||
omit,
|
||||
reject,
|
||||
sum,
|
||||
sumBy,
|
||||
@@ -49,6 +53,17 @@ import { MarkRequired } from 'ts-essentials';
|
||||
import { match } from 'ts-pattern';
|
||||
import { MaterializeLineupCommand } from '../../commands/MaterializeLineupCommand.ts';
|
||||
import { MaterializeProgramsCommand } from '../../commands/MaterializeProgramsCommand.ts';
|
||||
import { IWorkerPool } from '../../interfaces/IWorkerPool.ts';
|
||||
import {
|
||||
asyncMapToRecord,
|
||||
groupByFunc,
|
||||
groupByUniqProp,
|
||||
isDefined,
|
||||
isNonEmptyString,
|
||||
mapReduceAsyncSeq,
|
||||
programExternalIdString,
|
||||
run,
|
||||
} from '../../util/index.ts';
|
||||
import { ProgramConverter } from '../converters/ProgramConverter.ts';
|
||||
import {
|
||||
ContentItem,
|
||||
@@ -61,7 +76,6 @@ import {
|
||||
LineupSchema,
|
||||
PendingProgram,
|
||||
} from '../derived_types/Lineup.ts';
|
||||
import { IWorkerPool } from '../../interfaces/IWorkerPool.ts';
|
||||
import {
|
||||
ChannelAndLineup,
|
||||
ChannelAndRawLineup,
|
||||
@@ -75,22 +89,8 @@ import {
|
||||
NewChannelProgram,
|
||||
} from '../schema/ChannelPrograms.ts';
|
||||
import { DB } from '../schema/db.ts';
|
||||
import { DrizzleDBAccess } from '../schema/index.ts';
|
||||
import { ChannelOrmWithPrograms } from '../schema/derivedTypes.ts';
|
||||
import {
|
||||
asyncMapToRecord,
|
||||
groupByFunc,
|
||||
groupByUniqProp,
|
||||
isDefined,
|
||||
isNonEmptyString,
|
||||
mapReduceAsyncSeq,
|
||||
programExternalIdString,
|
||||
run,
|
||||
} from '../../util/index.ts';
|
||||
import { typedProperty } from '@/types/path.js';
|
||||
import { globalOptions } from '@/globals.js';
|
||||
import { and, eq, inArray, notInArray } from 'drizzle-orm';
|
||||
import { chunk } from 'lodash-es';
|
||||
import { DrizzleDBAccess } from '../schema/index.ts';
|
||||
|
||||
// Module-level cache shared within this module
|
||||
const fileDbCache: Record<string | number, Low<Lineup>> = {};
|
||||
@@ -110,6 +110,7 @@ function channelProgramToLineupItemFunc(
|
||||
type: 'content',
|
||||
id: program.persisted ? program.id! : dbIdByUniqueId[program.uniqueId]!,
|
||||
durationMs: program.duration,
|
||||
startOffsetMs: program.startOffsetMs,
|
||||
}))
|
||||
.with({ type: 'custom' }, (program) => ({
|
||||
type: 'content',
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ContentLineupItemSchema = z
|
||||
customShowId: z.uuid().optional(),
|
||||
fillerListId: z.uuid().optional(),
|
||||
fillerType: FillerType.optional(),
|
||||
startOffsetMs: z.number().nonnegative().optional(),
|
||||
})
|
||||
.merge(BaseLineupItemSchema);
|
||||
|
||||
@@ -82,7 +83,7 @@ export const OnDemandChannelConfigSchema = z.object({
|
||||
|
||||
export type OnDemandChannelConfig = z.infer<typeof OnDemandChannelConfigSchema>;
|
||||
|
||||
export const CurrentLineupSchemaVersion = 4;
|
||||
export const CurrentLineupSchemaVersion = 5;
|
||||
|
||||
export const LineupSchema = z.object({
|
||||
version: z
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
} from './slotSchedulerUtil.js';
|
||||
import {
|
||||
addHeadAndTailFillerToSlot,
|
||||
applyMidRollBreaks,
|
||||
createPaddedProgram,
|
||||
createProgramIterators,
|
||||
createProgramMap,
|
||||
@@ -252,7 +253,18 @@ export class RandomSlotScheduler {
|
||||
paddedPrograms = maybePrograms;
|
||||
}
|
||||
|
||||
const totalDuration = sum(map(paddedPrograms, (p) => p.totalDuration));
|
||||
const expandedPrograms = paddedPrograms.flatMap((pp) =>
|
||||
applyMidRollBreaks(
|
||||
pp,
|
||||
currSlot,
|
||||
currSlot.midRollConfig,
|
||||
currSlot.durationSpec.type === 'fixed'
|
||||
? currSlot.durationSpec.durationMs
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const totalDuration = sum(map(expandedPrograms, (p) => p.totalDuration));
|
||||
let remainingTimeInSlot = 0;
|
||||
const startOfNextBlock = +context.timeCursor.add(totalDuration);
|
||||
if (
|
||||
@@ -269,16 +281,16 @@ export class RandomSlotScheduler {
|
||||
// TODO: Implement greedy filling.
|
||||
if (flexPreference === 'distribute' && padStyle === 'episode') {
|
||||
distributeFlex(
|
||||
paddedPrograms,
|
||||
expandedPrograms,
|
||||
this.schedule.padMs,
|
||||
remainingTimeInSlot,
|
||||
);
|
||||
} else if (flexPreference === 'distribute') {
|
||||
// We pad the slot as a whole here. We must find the first content-type
|
||||
// program to add the padding to.
|
||||
const div = Math.floor(remainingTimeInSlot / paddedPrograms.length);
|
||||
const div = Math.floor(remainingTimeInSlot / expandedPrograms.length);
|
||||
let totalAdded = 0;
|
||||
forEach(paddedPrograms, (paddedProgram) => {
|
||||
forEach(expandedPrograms, (paddedProgram) => {
|
||||
if (paddedProgram.program.type === 'filler') {
|
||||
return;
|
||||
}
|
||||
@@ -286,19 +298,19 @@ export class RandomSlotScheduler {
|
||||
totalAdded += div;
|
||||
});
|
||||
const firstContent = find(
|
||||
paddedPrograms,
|
||||
expandedPrograms,
|
||||
({ program }) => program.type !== 'filler',
|
||||
);
|
||||
if (firstContent) {
|
||||
firstContent.padMs += remainingTimeInSlot - totalAdded;
|
||||
}
|
||||
} else {
|
||||
const lastProgram = last(paddedPrograms)!;
|
||||
const lastProgram = last(expandedPrograms)!;
|
||||
lastProgram.padMs += remainingTimeInSlot;
|
||||
}
|
||||
|
||||
let done = false;
|
||||
for (const { program, padMs, totalDuration, filler } of paddedPrograms) {
|
||||
for (const { program, padMs, totalDuration, filler } of expandedPrograms) {
|
||||
if (+context.timeCursor + program.duration > +upperLimit) {
|
||||
done = true;
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { CondensedChannelProgram, FillerProgram } from '@tunarr/types';
|
||||
import type { BaseSlot, SlotFillerTypes } from '@tunarr/types/api';
|
||||
import type {
|
||||
BaseSlot,
|
||||
MidRollConfig,
|
||||
SlotFillerTypes,
|
||||
} from '@tunarr/types/api';
|
||||
import { isEmpty, some } from 'lodash-es';
|
||||
import type { Random } from 'random-js';
|
||||
import type { Nullable } from '../../types/util.ts';
|
||||
@@ -19,6 +23,7 @@ export abstract class SlotImpl<
|
||||
pre: [],
|
||||
tail: [],
|
||||
fallback: [],
|
||||
mid: [],
|
||||
};
|
||||
|
||||
constructor(
|
||||
@@ -83,6 +88,20 @@ export abstract class SlotImpl<
|
||||
return some(this.fillerIteratorsByType, (v) => !isEmpty(v));
|
||||
}
|
||||
|
||||
get midRollConfig(): MidRollConfig | undefined {
|
||||
switch (this.slot.type) {
|
||||
case 'filler':
|
||||
case 'flex':
|
||||
case 'redirect':
|
||||
return;
|
||||
case 'movie':
|
||||
case 'show':
|
||||
case 'custom-show':
|
||||
case 'smart-collection':
|
||||
return this.slot.midRoll;
|
||||
}
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.slot.type;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
} from './slotSchedulerUtil.js';
|
||||
import {
|
||||
addHeadAndTailFillerToSlot,
|
||||
applyMidRollBreaks,
|
||||
createPaddedProgram,
|
||||
createProgramIterators,
|
||||
createProgramMap,
|
||||
@@ -288,6 +289,10 @@ export async function scheduleTimeSlots(
|
||||
);
|
||||
}
|
||||
|
||||
const expandedPrograms = paddedPrograms.flatMap((pp) =>
|
||||
applyMidRollBreaks(pp, currSlot, currSlot.midRollConfig, slotDuration),
|
||||
);
|
||||
|
||||
// We have two options here if there is remaining time in the slot
|
||||
// If we want to be "greedy", we can keep attempting to look for items
|
||||
// to fill the time for this slot. This works mainly if we're doing a
|
||||
@@ -298,19 +303,19 @@ export async function scheduleTimeSlots(
|
||||
currSlot.type !== 'filler'
|
||||
) {
|
||||
distributeFlex(
|
||||
paddedPrograms,
|
||||
expandedPrograms,
|
||||
schedule.padMs,
|
||||
Math.max(
|
||||
0,
|
||||
slotDuration - sumBy(paddedPrograms, (p) => p.totalDuration),
|
||||
slotDuration - sumBy(expandedPrograms, (p) => p.totalDuration),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const lastProgram = last(paddedPrograms)!;
|
||||
const lastProgram = last(expandedPrograms)!;
|
||||
lastProgram.padMs += remainingTimeInSlot;
|
||||
}
|
||||
|
||||
forEach(paddedPrograms, ({ program, padMs, filler }) => {
|
||||
forEach(expandedPrograms, ({ program, padMs, filler }) => {
|
||||
pushProgram(filler.head);
|
||||
pushProgram(filler.pre);
|
||||
pushProgram(program);
|
||||
|
||||
130
server/src/services/scheduling/midRollUtil.test.ts
Normal file
130
server/src/services/scheduling/midRollUtil.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MidRollConfig } from '@tunarr/types/api';
|
||||
import {
|
||||
calculateMidRollBreaks,
|
||||
programQualifiesForMidRoll,
|
||||
} from './midRollUtil.ts';
|
||||
|
||||
const baseConfig: MidRollConfig = {
|
||||
enabled: true,
|
||||
intervalMs: 30 * 60 * 1000, // 30 minutes
|
||||
maxBreaks: 10,
|
||||
breakDurationMs: 3 * 60 * 1000, // 3 minutes
|
||||
minProgramDurationMs: 60 * 60 * 1000, // 60 minutes
|
||||
};
|
||||
|
||||
describe('calculateMidRollBreaks', () => {
|
||||
it('returns null when disabled', () => {
|
||||
const result = calculateMidRollBreaks(2 * 60 * 60 * 1000, {
|
||||
...baseConfig,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for programs shorter than minProgramDurationMs', () => {
|
||||
const result = calculateMidRollBreaks(30 * 60 * 1000, baseConfig);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when breakCount is 0', () => {
|
||||
// Program of exactly 30 min => floor(30/30) - 1 = 0 breaks
|
||||
const result = calculateMidRollBreaks(30 * 60 * 1000, {
|
||||
...baseConfig,
|
||||
minProgramDurationMs: 0,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('caps breaks at maxBreaks', () => {
|
||||
// 3-hour program, 30-min interval => floor(180/30)-1 = 5 breaks, capped at 2
|
||||
const result = calculateMidRollBreaks(3 * 60 * 60 * 1000, {
|
||||
...baseConfig,
|
||||
maxBreaks: 2,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.segments.length).toBe(3); // 2 breaks = 3 segments
|
||||
});
|
||||
|
||||
it('segment durations sum to original program duration', () => {
|
||||
const programDurationMs = 2 * 60 * 60 * 1000; // 2 hours
|
||||
const result = calculateMidRollBreaks(programDurationMs, baseConfig);
|
||||
expect(result).not.toBeNull();
|
||||
const totalSegmentDuration = result!.segments.reduce(
|
||||
(acc, seg) => acc + seg.durationMs,
|
||||
0,
|
||||
);
|
||||
expect(totalSegmentDuration).toBe(programDurationMs);
|
||||
});
|
||||
|
||||
it('assigns correct startOffsetMs per segment', () => {
|
||||
const intervalMs = 30 * 60 * 1000;
|
||||
const result = calculateMidRollBreaks(2 * 60 * 60 * 1000, {
|
||||
...baseConfig,
|
||||
intervalMs,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.segments[0]!.startOffsetMs).toBe(0);
|
||||
expect(result!.segments[1]!.startOffsetMs).toBe(intervalMs);
|
||||
expect(result!.segments[2]!.startOffsetMs).toBe(2 * intervalMs);
|
||||
});
|
||||
|
||||
it('calculates totalBreakDurationMs correctly', () => {
|
||||
const result = calculateMidRollBreaks(2 * 60 * 60 * 1000, baseConfig);
|
||||
expect(result).not.toBeNull();
|
||||
// 2 hours / 30 min = 4, 4-1 = 3 breaks
|
||||
expect(result!.totalBreakDurationMs).toBe(3 * baseConfig.breakDurationMs);
|
||||
});
|
||||
|
||||
it('caps breaks to fit within slotDurationMs', () => {
|
||||
// 2-hour program, 30-min interval => 3 breaks normally
|
||||
// Each break = 3 min. Slot = 2h10m => room for only 3 breaks (2h + 3*3m = 2h9m fits, 4 would overflow)
|
||||
// But with maxBreaks=10 and 3 natural breaks, we just check it doesn't overflow the slot
|
||||
const programDurationMs = 2 * 60 * 60 * 1000; // 2 hours
|
||||
const slotDurationMs = 2 * 60 * 60 * 1000 + 5 * 60 * 1000; // 2h 5m
|
||||
// breakDurationMs = 3 min => max breaks from slot = floor(5min / 3min) = 1
|
||||
const result = calculateMidRollBreaks(programDurationMs, baseConfig, slotDurationMs);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.segments.length).toBe(2); // 1 break = 2 segments
|
||||
expect(
|
||||
programDurationMs + result!.totalBreakDurationMs,
|
||||
).toBeLessThanOrEqual(slotDurationMs);
|
||||
});
|
||||
|
||||
it('returns null when no breaks fit within slotDurationMs', () => {
|
||||
// 2-hour program in a exactly 2-hour slot => 0 room for breaks
|
||||
const programDurationMs = 2 * 60 * 60 * 1000;
|
||||
const result = calculateMidRollBreaks(
|
||||
programDurationMs,
|
||||
baseConfig,
|
||||
programDurationMs,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('programQualifiesForMidRoll', () => {
|
||||
it('returns false when disabled', () => {
|
||||
const result = programQualifiesForMidRoll(
|
||||
{ type: 'content', duration: 1000, persisted: true },
|
||||
{ ...baseConfig, enabled: false },
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-content programs', () => {
|
||||
const result = programQualifiesForMidRoll(
|
||||
{ type: 'flex', duration: 1000, persisted: false },
|
||||
baseConfig,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for content programs', () => {
|
||||
const result = programQualifiesForMidRoll(
|
||||
{ type: 'content', duration: 1000, persisted: true },
|
||||
baseConfig,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
71
server/src/services/scheduling/midRollUtil.ts
Normal file
71
server/src/services/scheduling/midRollUtil.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { CondensedChannelProgram } from '@tunarr/types';
|
||||
import type { MidRollConfig } from '@tunarr/types/api';
|
||||
|
||||
export type MidRollBreakResult = {
|
||||
segments: { startOffsetMs: number; durationMs: number }[];
|
||||
totalBreakDurationMs: number;
|
||||
};
|
||||
|
||||
export function calculateMidRollBreaks(
|
||||
programDurationMs: number,
|
||||
config: MidRollConfig,
|
||||
slotDurationMs?: number,
|
||||
): MidRollBreakResult | null {
|
||||
if (!config.enabled || programDurationMs < config.minProgramDurationMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let breakCount = Math.floor(programDurationMs / config.intervalMs) - 1;
|
||||
if (config.maxBreaks >= 1) {
|
||||
breakCount = Math.min(breakCount, config.maxBreaks);
|
||||
}
|
||||
|
||||
// If we know the slot duration, cap breaks so the total fits:
|
||||
// programDuration + breakCount * breakDuration <= slotDuration
|
||||
if (slotDurationMs != null) {
|
||||
const maxBreaksFromSlot = Math.floor(
|
||||
(slotDurationMs - programDurationMs) / config.breakDurationMs,
|
||||
);
|
||||
breakCount = Math.min(breakCount, maxBreaksFromSlot);
|
||||
}
|
||||
|
||||
if (breakCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments: { startOffsetMs: number; durationMs: number }[] = [];
|
||||
for (let i = 0; i < breakCount; i++) {
|
||||
const startOffsetMs = i * config.intervalMs;
|
||||
const endOffsetMs = (i + 1) * config.intervalMs;
|
||||
segments.push({ startOffsetMs, durationMs: endOffsetMs - startOffsetMs });
|
||||
}
|
||||
// Final segment
|
||||
segments.push({
|
||||
startOffsetMs: breakCount * config.intervalMs,
|
||||
durationMs: programDurationMs - breakCount * config.intervalMs,
|
||||
});
|
||||
|
||||
return {
|
||||
segments,
|
||||
totalBreakDurationMs: breakCount * config.breakDurationMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function programQualifiesForMidRoll(
|
||||
program: CondensedChannelProgram,
|
||||
config: MidRollConfig,
|
||||
): boolean {
|
||||
if (!config.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (program.type !== 'content') {
|
||||
return false;
|
||||
}
|
||||
if (!config.programTypes || config.programTypes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
// We can't easily check subtype on CondensedContentProgram, so we allow
|
||||
// all content programs when type filter is set (subtype filtering happens
|
||||
// at a higher level if needed).
|
||||
return true;
|
||||
}
|
||||
@@ -12,8 +12,13 @@ import type {
|
||||
BaseShowProgrammingSlot,
|
||||
BaseSlot,
|
||||
FillerProgrammingSlot,
|
||||
MidRollConfig,
|
||||
SlotFillerTypes,
|
||||
} from '@tunarr/types/api';
|
||||
import {
|
||||
calculateMidRollBreaks,
|
||||
programQualifiesForMidRoll,
|
||||
} from './midRollUtil.ts';
|
||||
import { FillerTypes } from '@tunarr/types/schemas';
|
||||
import type { Duration } from 'dayjs/plugin/duration.js';
|
||||
import {
|
||||
@@ -747,6 +752,128 @@ export class PaddedProgram {
|
||||
return programDur + fillerDur + this.padMs;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Builds a sequence of PaddedPrograms to fill a mid-roll commercial break.
|
||||
* Picks filler items from the slot until breakDurationMs is consumed.
|
||||
* Any remaining time (e.g. when no more filler fits) becomes padMs on the last
|
||||
* item and will be emitted as fallback filler or flex by the emission loop.
|
||||
*/
|
||||
function buildMidRollBreak(
|
||||
slot: SlotImpl<BaseSlot>,
|
||||
breakDurationMs: number,
|
||||
): PaddedProgram[] {
|
||||
const items: PaddedProgram[] = [];
|
||||
let remainingMs = breakDurationMs;
|
||||
|
||||
while (remainingMs > 0) {
|
||||
const filler = slot.getFillerOfType('mid', {
|
||||
slotDuration: remainingMs,
|
||||
timeCursor: -1,
|
||||
});
|
||||
if (!filler || filler.duration <= 0) break;
|
||||
|
||||
const usedDuration = Math.min(filler.duration, remainingMs);
|
||||
items.push(
|
||||
new PaddedProgram(
|
||||
{ ...filler, duration: usedDuration, fillerType: 'mid' } satisfies FillerProgram,
|
||||
0,
|
||||
{},
|
||||
),
|
||||
);
|
||||
remainingMs -= usedDuration;
|
||||
}
|
||||
|
||||
if (remainingMs > 0) {
|
||||
if (items.length > 0) {
|
||||
const lastIdx = items.length - 1;
|
||||
const lastItem = items[lastIdx]!;
|
||||
items[lastIdx] = new PaddedProgram(lastItem.program, remainingMs, lastItem.filler);
|
||||
} else {
|
||||
// No filler configured – emit the full break as flex
|
||||
items.push(
|
||||
new PaddedProgram(
|
||||
{ type: 'flex', duration: remainingMs, persisted: false },
|
||||
0,
|
||||
{},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a padded program into a flat list of PaddedPrograms for mid-roll
|
||||
* filler insertion. Content segments alternate with break filler sequences.
|
||||
* If the program doesn't qualify or mid-roll is disabled, returns the original
|
||||
* program unchanged.
|
||||
*/
|
||||
export function applyMidRollBreaks(
|
||||
paddedProgram: PaddedProgram,
|
||||
slot: SlotImpl<BaseSlot>,
|
||||
midRollConfig: MidRollConfig | undefined,
|
||||
slotDurationMs?: number,
|
||||
): PaddedProgram[] {
|
||||
if (!midRollConfig || !midRollConfig.enabled) {
|
||||
return [paddedProgram];
|
||||
}
|
||||
|
||||
const { program } = paddedProgram;
|
||||
if (!programQualifiesForMidRoll(program, midRollConfig)) {
|
||||
return [paddedProgram];
|
||||
}
|
||||
|
||||
const result = calculateMidRollBreaks(program.duration, midRollConfig, slotDurationMs);
|
||||
if (!result) {
|
||||
return [paddedProgram];
|
||||
}
|
||||
|
||||
const flat: PaddedProgram[] = [];
|
||||
|
||||
result.segments.forEach((segment, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === result.segments.length - 1;
|
||||
|
||||
const segmentProgram: CondensedChannelProgram = {
|
||||
...program,
|
||||
duration: segment.durationMs,
|
||||
...(program.type === 'content'
|
||||
? { startOffsetMs: segment.startOffsetMs }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const fillerForSegment: Partial<
|
||||
Record<SlotFillerTypes, CondensedChannelProgram>
|
||||
> = {};
|
||||
|
||||
if (isFirst) {
|
||||
if (paddedProgram.filler.head) fillerForSegment.head = paddedProgram.filler.head;
|
||||
if (paddedProgram.filler.pre) fillerForSegment.pre = paddedProgram.filler.pre;
|
||||
}
|
||||
|
||||
if (isLast) {
|
||||
if (paddedProgram.filler.post) fillerForSegment.post = paddedProgram.filler.post;
|
||||
if (paddedProgram.filler.tail) fillerForSegment.tail = paddedProgram.filler.tail;
|
||||
}
|
||||
|
||||
flat.push(
|
||||
new PaddedProgram(
|
||||
segmentProgram,
|
||||
isLast ? paddedProgram.padMs : 0,
|
||||
fillerForSegment,
|
||||
),
|
||||
);
|
||||
|
||||
// After each non-last segment, insert the commercial break as its own items
|
||||
if (!isLast) {
|
||||
flat.push(...buildMidRollBreak(slot, midRollConfig.breakDurationMs));
|
||||
}
|
||||
});
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
export function createIndexByIdMap(
|
||||
programs: SlotSchedulerProgram[],
|
||||
customShowId,
|
||||
|
||||
@@ -351,12 +351,14 @@ export class StreamProgramCalculator {
|
||||
type: 'commercial',
|
||||
fillerListId: lineupItem.fillerListId,
|
||||
infiniteLoop: backingItem.duration < streamDuration,
|
||||
startOffset: lineupItem.startOffsetMs ?? 0,
|
||||
} satisfies CommercialStreamLineupItem;
|
||||
} else {
|
||||
program = {
|
||||
...baseItem,
|
||||
type: 'program',
|
||||
infiniteLoop: false,
|
||||
startOffset: lineupItem.startOffsetMs ?? 0,
|
||||
} satisfies ProgramStreamLineupItem;
|
||||
}
|
||||
} else if (backingItem) {
|
||||
@@ -542,7 +544,7 @@ export class StreamProgramCalculator {
|
||||
if (program.type === 'commercial') {
|
||||
return {
|
||||
...program,
|
||||
startOffset: timeElapsed,
|
||||
startOffset: timeElapsed + (program.startOffset ?? 0),
|
||||
streamDuration,
|
||||
};
|
||||
}
|
||||
@@ -550,7 +552,7 @@ export class StreamProgramCalculator {
|
||||
return {
|
||||
...program,
|
||||
type: 'program',
|
||||
startOffset: timeElapsed,
|
||||
startOffset: timeElapsed + (program.startOffset ?? 0),
|
||||
streamDuration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,10 +25,25 @@ export const SlotFillerTypes = z.enum([
|
||||
'post',
|
||||
'tail',
|
||||
'fallback',
|
||||
'mid',
|
||||
]);
|
||||
|
||||
export type SlotFillerTypes = z.infer<typeof SlotFillerTypes>;
|
||||
|
||||
export const MidRollConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
intervalMs: z.number().positive(),
|
||||
maxBreaks: z.number().int().nonnegative(),
|
||||
breakDurationMs: z.number().positive(),
|
||||
minProgramDurationMs: z.number().nonnegative(),
|
||||
programTypes: z
|
||||
.array(
|
||||
z.enum(['movie', 'episode', 'track', 'music_video', 'other_video']),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
export type MidRollConfig = z.infer<typeof MidRollConfigSchema>;
|
||||
|
||||
export const SlotFiller = z.object({
|
||||
types: z.array(SlotFillerTypes).nonempty(),
|
||||
fillerListId: z.uuid(),
|
||||
@@ -41,6 +56,7 @@ export type SlotFiller = z.infer<typeof SlotFiller>;
|
||||
|
||||
export const Slot = z.object({
|
||||
filler: z.array(SlotFiller).optional(),
|
||||
midRoll: MidRollConfigSchema.optional(),
|
||||
});
|
||||
|
||||
//
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
DynamicContentConfigSchema,
|
||||
LineupScheduleSchema,
|
||||
} from '../api/Scheduling.js';
|
||||
import { ChannelIconSchema } from './utilSchemas.js';
|
||||
export * from './lineupPrograms.js';
|
||||
import {
|
||||
CondensedContentProgramSchema,
|
||||
CondensedCustomProgramSchema,
|
||||
@@ -15,6 +13,8 @@ import {
|
||||
FlexProgramSchema,
|
||||
RedirectProgramSchema,
|
||||
} from './lineupPrograms.js';
|
||||
import { ChannelIconSchema } from './utilSchemas.js';
|
||||
export * from './lineupPrograms.js';
|
||||
|
||||
export const ChannelProgramSchema = z.discriminatedUnion('type', [
|
||||
ContentProgramSchema,
|
||||
|
||||
@@ -430,7 +430,6 @@ export default function ChannelLineupList(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(program);
|
||||
setFocusedProgramDetails(program);
|
||||
const start = dayjs(startTimeDate);
|
||||
if (startTimeDate) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { SlotViewModel } from '../../model/SlotModels.ts';
|
||||
import { RouterLink } from '../base/RouterLink.tsx';
|
||||
import { TabPanel } from '../TabPanel.tsx';
|
||||
import { EditSlotProgrammingForm } from './EditSlotProgrammingForm.tsx';
|
||||
import { MidRollConfigPanel } from './MidRollConfigPanel.tsx';
|
||||
import { SlotFillerDialogPanel } from './SlotFillerDialogPanel.tsx';
|
||||
|
||||
type EditRandomSlotDialogContentProps = {
|
||||
@@ -268,6 +269,9 @@ export const EditRandomSlotDialogContent = ({
|
||||
>
|
||||
<Tab label="Programming" value={0} />
|
||||
{programType !== 'flex' && <Tab label="Filler" value={1} />}
|
||||
{programType !== 'flex' &&
|
||||
programType !== 'redirect' &&
|
||||
programType !== 'filler' && <Tab label="Mid-Roll" value={2} />}
|
||||
</Tabs>
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Stack gap={2} useFlexGap>
|
||||
@@ -421,6 +425,11 @@ export const EditRandomSlotDialogContent = ({
|
||||
</FormProvider>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={2}>
|
||||
<FormProvider {...formMethods}>
|
||||
<MidRollConfigPanel />
|
||||
</FormProvider>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useFillerLists } from '../../hooks/useFillerLists.ts';
|
||||
import { useTimeSlotFormContext } from '../../hooks/useTimeSlotFormContext.ts';
|
||||
import { TabPanel } from '../TabPanel.tsx';
|
||||
import { EditSlotProgrammingForm } from './EditSlotProgrammingForm.tsx';
|
||||
import { MidRollConfigPanel } from './MidRollConfigPanel.tsx';
|
||||
import { SlotFillerDialogPanel } from './SlotFillerDialogPanel.tsx';
|
||||
import { TimeSlotConfigDialogPanel } from './TimeSlotConfigDialogPanel.tsx';
|
||||
|
||||
@@ -214,6 +215,14 @@ export const EditTimeSlotDialogContent = ({
|
||||
disabled={slotType === 'flex' || fillerLists.length === 0}
|
||||
/>
|
||||
<Tab label="Config" />
|
||||
<Tab
|
||||
label="Mid-Roll"
|
||||
disabled={
|
||||
slotType === 'flex' ||
|
||||
slotType === 'redirect' ||
|
||||
slotType === 'filler'
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Stack gap={2} useFlexGap>
|
||||
@@ -283,6 +292,9 @@ export const EditTimeSlotDialogContent = ({
|
||||
<TabPanel value={tab} index={2}>
|
||||
<TimeSlotConfigDialogPanel />
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={3}>
|
||||
<MidRollConfigPanel />
|
||||
</TabPanel>
|
||||
</FormProvider>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
178
web/src/components/slot_scheduler/MidRollConfigPanel.tsx
Normal file
178
web/src/components/slot_scheduler/MidRollConfigPanel.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormLabel,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import type { BaseSlot, MidRollConfig } from '@tunarr/types/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
const programTypeLabels: Record<
|
||||
NonNullable<MidRollConfig['programTypes']>[number],
|
||||
string
|
||||
> = {
|
||||
movie: 'Movies',
|
||||
episode: 'Episodes',
|
||||
track: 'Music Tracks',
|
||||
music_video: 'Music Videos',
|
||||
other_video: 'Other Videos',
|
||||
};
|
||||
|
||||
const allProgramTypes = Object.keys(
|
||||
programTypeLabels,
|
||||
) as NonNullable<MidRollConfig['programTypes']>;
|
||||
|
||||
function msToMinutes(ms: number): number {
|
||||
return Math.round(dayjs.duration(ms).asMinutes());
|
||||
}
|
||||
|
||||
function minutesToMs(minutes: number): number {
|
||||
return dayjs.duration({ minutes }).asMilliseconds();
|
||||
}
|
||||
|
||||
export const MidRollConfigPanel = () => {
|
||||
const { control, watch } = useFormContext<BaseSlot>();
|
||||
const enabled = watch('midRoll.enabled' as never) as unknown as
|
||||
| boolean
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Mid-roll inserts commercial breaks within long programs at fixed
|
||||
intervals.
|
||||
</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'midRoll.enabled' as never}
|
||||
defaultValue={false as never}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!!field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Enable Mid-Roll Breaks"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{enabled && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'midRoll.intervalMs' as never}
|
||||
defaultValue={minutesToMs(30) as never}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Break Interval (minutes)"
|
||||
type="number"
|
||||
inputProps={{ min: 1 }}
|
||||
value={msToMinutes(field.value as number)}
|
||||
onChange={(e) =>
|
||||
field.onChange(minutesToMs(Number(e.target.value)))
|
||||
}
|
||||
helperText="How often to insert a break"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'midRoll.breakDurationMs' as never}
|
||||
defaultValue={minutesToMs(3) as never}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Break Duration (minutes)"
|
||||
type="number"
|
||||
inputProps={{ min: 1 }}
|
||||
value={msToMinutes(field.value as number)}
|
||||
onChange={(e) =>
|
||||
field.onChange(minutesToMs(Number(e.target.value)))
|
||||
}
|
||||
helperText="How long each commercial break lasts"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'midRoll.maxBreaks' as never}
|
||||
defaultValue={0 as never}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Max Breaks"
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
helperText="Maximum number of breaks per program (0 = unlimited)"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'midRoll.minProgramDurationMs' as never}
|
||||
defaultValue={minutesToMs(60) as never}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Minimum Program Duration (minutes)"
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
value={msToMinutes(field.value as number)}
|
||||
onChange={(e) =>
|
||||
field.onChange(minutesToMs(Number(e.target.value)))
|
||||
}
|
||||
helperText="Skip mid-roll for programs shorter than this"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'midRoll.programTypes' as never}
|
||||
defaultValue={[] as never}
|
||||
render={({ field }) => {
|
||||
const selectedTypes = (field.value as string[] | undefined) ?? [];
|
||||
const handleChange = (
|
||||
type: (typeof allProgramTypes)[number],
|
||||
checked: boolean,
|
||||
) => {
|
||||
if (checked) {
|
||||
field.onChange([...selectedTypes, type]);
|
||||
} else {
|
||||
field.onChange(selectedTypes.filter((t) => t !== type));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">
|
||||
Apply to Program Types (empty = all)
|
||||
</FormLabel>
|
||||
<FormGroup row>
|
||||
{allProgramTypes.map((type) => (
|
||||
<FormControlLabel
|
||||
key={type}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedTypes.includes(type)}
|
||||
onChange={(e) => handleChange(type, e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={programTypeLabels[type]}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -160,6 +160,7 @@ export const SlotFillerDialogPanel = () => {
|
||||
<ToggleButton value="tail">
|
||||
<LastPage sx={{ mr: 1 }} /> Tail
|
||||
</ToggleButton>
|
||||
<ToggleButton value="mid">Mid</ToggleButton>
|
||||
<ToggleButton value="fallback">
|
||||
<Repeat /> Fallback
|
||||
</ToggleButton>
|
||||
|
||||
@@ -3694,10 +3694,18 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -3707,10 +3715,18 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -3736,10 +3752,18 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
} | {
|
||||
@@ -3750,10 +3774,18 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
}>;
|
||||
timeZoneOffset: number;
|
||||
startTomorrow?: boolean;
|
||||
@@ -3765,10 +3797,18 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
padStyle: 'slot' | 'episode';
|
||||
slots: Array<{
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -3785,10 +3825,18 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
type: 'movie';
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -3835,10 +3883,18 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
channelName?: string;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -3874,10 +3930,18 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
recoveryFactor: number;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -4016,10 +4080,18 @@ export type PostApiChannelsByIdProgrammingData = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction?: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -4029,10 +4101,18 @@ export type PostApiChannelsByIdProgrammingData = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction?: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -4058,10 +4138,18 @@ export type PostApiChannelsByIdProgrammingData = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction?: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
} | {
|
||||
@@ -4072,10 +4160,18 @@ export type PostApiChannelsByIdProgrammingData = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction?: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
}>;
|
||||
timeZoneOffset: number;
|
||||
startTomorrow?: boolean;
|
||||
@@ -4093,10 +4189,18 @@ export type PostApiChannelsByIdProgrammingData = {
|
||||
padStyle: 'slot' | 'episode';
|
||||
slots: Array<{
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec?: {
|
||||
@@ -4113,10 +4217,18 @@ export type PostApiChannelsByIdProgrammingData = {
|
||||
type: 'movie';
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec?: {
|
||||
@@ -4163,10 +4275,18 @@ export type PostApiChannelsByIdProgrammingData = {
|
||||
channelName?: string;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec?: {
|
||||
@@ -4202,10 +4322,18 @@ export type PostApiChannelsByIdProgrammingData = {
|
||||
recoveryFactor: number;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec?: {
|
||||
@@ -4349,10 +4477,18 @@ export type PostApiChannelsByIdProgrammingResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -4362,10 +4498,18 @@ export type PostApiChannelsByIdProgrammingResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -4391,10 +4535,18 @@ export type PostApiChannelsByIdProgrammingResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
} | {
|
||||
@@ -4405,10 +4557,18 @@ export type PostApiChannelsByIdProgrammingResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
}>;
|
||||
timeZoneOffset: number;
|
||||
startTomorrow?: boolean;
|
||||
@@ -4420,10 +4580,18 @@ export type PostApiChannelsByIdProgrammingResponses = {
|
||||
padStyle: 'slot' | 'episode';
|
||||
slots: Array<{
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -4440,10 +4608,18 @@ export type PostApiChannelsByIdProgrammingResponses = {
|
||||
type: 'movie';
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -4490,10 +4666,18 @@ export type PostApiChannelsByIdProgrammingResponses = {
|
||||
channelName?: string;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -4529,10 +4713,18 @@ export type PostApiChannelsByIdProgrammingResponses = {
|
||||
recoveryFactor: number;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -5003,10 +5195,18 @@ export type PostApiChannelsByChannelIdScheduleTimeSlotsData = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction?: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -5016,10 +5216,18 @@ export type PostApiChannelsByChannelIdScheduleTimeSlotsData = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction?: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -5045,10 +5253,18 @@ export type PostApiChannelsByChannelIdScheduleTimeSlotsData = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction?: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
} | {
|
||||
@@ -5059,10 +5275,18 @@ export type PostApiChannelsByChannelIdScheduleTimeSlotsData = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction?: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
}>;
|
||||
timeZoneOffset: number;
|
||||
startTomorrow?: boolean;
|
||||
@@ -5175,10 +5399,18 @@ export type PostApiChannelsByChannelIdScheduleSlotsData = {
|
||||
padStyle: 'slot' | 'episode';
|
||||
slots: Array<{
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec?: {
|
||||
@@ -5195,10 +5427,18 @@ export type PostApiChannelsByChannelIdScheduleSlotsData = {
|
||||
type: 'movie';
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec?: {
|
||||
@@ -5245,10 +5485,18 @@ export type PostApiChannelsByChannelIdScheduleSlotsData = {
|
||||
channelName?: string;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec?: {
|
||||
@@ -5284,10 +5532,18 @@ export type PostApiChannelsByChannelIdScheduleSlotsData = {
|
||||
recoveryFactor: number;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder?: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled?: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec?: {
|
||||
@@ -5435,10 +5691,18 @@ export type GetApiChannelsByIdScheduleResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
} | {
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
@@ -5448,10 +5712,18 @@ export type GetApiChannelsByIdScheduleResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
show: Show | null;
|
||||
/**
|
||||
* A show that existed in the DB at schedule time, but no longer exists.
|
||||
@@ -5583,10 +5855,18 @@ export type GetApiChannelsByIdScheduleResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
startTime: number;
|
||||
padMs?: number;
|
||||
customShow: {
|
||||
@@ -5623,10 +5903,18 @@ export type GetApiChannelsByIdScheduleResponses = {
|
||||
order: 'next' | 'shuffle' | 'ordered_shuffle' | 'alphanumeric' | 'chronological';
|
||||
direction: 'asc' | 'desc';
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
smartCollection: {
|
||||
uuid: string;
|
||||
name: string;
|
||||
@@ -5646,10 +5934,18 @@ export type GetApiChannelsByIdScheduleResponses = {
|
||||
padStyle: 'slot' | 'episode';
|
||||
slots: Array<{
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -5666,10 +5962,18 @@ export type GetApiChannelsByIdScheduleResponses = {
|
||||
type: 'movie';
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -5831,10 +6135,18 @@ export type GetApiChannelsByIdScheduleResponses = {
|
||||
isMissing: boolean;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
@@ -5887,10 +6199,18 @@ export type GetApiChannelsByIdScheduleResponses = {
|
||||
isMissing: boolean;
|
||||
} | {
|
||||
filler?: Array<{
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback'>;
|
||||
types: Array<'head' | 'pre' | 'post' | 'tail' | 'fallback' | 'mid'>;
|
||||
fillerListId: string;
|
||||
fillerOrder: 'shuffle_prefer_short' | 'shuffle_prefer_long' | 'uniform';
|
||||
}>;
|
||||
midRoll?: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
maxBreaks: number;
|
||||
breakDurationMs: number;
|
||||
minProgramDurationMs: number;
|
||||
programTypes?: Array<'movie' | 'episode' | 'track' | 'music_video' | 'other_video'>;
|
||||
};
|
||||
cooldownMs: number;
|
||||
periodMs?: number;
|
||||
durationSpec: {
|
||||
|
||||
@@ -100,10 +100,19 @@ export const useProgramTitleFormatter = () => {
|
||||
) {
|
||||
title += ` ${baseItemTitleFormatter(program.program)}`;
|
||||
}
|
||||
const dur = betterHumanize(
|
||||
dayjs.duration({ milliseconds: program.duration }),
|
||||
{ exact: true },
|
||||
);
|
||||
|
||||
let dur: string;
|
||||
if (program.type === 'content' && program.startOffsetMs) {
|
||||
dur = betterHumanize(
|
||||
dayjs.duration(program.duration - program.startOffsetMs),
|
||||
{ exact: true },
|
||||
);
|
||||
} else {
|
||||
dur = betterHumanize(
|
||||
dayjs.duration({ milliseconds: program.duration }),
|
||||
{ exact: true },
|
||||
);
|
||||
}
|
||||
|
||||
return `${title} - (${dur})`;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user