mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
fix: regenerate lineups when channel start times change
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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(
|
||||
|
||||
98
server/src/commands/RegenerateChannelLineupCommand.ts
Normal file
98
server/src/commands/RegenerateChannelLineupCommand.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user