feat: add mid-roll filler to slot schedulers

This commit is contained in:
Christian Benincasa
2026-03-24 11:52:39 -04:00
parent 515729d370
commit 7855bc1192
21 changed files with 1000 additions and 94 deletions

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View 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);
});
});

View 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;
}

View File

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

View File

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

View File

@@ -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(),
});
//

View File

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

View File

@@ -430,7 +430,6 @@ export default function ChannelLineupList(props: Props) {
return;
}
console.log(program);
setFocusedProgramDetails(program);
const start = dayjs(startTimeDate);
if (startTimeDate) {

View File

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

View File

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

View 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>
);
};

View File

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

View File

@@ -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: {

View File

@@ -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})`;
},