fix: regenerate lineups when channel start times change

This commit is contained in:
Christian Benincasa
2026-02-19 17:54:31 -05:00
parent 84b88c0d97
commit 1966218bbd
8 changed files with 190 additions and 1038 deletions

File diff suppressed because one or more lines are too long

View File

@@ -54,6 +54,7 @@ import { GetMaterializedChannelScheduleCommand } from '../commands/GetMaterializ
import { MaterializeLineupCommand } from '../commands/MaterializeLineupCommand.ts';
import { MaterializeProgramGroupings } from '../commands/MaterializeProgramGroupings.ts';
import { MaterializeProgramsCommand } from '../commands/MaterializeProgramsCommand.ts';
import { RegenerateChannelLineupCommand } from '../commands/RegenerateChannelLineupCommand.ts';
import { container } from '../container.ts';
import { transcodeConfigOrmToDto } from '../db/converters/transcodeConfigConverters.ts';
import type { LegacyChannelAndLineup } from '../db/interfaces/IChannelDB.ts';
@@ -288,12 +289,19 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
const needsGuideRegen =
channel.guideMinimumDuration !==
updatedChannel.channel.guideMinimumDuration ||
channel.startTime !== updatedChannel.channel.startTime ||
isDefined(req.body.onDemand);
await req.serverCtx.guideService.updateCachedChannel(
channel.uuid,
needsGuideRegen,
);
if (needsGuideRegen) {
await container
.get<RegenerateChannelLineupCommand>(
RegenerateChannelLineupCommand,
)
.execute({ channelId: channel.uuid });
} else {
await req.serverCtx.guideService.updateCachedChannel(channel.uuid);
}
await req.serverCtx.m3uService.regenerateCache();
const apiChannel = omit(

View File

@@ -0,0 +1,98 @@
import { isNonEmptyString, seq } from '@tunarr/shared/util';
import { ChannelProgram } from '@tunarr/types';
import { inject, injectable, interfaces } from 'inversify';
import { match, P } from 'ts-pattern';
import { LineupItem } from '../db/derived_types/Lineup.ts';
import { IChannelDB } from '../db/interfaces/IChannelDB.ts';
import { IWorkerPool } from '../interfaces/IWorkerPool.ts';
import { TVGuideService } from '../services/TvGuideService.ts';
import { KEYS } from '../types/inject.ts';
import { Logger } from '../util/logging/LoggerFactory.ts';
type Request = {
channelId: string;
};
@injectable()
export class RegenerateChannelLineupCommand {
constructor(
@inject(KEYS.Logger) private logger: Logger,
@inject(KEYS.ChannelDB) private channelDB: IChannelDB,
@inject(KEYS.WorkerPoolFactory)
private workerPoolProvider: interfaces.AutoFactory<IWorkerPool>,
@inject(TVGuideService) private tvGuideService: TVGuideService,
) {}
async execute({ channelId }: Request) {
const channelAndLineup =
await this.channelDB.loadChannelAndLineupOrm(channelId);
if (!channelAndLineup) {
this.logger.warn('Channel ID %s not found', channelId);
return;
}
if (channelAndLineup.lineup.schedule) {
if (channelAndLineup.lineup.schedule.type === 'time') {
const { result } = await this.workerPoolProvider().queueTask({
type: 'time-slots',
request: {
type: 'channel',
channelId,
schedule: channelAndLineup.lineup.schedule,
startTime: channelAndLineup.channel.startTime,
},
});
const lineupItems = seq.collect(
result.lineup,
channelProgramToLineupItem,
);
const programIds = seq.collect(lineupItems, (item) => {
return match(item)
.with({ type: 'content' }, (i) => i.id)
.otherwise(() => null);
});
await this.channelDB.replaceChannelPrograms(channelId, programIds);
await this.channelDB.saveLineup(channelId, { items: lineupItems });
// Regenerate schedule at the new start time.
}
}
await this.tvGuideService.updateCachedChannel(channelId, true);
}
}
function channelProgramToLineupItem(p: ChannelProgram) {
return match(p)
.returnType<LineupItem | null>()
.with({ type: 'content', id: P.when(isNonEmptyString) }, (program) => ({
type: 'content',
id: program.id,
durationMs: program.duration,
}))
.with({ type: 'custom' }, (program) => ({
type: 'content', // Custom program
durationMs: program.duration,
id: program.id,
customShowId: program.customShowId,
}))
.with({ type: 'filler' }, (program) => ({
type: 'content',
durationMs: program.duration,
id: program.id,
fillerListId: program.fillerListId,
fillerType: program.fillerType,
}))
.with({ type: 'redirect' }, (program) => ({
type: 'redirect',
channel: program.channel,
durationMs: program.duration,
}))
.with({ type: 'flex' }, (program) => ({
type: 'offline',
durationMs: program.duration,
}))
.otherwise(() => null);
}

View File

@@ -14,7 +14,7 @@ import { KEYS } from '@/types/inject.js';
import { typedProperty } from '@/types/path.js';
import { Result } from '@/types/result.js';
import { jsonSchema } from '@/types/schemas.js';
import { Maybe, PagedResult } from '@/types/util.js';
import { Maybe, Nullable, PagedResult } from '@/types/util.js';
import { Timer } from '@/util/Timer.js';
import { asyncPool } from '@/util/asyncPool.js';
import dayjs from '@/util/dayjs.js';
@@ -34,7 +34,15 @@ import {
} from '@tunarr/types';
import { UpdateChannelProgrammingRequest } from '@tunarr/types/api';
import { ContentProgramType } from '@tunarr/types/schemas';
import { and, asc, count, countDistinct, eq, isNotNull } from 'drizzle-orm';
import {
and,
asc,
count,
countDistinct,
sum as dbSum,
eq,
isNotNull,
} from 'drizzle-orm';
import { inject, injectable, interfaces } from 'inversify';
import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
@@ -191,7 +199,7 @@ function updateRequestToChannel(updateReq: SaveableChannel): ChannelUpdate {
guideMinimumDuration: updateReq.guideMinimumDuration,
groupTitle: updateReq.groupTitle,
disableFillerOverlay: booleanToNumber(updateReq.disableFillerOverlay),
startTime: updateReq.startTime,
startTime: +dayjs(updateReq.startTime).second(0).millisecond(0),
offline: JSON.stringify(updateReq.offline),
name: updateReq.name,
duration: updateReq.duration,
@@ -1143,21 +1151,49 @@ export class ChannelDB implements IChannelDB {
// .execute();
}
async updateLineup(id: string, req: UpdateChannelProgrammingRequest) {
const channel = await this.db
.selectFrom('channel')
.selectAll()
.where('channel.uuid', '=', id)
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('channelPrograms')
.whereRef('channelPrograms.channelUuid', '=', 'channel.uuid')
.select(['channelPrograms.programUuid as uuid']),
).as('programs'),
)
.groupBy('channel.uuid')
.executeTakeFirst();
async replaceChannelPrograms(
channelId: string,
programIds: string[],
): Promise<void> {
const uniqueIds = uniq(programIds);
await this.drizzleDB.transaction(async (tx) => {
await tx
.delete(ChannelPrograms)
.where(eq(ChannelPrograms.channelUuid, channelId));
for (const c of chunk(uniqueIds, 250)) {
await tx
.insert(ChannelPrograms)
.values(c.map((id) => ({ channelUuid: channelId, programUuid: id })));
}
// This can probably be done in a single query.
const sumResult = await tx
.select({ duration: dbSum(Program.duration).mapWith(Number) })
.from(ChannelPrograms)
.where(eq(ChannelPrograms.channelUuid, channelId))
.innerJoin(Program, eq(Program.uuid, ChannelPrograms.programUuid));
await tx
.update(Channel)
.set({
duration: sum(sumResult.map((r) => r.duration)),
})
.where(eq(Channel.uuid, channelId));
});
}
async updateLineup(
id: string,
req: UpdateChannelProgrammingRequest,
): Promise<Nullable<{ channel: ChannelOrm; newLineup: LineupItem[] }>> {
const channel = await this.drizzleDB.query.channels.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
with: {
channelPrograms: {
columns: {
programUuid: true,
},
},
},
});
const lineup = await this.loadLineup(id);
@@ -1181,7 +1217,7 @@ export class ChannelDB implements IChannelDB {
]);
const existingIds = new Set([
...channel.programs.map((program) => program.uuid),
...channel.channelPrograms.map((cp) => cp.programUuid),
]);
// Create our remove operations
@@ -1333,17 +1369,7 @@ export class ChannelDB implements IChannelDB {
type: 'time-slots',
request: {
type: 'programs',
programIds: seq.collect(req.programs, (p) => {
switch (p.type) {
case 'custom':
case 'content':
case 'filler':
return p.id;
case 'redirect':
case 'flex':
return;
}
}),
programIds: req.programs,
schedule: req.schedule,
seed: req.seed,
startTime: channel.startTime,
@@ -1361,17 +1387,7 @@ export class ChannelDB implements IChannelDB {
type: 'schedule-slots',
request: {
type: 'programs',
programIds: seq.collect(req.programs, (p) => {
switch (p.type) {
case 'custom':
case 'content':
case 'filler':
return p.id;
case 'redirect':
case 'flex':
return;
}
}),
programIds: req.programs,
startTime: channel.startTime,
schedule: req.schedule,
seed: req.seed,

View File

@@ -112,10 +112,15 @@ export interface IChannelDB {
limit?: number,
): Promise<CondensedChannelProgramming | null>;
replaceChannelPrograms(
channelId: string,
programIds: string[],
): Promise<void>;
updateLineup(
id: string,
req: UpdateChannelProgrammingRequest,
): Promise<Nullable<{ channel: Channel; newLineup: LineupItem[] }>>;
): Promise<Nullable<{ channel: ChannelOrm; newLineup: LineupItem[] }>>;
saveLineup(
channelId: string,

View File

@@ -159,7 +159,7 @@ export const TimeBasedProgramLineupSchema = z.object({
// We do this so that we can potentially create longer schedules
// on the server-side. However, we can filter this list down to only
// programs included in at least one time slot...
programs: z.array(ChannelProgramSchema),
programs: z.array(z.string()),
schedule: TimeSlotScheduleSchema,
seed: z.number().array().optional(),
discardCount: z.number().optional(),
@@ -167,7 +167,7 @@ export const TimeBasedProgramLineupSchema = z.object({
export const RandomSlotProgramLineupSchema = z.object({
type: z.literal('random'),
programs: z.array(ChannelProgramSchema),
programs: z.array(z.string()),
schedule: RandomSlotScheduleSchema,
seed: z.number().array().optional(),
discardCount: z.number().optional(),

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@ import {
import { DateTimePicker } from '@mui/x-date-pickers';
import { useQueryClient } from '@tanstack/react-query';
import { dayjsMod } from '@tunarr/shared';
import { seq } from '@tunarr/shared/util';
import type {
MaterializedTimeSlotSchedule,
TimeSlotSchedule,
@@ -199,7 +200,17 @@ export default function TimeSlotEditorPage() {
body: {
type: 'time',
schedule,
programs: filteredLineup,
programs: seq.collect(filteredLineup, (l) => {
switch (l.type) {
case 'custom':
case 'content':
return l.id;
case 'redirect':
case 'flex':
case 'filler':
return null;
}
}),
seed: randomState?.seed,
discardCount: randomState?.discardCount,
},