mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
checkpoint
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,4 +41,4 @@ tunarr-openapi.json
|
||||
web/.tanstack
|
||||
|
||||
:memory:*
|
||||
.serena/
|
||||
.serena/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,8 +10,8 @@ import {
|
||||
BasicIdParamSchema,
|
||||
CreateInfiniteScheduleRequestSchema,
|
||||
GeneratedScheduleItemSchema,
|
||||
InfiniteScheduleGenerationResponseSchema,
|
||||
InfiniteSchedulePreviewRequestSchema,
|
||||
InfiniteSchedulePreviewResponseSchema,
|
||||
MaterializedScheduleSchema,
|
||||
ScheduleAssignChannelsResultSchema,
|
||||
ScheduleSchema,
|
||||
@@ -24,6 +24,7 @@ import { isNil, uniq } from 'lodash-es';
|
||||
import { v4 } from 'uuid';
|
||||
import { z } from 'zod/v4';
|
||||
import { MaterializeScheduleCommand } from '../commands/MaterializeScheduleCommand.ts';
|
||||
import { MaterializeScheduleGeneratedItems } from '../commands/scheduling/MaterializeScheduleGeneratedItems.ts';
|
||||
import { MaterializeScheduleGenerationResult } from '../commands/scheduling/MaterializeScheduleGenerationResult.ts';
|
||||
import { container } from '../container.ts';
|
||||
import { InfiniteScheduleGenerator } from '../services/scheduling/InfiniteScheduleGenerator.ts';
|
||||
@@ -285,7 +286,7 @@ export const infiniteScheduleApi: RouterPluginAsyncCallback = async (
|
||||
.default(() => +dayjs().add(1, 'day').startOf('day')),
|
||||
}),
|
||||
response: {
|
||||
200: InfiniteSchedulePreviewResponseSchema,
|
||||
200: InfiniteScheduleGenerationResponseSchema,
|
||||
400: BaseErrorSchema,
|
||||
404: z.void(),
|
||||
},
|
||||
@@ -623,8 +624,7 @@ export const infiniteScheduleApi: RouterPluginAsyncCallback = async (
|
||||
|
||||
// For full/buffer resets, always start from now (state was cleared or
|
||||
// is being discarded). For 'none', respect the caller-supplied fromTimeMs.
|
||||
const fromTimeMs =
|
||||
resetMode !== 'none' ? undefined : req.body.fromTimeMs;
|
||||
const fromTimeMs = resetMode !== 'none' ? undefined : req.body.fromTimeMs;
|
||||
|
||||
const generator = getGenerator();
|
||||
const result = await generator.generate(channel.uuid, fromTimeMs);
|
||||
@@ -765,9 +765,7 @@ export const infiniteScheduleApi: RouterPluginAsyncCallback = async (
|
||||
toTimeMs: z.coerce.number(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
items: z.array(GeneratedScheduleItemSchema),
|
||||
}),
|
||||
200: InfiniteScheduleGenerationResponseSchema,
|
||||
404: z.object({ error: z.string() }),
|
||||
},
|
||||
},
|
||||
@@ -791,7 +789,16 @@ export const infiniteScheduleApi: RouterPluginAsyncCallback = async (
|
||||
req.query.toTimeMs,
|
||||
);
|
||||
|
||||
return res.send({ items: items.map(generatedScheduleItemToDto) });
|
||||
const materialized = await container
|
||||
.get(MaterializeScheduleGeneratedItems)
|
||||
.run({ items });
|
||||
|
||||
return res.send({
|
||||
contentPrograms: groupByUniq(materialized, (m) => m.id!),
|
||||
fromTimeMs: req.query.fromTimeMs,
|
||||
toTimeMs: req.query.toTimeMs,
|
||||
items: items.map(generatedScheduleItemToDto),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -107,8 +107,6 @@ export class MaterializeScheduleCommand {
|
||||
return (
|
||||
match(slot)
|
||||
.returnType<MaterializedScheduleSlot | null>()
|
||||
// TODO: We probably don't want movie slots anymore
|
||||
.with({ type: 'movie' }, () => null)
|
||||
.with({ type: 'show' }, (showSlot) => {
|
||||
const materializedShow = materializedShows[showSlot.showId];
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { isNonEmptyString, seq } from '@tunarr/shared/util';
|
||||
import { ContentProgram } from '@tunarr/types';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { uniq } from 'lodash-es';
|
||||
import { ProgramConverter } from '../../db/converters/ProgramConverter.ts';
|
||||
import { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { NewGeneratedScheduleItem } from '../../db/schema/GeneratedScheduleItem.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { Command } from '../Command.ts';
|
||||
|
||||
type Request = {
|
||||
items: NewGeneratedScheduleItem[];
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class MaterializeScheduleGeneratedItems
|
||||
implements Command<Request, ContentProgram[]>
|
||||
{
|
||||
constructor(
|
||||
@inject(KEYS.Logger) private logger: Logger,
|
||||
@inject(KEYS.ProgramDB) private programDB: IProgramDB,
|
||||
@inject(ProgramConverter) private programConverter: ProgramConverter,
|
||||
) {}
|
||||
|
||||
async run({ items }: Request): Promise<ContentProgram[]> {
|
||||
const programIds = uniq(
|
||||
seq.collect(items, (item) => {
|
||||
const programId = item.programUuid;
|
||||
if (!isNonEmptyString(programId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return programId;
|
||||
}),
|
||||
);
|
||||
|
||||
if (programIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const programs = await this.programDB.getProgramsByIds(programIds);
|
||||
|
||||
const missingPrograms = new Set(programIds).difference(
|
||||
new Set(programs.map((p) => p.uuid)),
|
||||
);
|
||||
|
||||
if (missingPrograms.size > 0) {
|
||||
this.logger.warn('');
|
||||
}
|
||||
|
||||
return seq.collect(programs, (program) => {
|
||||
return this.programConverter.programOrmToContentProgram(program);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
import { isNonEmptyString, seq } from '@tunarr/shared/util';
|
||||
import { ContentProgram } from '@tunarr/types';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { uniq } from 'lodash-es';
|
||||
import { ProgramConverter } from '../../db/converters/ProgramConverter.ts';
|
||||
import { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { GenerationResult } from '../../services/scheduling/InfiniteScheduleGenerator.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { Command } from '../Command.ts';
|
||||
import { MaterializeScheduleGeneratedItems } from './MaterializeScheduleGeneratedItems.ts';
|
||||
|
||||
type Request = {
|
||||
result: GenerationResult;
|
||||
@@ -18,39 +13,11 @@ export class MaterializeScheduleGenerationResult
|
||||
implements Command<Request, ContentProgram[]>
|
||||
{
|
||||
constructor(
|
||||
@inject(KEYS.Logger) private logger: Logger,
|
||||
@inject(KEYS.ProgramDB) private programDB: IProgramDB,
|
||||
@inject(ProgramConverter) private programConverter: ProgramConverter,
|
||||
@inject(MaterializeScheduleGeneratedItems)
|
||||
private materializeItems: MaterializeScheduleGeneratedItems,
|
||||
) {}
|
||||
|
||||
async run({ result }: Request): Promise<ContentProgram[]> {
|
||||
const programIds = uniq(
|
||||
seq.collect(result.items, (item) => {
|
||||
const programId = item.programUuid;
|
||||
if (!isNonEmptyString(programId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return programId;
|
||||
}),
|
||||
);
|
||||
|
||||
if (programIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const programs = await this.programDB.getProgramsByIds(programIds);
|
||||
|
||||
const missingPrograms = new Set(programIds).difference(
|
||||
new Set(programs.map((p) => p.uuid)),
|
||||
);
|
||||
|
||||
if (missingPrograms.size > 0) {
|
||||
this.logger.warn('');
|
||||
}
|
||||
|
||||
return seq.collect(programs, (program) => {
|
||||
return this.programConverter.programOrmToContentProgram(program);
|
||||
});
|
||||
run({ result }: Request): Promise<ContentProgram[]> {
|
||||
return this.materializeItems.run({ items: result.items });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +734,22 @@ export class InfiniteScheduleDB {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all generated items (across all channels) that have fully aired
|
||||
* before the given time. Useful for periodic global cleanup.
|
||||
*/
|
||||
async deleteAllGeneratedItemsBefore(beforeTimeMs: number): Promise<number> {
|
||||
const result = await this.drizzle
|
||||
.delete(GeneratedScheduleItem)
|
||||
.where(
|
||||
lt(
|
||||
sql`${GeneratedScheduleItem.startTimeMs} + ${GeneratedScheduleItem.durationMs}`,
|
||||
beforeTimeMs,
|
||||
),
|
||||
);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all generated items for a channel
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ProgramGrouping } from './ProgramGrouping.ts';
|
||||
import { SmartCollection } from './SmartCollection.ts';
|
||||
|
||||
export const InfiniteSlotTypes = [
|
||||
'movie',
|
||||
'show',
|
||||
'custom-show',
|
||||
'filler',
|
||||
@@ -70,6 +69,8 @@ export const InfiniteScheduleSlot = sqliteTable(
|
||||
scheduleUuid: text()
|
||||
.notNull()
|
||||
.references(() => InfiniteSchedule.uuid, { onDelete: 'cascade' }),
|
||||
|
||||
// Generally only used for ordered playback mode
|
||||
slotIndex: integer().notNull(),
|
||||
slotType: text({ enum: InfiniteSlotTypes }).notNull(),
|
||||
|
||||
@@ -98,7 +99,7 @@ export const InfiniteScheduleSlot = sqliteTable(
|
||||
anchorMode: text({ enum: AnchorModes }),
|
||||
anchorDays: text({ mode: 'json' }).$type<number[]>(), // Days of week [0-6]
|
||||
|
||||
// For floating slots
|
||||
// For floating slots used in shuffle mode
|
||||
weight: integer().default(1).notNull(),
|
||||
cooldownMs: integer().default(0).notNull(),
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 3. Timezone / DST — UTC-offset math, spring-forward, fall-back
|
||||
* 4. Filler injection — relaxed/strict pre/head/tail, fallback filler
|
||||
* 5. State persistence — second preview run continues from saved iterator state
|
||||
* 6. Slot editing — changing/adding/removing slots while preserving other slot state
|
||||
*
|
||||
* The `preview()` method drives all tests because it takes a fully-constructed
|
||||
* schedule object and never touches the database, so no InfiniteScheduleDB mock
|
||||
@@ -137,6 +138,29 @@ function makeSchedule(
|
||||
};
|
||||
}
|
||||
|
||||
/** Promote a SlotStateUpdate back into the InfiniteScheduleSlotState shape. */
|
||||
function restoreSlotState(
|
||||
slot: InfiniteScheduleSlot,
|
||||
saved: SlotStateUpdate,
|
||||
): InfiniteScheduleSlotState {
|
||||
return {
|
||||
uuid: v4(),
|
||||
channelUuid: 'test-channel',
|
||||
slotUuid: slot.uuid,
|
||||
rngSeed: saved.rngSeed,
|
||||
rngUseCount: saved.rngUseCount,
|
||||
iteratorPosition: saved.iteratorPosition,
|
||||
shuffleOrder: saved.shuffleOrder ?? null,
|
||||
fillModeCount: saved.fillModeCount,
|
||||
fillModeDurationMs: saved.fillModeDurationMs,
|
||||
fillerState: saved.fillerState,
|
||||
lastScheduledAt: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ── Helper mock factory ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -909,10 +933,10 @@ describe('InfiniteScheduleGenerator', () => {
|
||||
fillerConfig: {
|
||||
fillers: [
|
||||
{
|
||||
types: ['pre'],
|
||||
type: 'pre',
|
||||
fillerListId,
|
||||
fillerOrder: 'uniform',
|
||||
mode: 'relaxed',
|
||||
playbackMode: { type: 'relaxed' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -947,10 +971,10 @@ describe('InfiniteScheduleGenerator', () => {
|
||||
fillerConfig: {
|
||||
fillers: [
|
||||
{
|
||||
types: ['pre'],
|
||||
type: 'pre',
|
||||
fillerListId,
|
||||
fillerOrder: 'uniform',
|
||||
mode: 'relaxed',
|
||||
playbackMode: { type: 'relaxed' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -982,11 +1006,10 @@ describe('InfiniteScheduleGenerator', () => {
|
||||
fillerConfig: {
|
||||
fillers: [
|
||||
{
|
||||
types: ['pre'],
|
||||
type: 'pre',
|
||||
fillerListId,
|
||||
fillerOrder: 'uniform',
|
||||
mode: 'strict',
|
||||
count: 2,
|
||||
playbackMode: { type: 'count', count: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1029,11 +1052,10 @@ describe('InfiniteScheduleGenerator', () => {
|
||||
fillerConfig: {
|
||||
fillers: [
|
||||
{
|
||||
types: ['head'],
|
||||
type: 'head',
|
||||
fillerListId,
|
||||
fillerOrder: 'uniform',
|
||||
mode: 'strict',
|
||||
count: 1,
|
||||
playbackMode: { type: 'count', count: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1071,30 +1093,6 @@ describe('InfiniteScheduleGenerator', () => {
|
||||
|
||||
// ── 11. Shuffle mode — new content discovery ─────────────────────────────────
|
||||
describe('shuffle mode — new content discovery', () => {
|
||||
/**
|
||||
* Helper that promotes a SlotStateUpdate back into the
|
||||
* InfiniteScheduleSlotState shape expected by the slot.
|
||||
*/
|
||||
function restoreSlotState(
|
||||
slot: InfiniteScheduleSlot,
|
||||
saved: SlotStateUpdate,
|
||||
): InfiniteScheduleSlotState {
|
||||
return {
|
||||
uuid: v4(),
|
||||
channelUuid: 'ch',
|
||||
slotUuid: slot.uuid,
|
||||
rngSeed: saved.rngSeed,
|
||||
rngUseCount: saved.rngUseCount,
|
||||
iteratorPosition: saved.iteratorPosition,
|
||||
shuffleOrder: saved.shuffleOrder ?? null,
|
||||
fillModeCount: saved.fillModeCount,
|
||||
fillModeDurationMs: saved.fillModeDurationMs,
|
||||
fillerState: saved.fillerState,
|
||||
lastScheduledAt: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
it('list grows: new program appears in the current pass and existing shuffle order is preserved', async () => {
|
||||
const showId = 'show-a';
|
||||
@@ -1212,22 +1210,7 @@ describe('InfiniteScheduleGenerator', () => {
|
||||
const savedState = run1.slotStates.get(slot.uuid);
|
||||
expect(savedState).toBeDefined();
|
||||
|
||||
// Reconstruct slot state from the saved SlotStateUpdate
|
||||
const restoredSlotState: InfiniteScheduleSlotState = {
|
||||
uuid: v4(),
|
||||
channelUuid: 'test-channel',
|
||||
slotUuid: slot.uuid,
|
||||
rngSeed: savedState!.rngSeed,
|
||||
rngUseCount: savedState!.rngUseCount,
|
||||
iteratorPosition: savedState!.iteratorPosition,
|
||||
shuffleOrder: savedState!.shuffleOrder ?? null,
|
||||
fillModeCount: savedState!.fillModeCount,
|
||||
fillModeDurationMs: savedState!.fillModeDurationMs,
|
||||
fillerState: savedState!.fillerState,
|
||||
lastScheduledAt: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
const restoredSlotState = restoreSlotState(slot, savedState!);
|
||||
|
||||
// Run 2: generate the next 3 hours with restored state
|
||||
const run2 = await makeGenerator(helper).preview(
|
||||
@@ -1244,4 +1227,515 @@ describe('InfiniteScheduleGenerator', () => {
|
||||
expect(run2Uuids).toEqual(['ep-3', 'ep-4', 'ep-5']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 13. Slot editing ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// These tests document what happens to ongoing schedule generation when the
|
||||
// user edits the schedule's slot configuration.
|
||||
//
|
||||
// Key invariants:
|
||||
// • Each slot's iterator state is stored under its UUID. Editing one slot's
|
||||
// config must not alter the saved state of any other slot.
|
||||
// • preview() always starts with floatingSlotIndex=0 (schedule-level rotation
|
||||
// position is not persisted in preview). Only slot-level state (iterator
|
||||
// position, shuffle order, fill progress) is restored from `slot.state`.
|
||||
describe('slot editing — ordered mode', () => {
|
||||
it("changing one slot's fill mode does not affect the other slot's iterator position", async () => {
|
||||
const showA = 'show-a';
|
||||
const showB = 'show-b';
|
||||
const programsA = makePrograms(6, { prefix: 'ep' });
|
||||
const programsB = makePrograms(6, { prefix: 'movie' });
|
||||
|
||||
// Both slots start in 'fill' mode.
|
||||
const slotA = makeCustomShowSlot(showA, { fillMode: 'fill' });
|
||||
const slotB = makeCustomShowSlot(showB, { fillMode: 'fill' });
|
||||
const helper = makeHelper({ [showA]: programsA, [showB]: programsB });
|
||||
|
||||
// Run 1: 2-hour window. slotA fires for 1 h (ep-0), slotB fires for 1 h
|
||||
// (movie-0). Both iterator positions advance to index 1.
|
||||
const run1 = await makeGenerator(helper).preview(
|
||||
makeSchedule([slotA, slotB]),
|
||||
0,
|
||||
2 * HOUR_MS,
|
||||
);
|
||||
|
||||
expect(contentItems(run1).map((i) => i.programUuid)).toEqual([
|
||||
'ep-0',
|
||||
'movie-0',
|
||||
]);
|
||||
|
||||
const savedA = run1.slotStates.get(slotA.uuid)!;
|
||||
const savedB = run1.slotStates.get(slotB.uuid)!;
|
||||
|
||||
// User edits slotA: switch fill mode from 'fill' → 'count' (1 program per
|
||||
// rotation). The iterator position (ep-1) must survive the config change.
|
||||
const editedSlotA = {
|
||||
...slotA,
|
||||
fillMode: 'count' as const,
|
||||
fillValue: 1,
|
||||
state: restoreSlotState(slotA, savedA),
|
||||
};
|
||||
const restoredSlotB = { ...slotB, state: restoreSlotState(slotB, savedB) };
|
||||
|
||||
// Run 2: 4-hour window with restored states.
|
||||
// 'count' mode (fillValue=1) causes slotA and slotB to alternate every
|
||||
// program → [ep-1, movie-1, ep-2, movie-2].
|
||||
// preview() starts with floatingSlotIndex=0 (schedule-level rotation is
|
||||
// not persisted in preview), so both slots start from position 0 in the
|
||||
// rotation — the iterator positions (ep-1, movie-1) are the saved state.
|
||||
const run2 = await makeGenerator(helper).preview(
|
||||
makeSchedule([editedSlotA, restoredSlotB]),
|
||||
2 * HOUR_MS,
|
||||
6 * HOUR_MS,
|
||||
);
|
||||
|
||||
const run2UUIDs = contentItems(run2).map((i) => i.programUuid);
|
||||
|
||||
// Iterator positions were preserved: both slots start from index 1.
|
||||
expect(run2UUIDs).not.toContain('ep-0');
|
||||
expect(run2UUIDs).not.toContain('movie-0');
|
||||
expect(run2UUIDs).toContain('ep-1');
|
||||
expect(run2UUIDs).toContain('movie-1');
|
||||
|
||||
// Config change took effect: slots alternate every program.
|
||||
expect(run2UUIDs).toEqual(['ep-1', 'movie-1', 'ep-2', 'movie-2']);
|
||||
|
||||
assertContinuous(run2.items);
|
||||
assertWindowCovered(run2);
|
||||
});
|
||||
|
||||
it('adding a new slot starts it at position 0 while existing slots continue', async () => {
|
||||
const showA = 'show-a';
|
||||
const showB = 'show-b';
|
||||
const showC = 'show-c';
|
||||
const programsA = makePrograms(4, { prefix: 'ep' });
|
||||
const programsB = makePrograms(4, { prefix: 'movie' });
|
||||
const programsC = makePrograms(4, { prefix: 'doc' });
|
||||
|
||||
const slotA = makeCustomShowSlot(showA);
|
||||
const slotB = makeCustomShowSlot(showB);
|
||||
const helper = makeHelper({
|
||||
[showA]: programsA,
|
||||
[showB]: programsB,
|
||||
[showC]: programsC,
|
||||
});
|
||||
|
||||
// Run 1: 2-hour window → slotA emits ep-0, slotB emits movie-0.
|
||||
const run1 = await makeGenerator(helper).preview(
|
||||
makeSchedule([slotA, slotB]),
|
||||
0,
|
||||
2 * HOUR_MS,
|
||||
);
|
||||
|
||||
expect(contentItems(run1).map((i) => i.programUuid)).toEqual([
|
||||
'ep-0',
|
||||
'movie-0',
|
||||
]);
|
||||
|
||||
const savedA = run1.slotStates.get(slotA.uuid)!;
|
||||
const savedB = run1.slotStates.get(slotB.uuid)!;
|
||||
|
||||
// User adds a brand-new slot C. It has no saved state (state: null).
|
||||
const slotC = makeCustomShowSlot(showC);
|
||||
const restoredSlotA = { ...slotA, state: restoreSlotState(slotA, savedA) };
|
||||
const restoredSlotB = { ...slotB, state: restoreSlotState(slotB, savedB) };
|
||||
|
||||
// Run 2: 3-hour window with slots [A, B, C].
|
||||
// preview() starts with floatingSlotIndex=0, so rotation is:
|
||||
// 0 % 3 = 0 → slot A fires first → ep-1 (continues from position 1)
|
||||
// 1 % 3 = 1 → slot B fires next → movie-1 (continues from position 1)
|
||||
// 2 % 3 = 2 → slot C fires last → doc-0 (fresh state, position 0)
|
||||
const run2 = await makeGenerator(helper).preview(
|
||||
makeSchedule([restoredSlotA, restoredSlotB, slotC]),
|
||||
2 * HOUR_MS,
|
||||
5 * HOUR_MS,
|
||||
);
|
||||
|
||||
const run2UUIDs = contentItems(run2).map((i) => i.programUuid);
|
||||
|
||||
// New slot C starts from the beginning (doc-0, not doc-1).
|
||||
expect(run2UUIDs).toContain('doc-0');
|
||||
expect(run2UUIDs).not.toContain('doc-1');
|
||||
|
||||
// Existing slots A and B continue from their saved positions.
|
||||
expect(run2UUIDs).toContain('ep-1');
|
||||
expect(run2UUIDs).not.toContain('ep-0');
|
||||
expect(run2UUIDs).toContain('movie-1');
|
||||
expect(run2UUIDs).not.toContain('movie-0');
|
||||
|
||||
expect(run2UUIDs).toEqual(['ep-1', 'movie-1', 'doc-0']);
|
||||
|
||||
assertContinuous(run2.items);
|
||||
assertWindowCovered(run2);
|
||||
});
|
||||
|
||||
it('removing a slot discards its state; remaining slots continue from their saved positions', async () => {
|
||||
const showA = 'show-a';
|
||||
const showB = 'show-b';
|
||||
const showC = 'show-c';
|
||||
const programsA = makePrograms(4, { prefix: 'ep' });
|
||||
const programsB = makePrograms(4, { prefix: 'movie' });
|
||||
const programsC = makePrograms(4, { prefix: 'doc' });
|
||||
|
||||
const slotA = makeCustomShowSlot(showA);
|
||||
const slotB = makeCustomShowSlot(showB);
|
||||
const slotC = makeCustomShowSlot(showC);
|
||||
const helper = makeHelper({
|
||||
[showA]: programsA,
|
||||
[showB]: programsB,
|
||||
[showC]: programsC,
|
||||
});
|
||||
|
||||
// Run 1: 3-hour window. Each slot fires once in order:
|
||||
// slotA → ep-0, slotB → movie-0, slotC → doc-0.
|
||||
const run1 = await makeGenerator(helper).preview(
|
||||
makeSchedule([slotA, slotB, slotC]),
|
||||
0,
|
||||
3 * HOUR_MS,
|
||||
);
|
||||
|
||||
expect(contentItems(run1).map((i) => i.programUuid)).toEqual([
|
||||
'ep-0',
|
||||
'movie-0',
|
||||
'doc-0',
|
||||
]);
|
||||
|
||||
const savedA = run1.slotStates.get(slotA.uuid)!;
|
||||
const savedC = run1.slotStates.get(slotC.uuid)!;
|
||||
// slotB's state is intentionally not restored — the slot is being removed.
|
||||
|
||||
const restoredSlotA = { ...slotA, state: restoreSlotState(slotA, savedA) };
|
||||
const restoredSlotC = { ...slotC, state: restoreSlotState(slotC, savedC) };
|
||||
|
||||
// Run 2: 2-hour window with slotB removed; schedule has slots [A, C].
|
||||
// preview() starts with floatingSlotIndex=0:
|
||||
// 0 % 2 = 0 → slot A fires first → ep-1 (continues from position 1)
|
||||
// 1 % 2 = 1 → slot C fires next → doc-1 (continues from position 1)
|
||||
const run2 = await makeGenerator(helper).preview(
|
||||
makeSchedule([restoredSlotA, restoredSlotC]),
|
||||
3 * HOUR_MS,
|
||||
5 * HOUR_MS,
|
||||
);
|
||||
|
||||
const run2UUIDs = contentItems(run2).map((i) => i.programUuid);
|
||||
|
||||
// Removed slot B must not appear at all.
|
||||
expect(run2UUIDs).not.toContain('movie-0');
|
||||
expect(run2UUIDs).not.toContain('movie-1');
|
||||
|
||||
// Slots A and C continue from their saved positions (index 1).
|
||||
expect(run2UUIDs).not.toContain('ep-0');
|
||||
expect(run2UUIDs).not.toContain('doc-0');
|
||||
expect(run2UUIDs).toContain('ep-1');
|
||||
expect(run2UUIDs).toContain('doc-1');
|
||||
|
||||
expect(run2UUIDs).toEqual(['ep-1', 'doc-1']);
|
||||
|
||||
assertContinuous(run2.items);
|
||||
assertWindowCovered(run2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 14. Ordered mode — new content discovery ─────────────────────────────────
|
||||
//
|
||||
// Unlike shuffle mode, ordered ('next') iteration has no UUID-based
|
||||
// reconciliation: the saved position is a raw index applied to whatever array
|
||||
// the helper returns on the next run. List mutations can therefore cause
|
||||
// skips or replays that wouldn't occur in shuffle mode.
|
||||
describe('ordered mode — new content discovery', () => {
|
||||
it('list grows: appending a program does not disrupt the saved iterator position', async () => {
|
||||
const showId = 'show-a';
|
||||
const programs = makePrograms(3, { prefix: 'ep' });
|
||||
const slot = makeCustomShowSlot(showId);
|
||||
|
||||
// Run 1: 1-hour window → ep-0 emitted; iterator advances to position 1.
|
||||
const run1 = await makeGenerator(makeHelper({ [showId]: programs })).preview(
|
||||
makeSchedule([slot]),
|
||||
0,
|
||||
HOUR_MS,
|
||||
);
|
||||
|
||||
expect(contentItems(run1).map((i) => i.programUuid)).toEqual(['ep-0']);
|
||||
const savedState = run1.slotStates.get(slot.uuid)!;
|
||||
|
||||
// Append a new program. The existing position 1 still points to ep-1.
|
||||
const ep3 = createFakeProgramOrm({
|
||||
uuid: 'ep-3',
|
||||
type: 'movie',
|
||||
duration: HOUR_MS,
|
||||
});
|
||||
const expandedPrograms = [...programs, ep3];
|
||||
|
||||
// Run 2: 3-hour window, position 1 → [ep-1, ep-2, ep-3].
|
||||
const run2 = await makeGenerator(
|
||||
makeHelper({ [showId]: expandedPrograms }),
|
||||
).preview(
|
||||
makeSchedule([{ ...slot, state: restoreSlotState(slot, savedState) }]),
|
||||
HOUR_MS,
|
||||
4 * HOUR_MS,
|
||||
);
|
||||
|
||||
const run2UUIDs = contentItems(run2).map((i) => i.programUuid);
|
||||
|
||||
// Iterator is unaffected: ep-1 is still the first program in run 2.
|
||||
expect(run2UUIDs[0]).toBe('ep-1');
|
||||
// The new program appears naturally after the existing ones cycle through.
|
||||
expect(run2UUIDs).toEqual(['ep-1', 'ep-2', 'ep-3']);
|
||||
// ep-0 was already emitted and is not replayed.
|
||||
expect(run2UUIDs).not.toContain('ep-0');
|
||||
|
||||
assertContinuous(run2.items);
|
||||
assertWindowCovered(run2);
|
||||
});
|
||||
|
||||
it('list shrinks: removing a program before the iterator position wraps the index, replaying an already-emitted program', async () => {
|
||||
const showId = 'show-a';
|
||||
const programs = makePrograms(3, { prefix: 'ep' });
|
||||
const slot = makeCustomShowSlot(showId);
|
||||
|
||||
// Run 1: 2-hour window → ep-0 and ep-1 emitted. Iterator ends at
|
||||
// position 2, which would point to ep-2 on the next run.
|
||||
const run1 = await makeGenerator(makeHelper({ [showId]: programs })).preview(
|
||||
makeSchedule([slot]),
|
||||
0,
|
||||
2 * HOUR_MS,
|
||||
);
|
||||
|
||||
const run1UUIDs = contentItems(run1).map((i) => i.programUuid);
|
||||
expect(run1UUIDs).toEqual(['ep-0', 'ep-1']);
|
||||
|
||||
const savedState = run1.slotStates.get(slot.uuid)!;
|
||||
expect(savedState.iteratorPosition).toBe(2);
|
||||
|
||||
// Remove ep-0 from the list. List is now [ep-1, ep-2] (length 2).
|
||||
// There is no UUID-based reconciliation in ordered mode — the raw
|
||||
// position 2 is applied to the new array: 2 % 2 = 0 → ep-1.
|
||||
const shrunkPrograms = programs.filter((p) => p.uuid !== 'ep-0');
|
||||
|
||||
const run2 = await makeGenerator(
|
||||
makeHelper({ [showId]: shrunkPrograms }),
|
||||
).preview(
|
||||
makeSchedule([{ ...slot, state: restoreSlotState(slot, savedState) }]),
|
||||
2 * HOUR_MS,
|
||||
4 * HOUR_MS,
|
||||
);
|
||||
|
||||
const run2UUIDs = contentItems(run2).map((i) => i.programUuid);
|
||||
|
||||
// ep-1 is replayed: it appeared in run 1 and is also the first program
|
||||
// in run 2 (position wrapped to 0 → ep-1 rather than advancing to ep-2).
|
||||
expect(run1UUIDs).toContain('ep-1');
|
||||
expect(run2UUIDs[0]).toBe('ep-1');
|
||||
|
||||
// ep-2 still appears — it is not permanently lost, just delayed.
|
||||
expect(run2UUIDs).toContain('ep-2');
|
||||
|
||||
// ep-0 does not appear: it was removed from the list.
|
||||
expect(run2UUIDs).not.toContain('ep-0');
|
||||
|
||||
assertContinuous(run2.items);
|
||||
assertWindowCovered(run2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 15. Slot editing — shuffle mode ──────────────────────────────────────────
|
||||
//
|
||||
// The slot-state invariants from section 13 hold equally in shuffle mode:
|
||||
// each slot's iterator state is keyed by UUID and is unaffected by edits to
|
||||
// other slots. These tests demonstrate the same three scenarios (config
|
||||
// change, add slot, remove slot) under schedule-level random slot selection.
|
||||
//
|
||||
// Because slot selection is non-deterministic, assertions use slotUuid
|
||||
// filtering to isolate each slot's program subsequence. Within-slot order
|
||||
// is always sequential for `order: 'next'` regardless of which slot the
|
||||
// schedule randomly selects next.
|
||||
describe('slot editing — shuffle mode', () => {
|
||||
it("changing one slot's config does not affect the other slot's iterator position", async () => {
|
||||
// Use a two-slot shuffle schedule. Assertions are per-slot via slotUuid
|
||||
// filtering so they hold regardless of which slot the RNG selects next.
|
||||
const showA = 'show-a';
|
||||
const showB = 'show-b';
|
||||
// Large program pool (40) ensures the cycle never wraps back to position 0
|
||||
// within either run window (max ~10 fires per slot per window).
|
||||
const programsA = makePrograms(40, { prefix: 'ep' });
|
||||
const programsB = makePrograms(40, { prefix: 'movie' });
|
||||
|
||||
const slotA = makeCustomShowSlot(showA, { fillMode: 'fill' });
|
||||
const slotB = makeCustomShowSlot(showB, { fillMode: 'fill' });
|
||||
const helper = makeHelper({ [showA]: programsA, [showB]: programsB });
|
||||
|
||||
// Run 1: 20-hour window so both slots are near-certain to fire multiple
|
||||
// times (P(either slot fires 0 times) ≈ 2 × 2^-20 ≈ negligible).
|
||||
const run1 = await makeGenerator(helper).preview(
|
||||
makeSchedule([slotA, slotB], { slotPlaybackOrder: 'shuffle' }),
|
||||
0,
|
||||
20 * HOUR_MS,
|
||||
);
|
||||
|
||||
const savedA = run1.slotStates.get(slotA.uuid);
|
||||
const savedB = run1.slotStates.get(slotB.uuid);
|
||||
expect(savedA).toBeDefined();
|
||||
expect(savedB).toBeDefined();
|
||||
|
||||
// Edit: change slotA's fill mode. Iterator positions must survive.
|
||||
const editedSlotA = {
|
||||
...slotA,
|
||||
fillMode: 'count' as const,
|
||||
fillValue: 1,
|
||||
state: restoreSlotState(slotA, savedA!),
|
||||
};
|
||||
const restoredSlotB = {
|
||||
...slotB,
|
||||
state: restoreSlotState(slotB, savedB!),
|
||||
};
|
||||
|
||||
// Run 2: 20-hour window.
|
||||
const run2 = await makeGenerator(helper).preview(
|
||||
makeSchedule([editedSlotA, restoredSlotB], {
|
||||
slotPlaybackOrder: 'shuffle',
|
||||
}),
|
||||
20 * HOUR_MS,
|
||||
40 * HOUR_MS,
|
||||
);
|
||||
|
||||
// Filter run 2 results by slot to get each slot's independent sequence.
|
||||
const run2AProgs = contentItems(run2).filter(
|
||||
(i) => i.slotUuid === slotA.uuid,
|
||||
);
|
||||
const run2BProgs = contentItems(run2).filter(
|
||||
(i) => i.slotUuid === slotB.uuid,
|
||||
);
|
||||
|
||||
// The first program A emits in run 2 must be at its saved position.
|
||||
const savedPosA = savedA!.iteratorPosition;
|
||||
expect(run2AProgs.length).toBeGreaterThan(0);
|
||||
expect(run2AProgs[0]!.programUuid).toBe(`ep-${savedPosA}`);
|
||||
|
||||
// Slot B's iterator is unaffected by the edit to slot A.
|
||||
const savedPosB = savedB!.iteratorPosition;
|
||||
expect(run2BProgs.length).toBeGreaterThan(0);
|
||||
expect(run2BProgs[0]!.programUuid).toBe(`movie-${savedPosB}`);
|
||||
});
|
||||
|
||||
it('adding a new slot starts it at position 0 while existing slots continue', async () => {
|
||||
const showA = 'show-a';
|
||||
const showC = 'show-c';
|
||||
const programsA = makePrograms(40, { prefix: 'ep' });
|
||||
const programsC = makePrograms(40, { prefix: 'doc' });
|
||||
|
||||
const slotA = makeCustomShowSlot(showA);
|
||||
const helper = makeHelper({ [showA]: programsA, [showC]: programsC });
|
||||
|
||||
// Run 1: single-slot shuffle schedule → deterministic, only slotA fires.
|
||||
// 3-hour window: ep-0, ep-1, ep-2 emitted; iterator ends at position 3.
|
||||
const run1 = await makeGenerator(helper).preview(
|
||||
makeSchedule([slotA], { slotPlaybackOrder: 'shuffle' }),
|
||||
0,
|
||||
3 * HOUR_MS,
|
||||
);
|
||||
|
||||
expect(contentItems(run1).map((i) => i.programUuid)).toEqual([
|
||||
'ep-0',
|
||||
'ep-1',
|
||||
'ep-2',
|
||||
]);
|
||||
|
||||
const savedA = run1.slotStates.get(slotA.uuid)!;
|
||||
expect(savedA.iteratorPosition).toBe(3);
|
||||
|
||||
// Add brand-new slot C (no state).
|
||||
const slotC = makeCustomShowSlot(showC);
|
||||
const restoredSlotA = { ...slotA, state: restoreSlotState(slotA, savedA) };
|
||||
|
||||
// Run 2: 20-hour window with [A, C]. Both slots fire many times.
|
||||
const run2 = await makeGenerator(helper).preview(
|
||||
makeSchedule([restoredSlotA, slotC], { slotPlaybackOrder: 'shuffle' }),
|
||||
3 * HOUR_MS,
|
||||
23 * HOUR_MS,
|
||||
);
|
||||
|
||||
const run2AProgs = contentItems(run2).filter(
|
||||
(i) => i.slotUuid === slotA.uuid,
|
||||
);
|
||||
const run2CProgs = contentItems(run2).filter(
|
||||
(i) => i.slotUuid === slotC.uuid,
|
||||
);
|
||||
|
||||
// Slot A continues from position 3: first program is ep-3.
|
||||
expect(run2AProgs.length).toBeGreaterThan(0);
|
||||
expect(run2AProgs[0]!.programUuid).toBe('ep-3');
|
||||
expect(run2AProgs.map((i) => i.programUuid)).not.toContain('ep-0');
|
||||
|
||||
// Slot C starts fresh at position 0: first program is doc-0.
|
||||
expect(run2CProgs.length).toBeGreaterThan(0);
|
||||
expect(run2CProgs[0]!.programUuid).toBe('doc-0');
|
||||
});
|
||||
|
||||
it('removing a slot discards its state; remaining slots continue from their saved positions', async () => {
|
||||
const showA = 'show-a';
|
||||
const showB = 'show-b';
|
||||
const showC = 'show-c';
|
||||
const programsA = makePrograms(40, { prefix: 'ep' });
|
||||
const programsB = makePrograms(40, { prefix: 'movie' });
|
||||
const programsC = makePrograms(40, { prefix: 'doc' });
|
||||
|
||||
const slotA = makeCustomShowSlot(showA);
|
||||
const slotB = makeCustomShowSlot(showB);
|
||||
const slotC = makeCustomShowSlot(showC);
|
||||
const helper = makeHelper({
|
||||
[showA]: programsA,
|
||||
[showB]: programsB,
|
||||
[showC]: programsC,
|
||||
});
|
||||
|
||||
// Run 1: 30-hour window, three-slot shuffle schedule. All three slots
|
||||
// fire many times (~10 each), advancing their iterator positions well
|
||||
// past 0 so the saved positions are meaningful in run 2.
|
||||
const run1 = await makeGenerator(helper).preview(
|
||||
makeSchedule([slotA, slotB, slotC], { slotPlaybackOrder: 'shuffle' }),
|
||||
0,
|
||||
30 * HOUR_MS,
|
||||
);
|
||||
|
||||
const savedA = run1.slotStates.get(slotA.uuid)!;
|
||||
const savedC = run1.slotStates.get(slotC.uuid)!;
|
||||
// slotB's state is intentionally discarded — the slot is being removed.
|
||||
|
||||
const restoredSlotA = { ...slotA, state: restoreSlotState(slotA, savedA) };
|
||||
const restoredSlotC = { ...slotC, state: restoreSlotState(slotC, savedC) };
|
||||
|
||||
// Run 2: 20-hour window with slotB removed. A and C continue from their
|
||||
// saved positions.
|
||||
const run2 = await makeGenerator(helper).preview(
|
||||
makeSchedule([restoredSlotA, restoredSlotC], {
|
||||
slotPlaybackOrder: 'shuffle',
|
||||
}),
|
||||
30 * HOUR_MS,
|
||||
50 * HOUR_MS,
|
||||
);
|
||||
|
||||
const run2AProgs = contentItems(run2).filter(
|
||||
(i) => i.slotUuid === slotA.uuid,
|
||||
);
|
||||
const run2BProgs = contentItems(run2).filter(
|
||||
(i) => i.slotUuid === slotB.uuid,
|
||||
);
|
||||
const run2CProgs = contentItems(run2).filter(
|
||||
(i) => i.slotUuid === slotC.uuid,
|
||||
);
|
||||
|
||||
// Removed slot B must not appear at all.
|
||||
expect(run2BProgs).toHaveLength(0);
|
||||
|
||||
// Slot A's first program in run 2 is exactly at its saved position.
|
||||
const savedPosA = savedA.iteratorPosition;
|
||||
expect(run2AProgs.length).toBeGreaterThan(0);
|
||||
expect(run2AProgs[0]!.programUuid).toBe(`ep-${savedPosA}`);
|
||||
|
||||
// Slot C's first program in run 2 is exactly at its saved position.
|
||||
const savedPosC = savedC.iteratorPosition;
|
||||
expect(run2CProgs.length).toBeGreaterThan(0);
|
||||
expect(run2CProgs[0]!.programUuid).toBe(`doc-${savedPosC}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,7 +277,6 @@ export class InfiniteScheduleGenerator {
|
||||
);
|
||||
return Object.values(documents).flat();
|
||||
}
|
||||
case 'movie': // Not supported currently
|
||||
case 'redirect':
|
||||
case 'flex':
|
||||
return [];
|
||||
@@ -953,89 +952,51 @@ export class InfiniteScheduleGenerator {
|
||||
|
||||
// ── Pre-content filler injection ──────────────────────────────────────
|
||||
|
||||
// (1) Strict head — fires once before the first content item in a run
|
||||
// (1/2) Head — fires once before the first content item in a run
|
||||
if (fillerHelper && isFirstInRun) {
|
||||
const mc = fillerHelper.modeAndCount('head');
|
||||
if (mc?.mode === 'strict') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'head',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
true,
|
||||
mc.count,
|
||||
0,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
const pm = fillerHelper.getPlaybackMode('head');
|
||||
if (pm) {
|
||||
const isRelaxed = pm.type === 'relaxed';
|
||||
if (!isRelaxed || flexBudget > 0) {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'head',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
pm,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
if (isRelaxed) flexBudget -= r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Relaxed head — fires within the flex budget before the first content item
|
||||
if (fillerHelper && isFirstInRun && flexBudget > 0) {
|
||||
const mc = fillerHelper.modeAndCount('head');
|
||||
if (mc?.mode === 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'head',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
false,
|
||||
1,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
flexBudget -= r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Strict pre — fires before each content item
|
||||
// (3/4) Pre — fires before each content item
|
||||
if (fillerHelper) {
|
||||
const mc = fillerHelper.modeAndCount('pre');
|
||||
if (mc?.mode === 'strict') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'pre',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
true,
|
||||
mc.count,
|
||||
0,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// (4) Relaxed pre — fires within flex budget before each content item
|
||||
if (fillerHelper && flexBudget > 0) {
|
||||
const mc = fillerHelper.modeAndCount('pre');
|
||||
if (mc?.mode === 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'pre',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
false,
|
||||
1,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
flexBudget -= r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
const pm = fillerHelper.getPlaybackMode('pre');
|
||||
if (pm) {
|
||||
const isRelaxed = pm.type === 'relaxed';
|
||||
if (!isRelaxed || flexBudget > 0) {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'pre',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
pm,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
if (isRelaxed) flexBudget -= r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1076,18 +1037,17 @@ export class InfiniteScheduleGenerator {
|
||||
|
||||
// ── Post-content filler injection (relaxed — consume from flex budget) ─
|
||||
|
||||
// (7) Relaxed post
|
||||
// (7) Post (relaxed)
|
||||
if (fillerHelper && flexBudget > 0) {
|
||||
const mc = fillerHelper.modeAndCount('post');
|
||||
if (mc?.mode === 'relaxed') {
|
||||
const pm = fillerHelper.getPlaybackMode('post');
|
||||
if (pm?.type === 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'post',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
false,
|
||||
1,
|
||||
pm,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
@@ -1098,18 +1058,17 @@ export class InfiniteScheduleGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
// (8) Relaxed tail — fires within flex budget after the last content item
|
||||
// (8) Tail (relaxed) — fires within flex budget after the last content item
|
||||
if (fillerHelper && isLastInRun && flexBudget > 0) {
|
||||
const mc = fillerHelper.modeAndCount('tail');
|
||||
if (mc?.mode === 'relaxed') {
|
||||
const pm = fillerHelper.getPlaybackMode('tail');
|
||||
if (pm?.type === 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'tail',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
false,
|
||||
1,
|
||||
pm,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
@@ -1153,20 +1112,19 @@ export class InfiniteScheduleGenerator {
|
||||
currentTimeMs += flexBudget;
|
||||
}
|
||||
|
||||
// ── Post-content filler injection (strict — unconditionally advances time) ─
|
||||
// ── Post-content filler injection (non-relaxed — unconditionally advances time) ─
|
||||
|
||||
// (11) Strict post
|
||||
// (11) Post (non-relaxed)
|
||||
if (fillerHelper) {
|
||||
const mc = fillerHelper.modeAndCount('post');
|
||||
if (mc?.mode === 'strict') {
|
||||
const pm = fillerHelper.getPlaybackMode('post');
|
||||
if (pm && pm.type !== 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'post',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
true,
|
||||
mc.count,
|
||||
pm,
|
||||
0,
|
||||
sequenceIndex,
|
||||
);
|
||||
@@ -1176,18 +1134,17 @@ export class InfiniteScheduleGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
// (12) Strict tail — fires unconditionally after the last content item
|
||||
// (12) Tail (non-relaxed) — fires unconditionally after the last content item
|
||||
if (fillerHelper && isLastInRun) {
|
||||
const mc = fillerHelper.modeAndCount('tail');
|
||||
if (mc?.mode === 'strict') {
|
||||
const pm = fillerHelper.getPlaybackMode('tail');
|
||||
if (pm && pm.type !== 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'tail',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
true,
|
||||
mc.count,
|
||||
pm,
|
||||
0,
|
||||
sequenceIndex,
|
||||
);
|
||||
@@ -1508,89 +1465,51 @@ export class InfiniteScheduleGenerator {
|
||||
|
||||
// ── Pre-content filler (shuffle mode: head/pre fire around every program) ─
|
||||
|
||||
// (1) Strict head
|
||||
// (1/2) Head
|
||||
if (fillerHelper && isFirstInRun) {
|
||||
const mc = fillerHelper.modeAndCount('head');
|
||||
if (mc?.mode === 'strict') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'head',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
true,
|
||||
mc.count,
|
||||
0,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
const pm = fillerHelper.getPlaybackMode('head');
|
||||
if (pm) {
|
||||
const isRelaxed = pm.type === 'relaxed';
|
||||
if (!isRelaxed || flexBudget > 0) {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'head',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
pm,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
if (isRelaxed) flexBudget -= r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Relaxed head
|
||||
if (fillerHelper && isFirstInRun && flexBudget > 0) {
|
||||
const mc = fillerHelper.modeAndCount('head');
|
||||
if (mc?.mode === 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'head',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
false,
|
||||
1,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
flexBudget -= r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Strict pre
|
||||
// (3/4) Pre
|
||||
if (fillerHelper) {
|
||||
const mc = fillerHelper.modeAndCount('pre');
|
||||
if (mc?.mode === 'strict') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'pre',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
true,
|
||||
mc.count,
|
||||
0,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// (4) Relaxed pre
|
||||
if (fillerHelper && flexBudget > 0) {
|
||||
const mc = fillerHelper.modeAndCount('pre');
|
||||
if (mc?.mode === 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'pre',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
false,
|
||||
1,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
flexBudget -= r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
const pm = fillerHelper.getPlaybackMode('pre');
|
||||
if (pm) {
|
||||
const isRelaxed = pm.type === 'relaxed';
|
||||
if (!isRelaxed || flexBudget > 0) {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'pre',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
pm,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
items.push(...r.items);
|
||||
currentTimeMs += r.timeConsumedMs;
|
||||
if (isRelaxed) flexBudget -= r.timeConsumedMs;
|
||||
sequenceIndex = r.nextSeqIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1610,18 +1529,17 @@ export class InfiniteScheduleGenerator {
|
||||
|
||||
iterator.next();
|
||||
|
||||
// (7) Relaxed post
|
||||
// (7) Post (relaxed)
|
||||
if (fillerHelper && flexBudget > 0) {
|
||||
const mc = fillerHelper.modeAndCount('post');
|
||||
if (mc?.mode === 'relaxed') {
|
||||
const pm = fillerHelper.getPlaybackMode('post');
|
||||
if (pm?.type === 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'post',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
false,
|
||||
1,
|
||||
pm,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
@@ -1632,18 +1550,17 @@ export class InfiniteScheduleGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
// (8) Relaxed tail
|
||||
// (8) Tail (relaxed)
|
||||
if (fillerHelper && isLastInRun && flexBudget > 0) {
|
||||
const mc = fillerHelper.modeAndCount('tail');
|
||||
if (mc?.mode === 'relaxed') {
|
||||
const pm = fillerHelper.getPlaybackMode('tail');
|
||||
if (pm?.type === 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'tail',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
false,
|
||||
1,
|
||||
pm,
|
||||
flexBudget,
|
||||
sequenceIndex,
|
||||
);
|
||||
@@ -1687,18 +1604,17 @@ export class InfiniteScheduleGenerator {
|
||||
currentTimeMs += flexBudget;
|
||||
}
|
||||
|
||||
// (11) Strict post
|
||||
// (11) Post (non-relaxed)
|
||||
if (fillerHelper) {
|
||||
const mc = fillerHelper.modeAndCount('post');
|
||||
if (mc?.mode === 'strict') {
|
||||
const pm = fillerHelper.getPlaybackMode('post');
|
||||
if (pm && pm.type !== 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'post',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
true,
|
||||
mc.count,
|
||||
pm,
|
||||
0,
|
||||
sequenceIndex,
|
||||
);
|
||||
@@ -1708,18 +1624,17 @@ export class InfiniteScheduleGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
// (12) Strict tail
|
||||
// (12) Tail (non-relaxed)
|
||||
if (fillerHelper && isLastInRun) {
|
||||
const mc = fillerHelper.modeAndCount('tail');
|
||||
if (mc?.mode === 'strict') {
|
||||
const pm = fillerHelper.getPlaybackMode('tail');
|
||||
if (pm && pm.type !== 'relaxed') {
|
||||
const r = fillerHelper.emitFillerItems(
|
||||
'tail',
|
||||
channelUuid,
|
||||
schedule.uuid,
|
||||
slot.uuid,
|
||||
currentTimeMs,
|
||||
true,
|
||||
mc.count,
|
||||
pm,
|
||||
0,
|
||||
sequenceIndex,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import dayjs from '@/util/dayjs.js';
|
||||
import { isNonEmptyArray } from '@/util/index.js';
|
||||
import type { FillerProgram } from '@tunarr/types';
|
||||
import type {
|
||||
FillerPlaybackMode,
|
||||
FillerProgrammingSlot,
|
||||
SlotFiller,
|
||||
SlotFillerTypes,
|
||||
@@ -41,8 +42,7 @@ type ListEntry = {
|
||||
|
||||
type TypeEntry = {
|
||||
listId: string;
|
||||
mode: 'relaxed' | 'strict';
|
||||
count: number;
|
||||
playbackMode: FillerPlaybackMode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,8 +71,8 @@ export class InfiniteSlotFillerHelper {
|
||||
for (const def of fillerDefs) {
|
||||
const fillerListId = def.fillerListId;
|
||||
const fillerOrder = def.fillerOrder ?? 'shuffle_prefer_short';
|
||||
const mode = def.mode ?? 'relaxed';
|
||||
const count = def.count ?? 1;
|
||||
const playbackMode: FillerPlaybackMode =
|
||||
def.playbackMode ?? { type: 'relaxed' };
|
||||
|
||||
if (!this.listEntries.has(fillerListId)) {
|
||||
const programs = programsByListId[fillerListId] ?? [];
|
||||
@@ -159,14 +159,13 @@ export class InfiniteSlotFillerHelper {
|
||||
});
|
||||
}
|
||||
|
||||
// Map each type to this list entry (mode/count come from the def)
|
||||
for (const type of def.types) {
|
||||
const existing = this.fillerByType.get(type) ?? [];
|
||||
// Avoid duplicate entries for the same fillerListId+type
|
||||
if (!existing.some((e) => e.listId === fillerListId)) {
|
||||
existing.push({ listId: fillerListId, mode, count });
|
||||
this.fillerByType.set(type, existing);
|
||||
}
|
||||
// Map the singular type to this list entry
|
||||
const type = def.type;
|
||||
const existing = this.fillerByType.get(type) ?? [];
|
||||
// Avoid duplicate entries for the same (fillerListId, type) pair
|
||||
if (!existing.some((e) => e.listId === fillerListId)) {
|
||||
existing.push({ listId: fillerListId, playbackMode });
|
||||
this.fillerByType.set(type, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,16 +176,13 @@ export class InfiniteSlotFillerHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the mode and count for the first config entry matching this type.
|
||||
* Returns the playback mode for the first config entry matching this type.
|
||||
* Returns null if no entry exists for this type.
|
||||
*/
|
||||
modeAndCount(
|
||||
type: SlotFillerTypes,
|
||||
): { mode: 'relaxed' | 'strict'; count: number } | null {
|
||||
getPlaybackMode(type: SlotFillerTypes): FillerPlaybackMode | null {
|
||||
const entries = this.fillerByType.get(type);
|
||||
if (!entries || entries.length === 0) return null;
|
||||
const first = entries[0]!;
|
||||
return { mode: first.mode, count: first.count };
|
||||
return entries[0]!.playbackMode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,10 +261,13 @@ export class InfiniteSlotFillerHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit filler items of the given type and return them along with total time
|
||||
* consumed. For strict mode, emits exactly `count` items (or fewer if
|
||||
* nothing is available). For relaxed mode, emits at most 1 item within
|
||||
* `flexBudgetMs`.
|
||||
* Emit filler items of the given type and return them along with total time consumed.
|
||||
*
|
||||
* Modes:
|
||||
* - `relaxed`: emit at most 1 item within `flexBudgetMs`
|
||||
* - `count`: emit exactly N items unconditionally
|
||||
* - `duration`: emit items until `durationMs` time target is met
|
||||
* - `random_count`: emit a random number of items between min and available count
|
||||
*/
|
||||
emitFillerItems(
|
||||
type: SlotFillerTypes,
|
||||
@@ -276,8 +275,7 @@ export class InfiniteSlotFillerHelper {
|
||||
scheduleUuid: string,
|
||||
slotUuid: string,
|
||||
startTimeMs: number,
|
||||
strict: boolean,
|
||||
count: number,
|
||||
playbackMode: FillerPlaybackMode,
|
||||
flexBudgetMs: number,
|
||||
startSeqIndex: number,
|
||||
): {
|
||||
@@ -289,35 +287,38 @@ export class InfiniteSlotFillerHelper {
|
||||
let timeConsumed = 0;
|
||||
let seqIndex = startSeqIndex;
|
||||
|
||||
if (strict) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// For strict: use the list's maxDuration so all programs qualify
|
||||
const sel = this.select(
|
||||
type,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
startTimeMs + timeConsumed,
|
||||
);
|
||||
if (!sel) break;
|
||||
items.push(
|
||||
this.createFillerItem(
|
||||
channelUuid,
|
||||
scheduleUuid,
|
||||
slotUuid,
|
||||
sel.program,
|
||||
type,
|
||||
sel.fillerListId,
|
||||
startTimeMs + timeConsumed,
|
||||
sel.program.duration,
|
||||
seqIndex++,
|
||||
),
|
||||
);
|
||||
timeConsumed += sel.program.duration;
|
||||
switch (playbackMode.type) {
|
||||
case 'relaxed': {
|
||||
// At most 1 item within the flex budget
|
||||
if (flexBudgetMs > 0) {
|
||||
const sel = this.select(type, flexBudgetMs, startTimeMs);
|
||||
if (sel) {
|
||||
items.push(
|
||||
this.createFillerItem(
|
||||
channelUuid,
|
||||
scheduleUuid,
|
||||
slotUuid,
|
||||
sel.program,
|
||||
type,
|
||||
sel.fillerListId,
|
||||
startTimeMs,
|
||||
sel.program.duration,
|
||||
seqIndex++,
|
||||
),
|
||||
);
|
||||
timeConsumed += sel.program.duration;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Relaxed: at most 1 item within the flex budget
|
||||
if (flexBudgetMs > 0) {
|
||||
const sel = this.select(type, flexBudgetMs, startTimeMs);
|
||||
if (sel) {
|
||||
case 'count': {
|
||||
for (let i = 0; i < playbackMode.count; i++) {
|
||||
const sel = this.select(
|
||||
type,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
startTimeMs + timeConsumed,
|
||||
);
|
||||
if (!sel) break;
|
||||
items.push(
|
||||
this.createFillerItem(
|
||||
channelUuid,
|
||||
@@ -326,13 +327,71 @@ export class InfiniteSlotFillerHelper {
|
||||
sel.program,
|
||||
type,
|
||||
sel.fillerListId,
|
||||
startTimeMs,
|
||||
startTimeMs + timeConsumed,
|
||||
sel.program.duration,
|
||||
seqIndex++,
|
||||
),
|
||||
);
|
||||
timeConsumed += sel.program.duration;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'duration': {
|
||||
const targetMs = playbackMode.durationMs;
|
||||
while (timeConsumed < targetMs) {
|
||||
const remaining = targetMs - timeConsumed;
|
||||
const sel = this.select(type, remaining, startTimeMs + timeConsumed);
|
||||
if (!sel) break;
|
||||
items.push(
|
||||
this.createFillerItem(
|
||||
channelUuid,
|
||||
scheduleUuid,
|
||||
slotUuid,
|
||||
sel.program,
|
||||
type,
|
||||
sel.fillerListId,
|
||||
startTimeMs + timeConsumed,
|
||||
sel.program.duration,
|
||||
seqIndex++,
|
||||
),
|
||||
);
|
||||
timeConsumed += sel.program.duration;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'random_count': {
|
||||
const typeEntries = this.fillerByType.get(type) ?? [];
|
||||
const totalSize = typeEntries.reduce((sum, { listId }) => {
|
||||
const entry = this.listEntries.get(listId);
|
||||
return sum + (entry?.programs.length ?? 0);
|
||||
}, 0);
|
||||
if (totalSize === 0) break;
|
||||
const min = playbackMode.min ?? 1;
|
||||
const max = Math.min(playbackMode.max ?? totalSize, totalSize);
|
||||
const n = this.random.integer(min, Math.max(min, max));
|
||||
for (let i = 0; i < n; i++) {
|
||||
const sel = this.select(
|
||||
type,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
startTimeMs + timeConsumed,
|
||||
);
|
||||
if (!sel) break;
|
||||
items.push(
|
||||
this.createFillerItem(
|
||||
channelUuid,
|
||||
scheduleUuid,
|
||||
slotUuid,
|
||||
sel.program,
|
||||
type,
|
||||
sel.fillerListId,
|
||||
startTimeMs + timeConsumed,
|
||||
sel.program.duration,
|
||||
seqIndex++,
|
||||
),
|
||||
);
|
||||
timeConsumed += sel.program.duration;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,12 +37,11 @@ export abstract class SlotImpl<
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const type of filler.types) {
|
||||
if (this.fillerIteratorsByType[type]) {
|
||||
this.fillerIteratorsByType[type].push(it);
|
||||
} else {
|
||||
this.fillerIteratorsByType[type] = [it];
|
||||
}
|
||||
const type = filler.type;
|
||||
if (this.fillerIteratorsByType[type]) {
|
||||
this.fillerIteratorsByType[type].push(it);
|
||||
} else {
|
||||
this.fillerIteratorsByType[type] = [it];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ export class SlotSchedulerHelper {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenContentIds.add(program.uuid);
|
||||
slotPrograms.push({
|
||||
...program,
|
||||
parentCustomShows: customShowContexts[program.uuid] ?? [],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inject, injectable, interfaces } from 'inversify';
|
||||
import { filter, flatten, forEach, values } from 'lodash-es';
|
||||
import { container } from '../../container.ts';
|
||||
import { ISettingsDB } from '../../db/interfaces/ISettingsDB.ts';
|
||||
import { CleanupGeneratedScheduleItemsTask } from '../../tasks/CleanupGeneratedScheduleItemsTask.ts';
|
||||
import { CleanupSessionsTask } from '../../tasks/CleanupSessionsTask.ts';
|
||||
import { OnDemandChannelStateTask } from '../../tasks/OnDemandChannelStateTask.ts';
|
||||
import { RefreshMediaSourceLibraryTask } from '../../tasks/RefreshMediaSourceLibraryTask.ts';
|
||||
@@ -48,6 +49,18 @@ export class ScheduleJobsStartupTask extends SimpleStartupTask {
|
||||
),
|
||||
);
|
||||
|
||||
GlobalScheduler.scheduleTask(
|
||||
CleanupGeneratedScheduleItemsTask.ID,
|
||||
new ScheduledTask(
|
||||
CleanupGeneratedScheduleItemsTask,
|
||||
hoursCrontab(1),
|
||||
container.get<interfaces.AutoFactory<CleanupGeneratedScheduleItemsTask>>(
|
||||
CleanupGeneratedScheduleItemsTask.KEY,
|
||||
),
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
|
||||
GlobalScheduler.scheduleTask(
|
||||
CleanupSessionsTask.ID,
|
||||
new ScheduledTask(
|
||||
|
||||
37
server/src/tasks/CleanupGeneratedScheduleItemsTask.ts
Normal file
37
server/src/tasks/CleanupGeneratedScheduleItemsTask.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { InfiniteScheduleDB } from '@/db/InfiniteScheduleDB.js';
|
||||
import { KEYS } from '@/types/inject.js';
|
||||
import { type Logger } from '@/util/logging/LoggerFactory.js';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { SimpleTask, TaskId } from './Task.js';
|
||||
import { simpleTaskDef } from './TaskRegistry.ts';
|
||||
|
||||
@injectable()
|
||||
@simpleTaskDef({
|
||||
description: 'Removes expired generated schedule items from the database',
|
||||
})
|
||||
export class CleanupGeneratedScheduleItemsTask extends SimpleTask {
|
||||
static KEY = Symbol.for(CleanupGeneratedScheduleItemsTask.name);
|
||||
public static ID: TaskId = 'cleanup-generated-schedule-items';
|
||||
public ID = CleanupGeneratedScheduleItemsTask.ID;
|
||||
|
||||
constructor(
|
||||
@inject(KEYS.Logger) logger: Logger,
|
||||
@inject(KEYS.InfiniteScheduleDB)
|
||||
private infiniteScheduleDB: InfiniteScheduleDB,
|
||||
) {
|
||||
super(logger);
|
||||
}
|
||||
|
||||
protected async runInternal(): Promise<void> {
|
||||
const deleted = await this.infiniteScheduleDB.deleteAllGeneratedItemsBefore(
|
||||
Date.now(),
|
||||
);
|
||||
if (deleted > 0) {
|
||||
this.logger.debug('Cleaned up %d expired generated schedule items', deleted);
|
||||
}
|
||||
}
|
||||
|
||||
get taskName() {
|
||||
return CleanupGeneratedScheduleItemsTask.name;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ArchiveDatabaseBackup,
|
||||
ArchiveDatabaseBackupKey,
|
||||
} from '@/db/backup/ArchiveDatabaseBackup.js';
|
||||
import { CleanupGeneratedScheduleItemsTask } from '@/tasks/CleanupGeneratedScheduleItemsTask.js';
|
||||
import { CleanupSessionsTask } from '@/tasks/CleanupSessionsTask.js';
|
||||
import { OnDemandChannelStateTask } from '@/tasks/OnDemandChannelStateTask.js';
|
||||
import type { ReconcileProgramDurationsTaskRequest } from '@/tasks/ReconcileProgramDurationsTask.js';
|
||||
@@ -56,6 +57,11 @@ const TasksModule = new ContainerModule((bind) => {
|
||||
ScheduleDynamicChannelsTask.KEY,
|
||||
).toAutoFactory(ScheduleDynamicChannelsTask);
|
||||
|
||||
bind(CleanupGeneratedScheduleItemsTask).toSelf();
|
||||
bind<interfaces.Factory<CleanupGeneratedScheduleItemsTask>>(
|
||||
CleanupGeneratedScheduleItemsTask.KEY,
|
||||
).toAutoFactory(CleanupGeneratedScheduleItemsTask);
|
||||
|
||||
bind(CleanupSessionsTask).toSelf();
|
||||
bind<interfaces.Factory<CleanupSessionsTask>>(
|
||||
CleanupSessionsTask.KEY,
|
||||
|
||||
@@ -54,20 +54,44 @@ export const SlotFillerTypes = z.enum([
|
||||
|
||||
export type SlotFillerTypes = z.infer<typeof SlotFillerTypes>;
|
||||
|
||||
export const SlotFiller = z.object({
|
||||
types: z.array(SlotFillerTypes).nonempty(),
|
||||
export const FillerPlaybackMode = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('relaxed') }),
|
||||
z.object({ type: z.literal('count'), count: z.number().int().positive() }),
|
||||
z.object({
|
||||
type: z.literal('duration'),
|
||||
durationMs: z.number().int().positive(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('random_count'),
|
||||
min: z.number().int().min(1).optional(),
|
||||
max: z.number().int().positive().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type FillerPlaybackMode = z.infer<typeof FillerPlaybackMode>;
|
||||
|
||||
export const LegacySlotFiller = z.object({
|
||||
types: SlotFillerTypes.array(),
|
||||
fillerListId: z.uuid(),
|
||||
fillerOrder: SlotProgrammingFillerOrder.optional().default(
|
||||
'shuffle_prefer_short',
|
||||
),
|
||||
mode: z.enum(['relaxed', 'strict']).optional(),
|
||||
count: z.number().int().positive().optional(),
|
||||
// playbackMode: FillerPlaybackMode.optional().default({ type: 'relaxed' }),
|
||||
});
|
||||
|
||||
export const SlotFiller = z.object({
|
||||
type: SlotFillerTypes,
|
||||
fillerListId: z.uuid(),
|
||||
fillerOrder: SlotProgrammingFillerOrder.optional().default(
|
||||
'shuffle_prefer_short',
|
||||
),
|
||||
playbackMode: FillerPlaybackMode.optional().default({ type: 'relaxed' }),
|
||||
});
|
||||
|
||||
export type SlotFiller = z.infer<typeof SlotFiller>;
|
||||
|
||||
export const Slot = z.object({
|
||||
filler: z.array(SlotFiller).optional(),
|
||||
filler: z.array(LegacySlotFiller).optional(),
|
||||
});
|
||||
|
||||
//
|
||||
|
||||
@@ -24,7 +24,6 @@ export const AnchorModeSchema = z.enum(['hard', 'soft', 'padded']);
|
||||
export type AnchorMode = z.infer<typeof AnchorModeSchema>;
|
||||
|
||||
export const InfiniteSlotTypeSchema = z.enum([
|
||||
'movie',
|
||||
'show',
|
||||
'custom-show',
|
||||
'filler',
|
||||
@@ -116,11 +115,6 @@ export const BaseInfiniteSlotSchema = z.object({
|
||||
|
||||
export type BaseScheduleSlot = z.infer<typeof BaseInfiniteSlotSchema>;
|
||||
|
||||
export const MovieScheduleSlotSchema = BaseInfiniteSlotSchema.extend({
|
||||
type: z.literal('movie'),
|
||||
slotConfig: InfiniteSlotConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export const ShowScheduleSlotSchema = BaseInfiniteSlotSchema.extend({
|
||||
type: z.literal('show'),
|
||||
showId: z.uuid(),
|
||||
@@ -155,7 +149,6 @@ export const SmartCollectionScheduleSlotSchema = BaseInfiniteSlotSchema.extend({
|
||||
});
|
||||
|
||||
export const ScheduleSlotSchema = z.discriminatedUnion('type', [
|
||||
MovieScheduleSlotSchema,
|
||||
ShowScheduleSlotSchema,
|
||||
CustomShowScheduleSlotSchema,
|
||||
FillerScheduleSlotSchema,
|
||||
@@ -231,7 +224,6 @@ export type MaterializedSmartCollectioScheduleSlot = z.infer<
|
||||
|
||||
export const MaterializedScheduleSlotSchema = z.discriminatedUnion('type', [
|
||||
FlexScheduleSlotSchema,
|
||||
MovieScheduleSlotSchema,
|
||||
MaterializedRedirectScheduleSlotSchema,
|
||||
MaterializedCustomShowScheduleSlotSchema,
|
||||
MaterializedFillerShowScheduleSlotSchema,
|
||||
@@ -303,6 +295,7 @@ export const GeneratedFillerScheduleItemSchema =
|
||||
BaseGeneratedScheduleItemSchema.extend({
|
||||
itemType: z.literal('filler'),
|
||||
fillerListId: z.string(),
|
||||
programUuid: z.string().uuid().nullish(),
|
||||
});
|
||||
|
||||
export const GeneratedRedirectScheduleItemSchema =
|
||||
@@ -403,15 +396,15 @@ export type InfiniteSchedulePreviewRequest = z.infer<
|
||||
typeof InfiniteSchedulePreviewRequestSchema
|
||||
>;
|
||||
|
||||
export const InfiniteSchedulePreviewResponseSchema = z.object({
|
||||
export const InfiniteScheduleGenerationResponseSchema = z.object({
|
||||
items: z.array(GeneratedScheduleItemSchema),
|
||||
fromTimeMs: z.number(),
|
||||
toTimeMs: z.number(),
|
||||
contentPrograms: z.record(z.uuid(), ContentProgramSchema),
|
||||
});
|
||||
|
||||
export type InfiniteSchedulePreviewResponse = z.infer<
|
||||
typeof InfiniteSchedulePreviewResponseSchema
|
||||
export type InfiniteScheduleGenerationResponse = z.infer<
|
||||
typeof InfiniteScheduleGenerationResponseSchema
|
||||
>;
|
||||
|
||||
// Add/Update slot request
|
||||
|
||||
@@ -211,6 +211,7 @@ export const ContentProgramSchema = CondensedContentProgramSchema.extend({
|
||||
uniqueId: z.string(), // If persisted, this is the ID. If not persisted, this is `externalSourceType|externalSourceName|externalKey`
|
||||
externalIds: z.array(ExternalIdSchema),
|
||||
canonicalId: z.string().optional(),
|
||||
startTime: z.number().optional(),
|
||||
});
|
||||
|
||||
export const CondensedCustomProgramSchema = BaseProgramSchema.extend({
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { Dictionary } from 'ts-essentials';
|
||||
import { useGetRouteDetails } from '../hooks/useRouteName.ts';
|
||||
import { RouterLink } from './base/RouterLink.tsx';
|
||||
|
||||
const templateExtractor = /\{\{\s*(.*?)\s*\}\}/g;
|
||||
|
||||
type Props = BreadcrumbsProps & {
|
||||
thisRouteName?: string;
|
||||
routeNameMap?: Dictionary<string>;
|
||||
@@ -57,9 +55,6 @@ export default function Breadcrumbs(props: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(route.name, [
|
||||
...route.name.matchAll(/\{\{\s*(.*?)\s*\}\}/g),
|
||||
]);
|
||||
const routeName = route.name.replaceAll(
|
||||
/\{\{\s*(.*?)\s*\}\}/g,
|
||||
(_, match) => {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { BreadcrumbsProps } from '@mui/material';
|
||||
import { Breadcrumbs, Typography } from '@mui/material';
|
||||
import type { AnyRoute } from '@tanstack/react-router';
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { isNonEmptyString, seq } from '@tunarr/shared/util';
|
||||
import { useMemo } from 'react';
|
||||
import { RouterLink } from './base/RouterLink.tsx';
|
||||
|
||||
function walkRouteTree(r: AnyRoute, depth = 0) {
|
||||
console.log(depth, r.fullPath, r);
|
||||
for (const c of r.children ?? []) {
|
||||
walkRouteTree(c, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export const BreadcrumbsV2 = (props: BreadcrumbsProps) => {
|
||||
const { matches } = useRouterState();
|
||||
const crumbs = useMemo(() => {
|
||||
return matches.flatMap(({ pathname, meta }) => {
|
||||
return seq.collect(meta, (m) => {
|
||||
const title = m?.title;
|
||||
if (!isNonEmptyString(title)) {
|
||||
return;
|
||||
}
|
||||
return { title, path: pathname };
|
||||
});
|
||||
});
|
||||
}, [matches]);
|
||||
|
||||
return (
|
||||
<Breadcrumbs {...props} sx={props.sx ?? { mb: 2 }}>
|
||||
{crumbs.map((crumb, idx) => {
|
||||
const isLast = idx === crumbs.length - 1;
|
||||
return isLast ? (
|
||||
<Typography color="text.primary" key={crumb.title}>
|
||||
{crumb.title}
|
||||
</Typography>
|
||||
) : (
|
||||
<RouterLink to={crumb.path} key={crumb.title}>
|
||||
{crumb.title}
|
||||
</RouterLink>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Directions,
|
||||
Expand,
|
||||
MusicVideo,
|
||||
PlaylistPlay,
|
||||
VideoCameraBackOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
@@ -194,13 +195,16 @@ const ProgramListItem = ({
|
||||
{titleFormatter(program)}
|
||||
</Box>,
|
||||
];
|
||||
const startTimeDate = !isUndefined(program.startTimeOffset)
|
||||
? dayjs(channel.startTime + program.startTimeOffset)
|
||||
: program.startTime
|
||||
? dayjs(program.startTime)
|
||||
const startTimeDate = program.startTime
|
||||
? dayjs(program.startTime)
|
||||
: !isUndefined(program.startTimeOffset)
|
||||
? dayjs(channel.startTime + program.startTimeOffset)
|
||||
: undefined;
|
||||
|
||||
const startTime = startTimeDate?.format(smallViewport ? 'L LT' : 'lll');
|
||||
if (!startTime) {
|
||||
console.log('hello', program);
|
||||
}
|
||||
if (!smallViewport && showProgramStartTime && startTime) {
|
||||
if (startTime) {
|
||||
titleParts.push(<Box component="span">{startTime}</Box>);
|
||||
@@ -220,6 +224,8 @@ const ProgramListItem = ({
|
||||
.with('other_video', () => <VideoCameraBackOutlined />)
|
||||
.with(P.nullish, () => null)
|
||||
.exhaustive();
|
||||
} else if (program.type === 'filler') {
|
||||
icon = <PlaylistPlay />;
|
||||
} else if (program.type === 'flex') {
|
||||
icon = <Expand />;
|
||||
} else if (program.type === 'redirect') {
|
||||
|
||||
71
web/src/components/channel_config/ChannelScheduleViewer.tsx
Normal file
71
web/src/components/channel_config/ChannelScheduleViewer.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Alert, AlertTitle, Stack } from '@mui/material';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
getInfiniteScheduleItemsOptions,
|
||||
getScheduleByIdOptions,
|
||||
} from '../../generated/@tanstack/react-query.gen.ts';
|
||||
import { scheduleGenerationResponseToLineupList } from '../../helpers/converters.ts';
|
||||
import { useChannelEditor } from '../../store/selectors.ts';
|
||||
import PaddedPaper from '../base/PaddedPaper.tsx';
|
||||
import { RouterLink } from '../base/RouterLink.tsx';
|
||||
import ChannelLineupList from './ChannelLineupList.tsx';
|
||||
|
||||
export function ChannelScheduleViewer() {
|
||||
const { currentEntity: channel } = useChannelEditor();
|
||||
const scheduleId = useMemo(() => channel!.scheduleId!, [channel]);
|
||||
const schedulesQuery = useSuspenseQuery({
|
||||
...getScheduleByIdOptions({ path: { id: scheduleId } }),
|
||||
});
|
||||
|
||||
const scheduleItems = useQuery({
|
||||
...getInfiniteScheduleItemsOptions({
|
||||
path: { id: channel!.id },
|
||||
query: {
|
||||
fromTimeMs: +dayjs().startOf('day'),
|
||||
toTimeMs: +dayjs().startOf('day').add(2, 'days'),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const lineup = useMemo(() => {
|
||||
if (!scheduleItems.data) {
|
||||
return [];
|
||||
}
|
||||
return scheduleGenerationResponseToLineupList(scheduleItems.data);
|
||||
}, [scheduleItems.data]);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">
|
||||
<AlertTitle>
|
||||
This channel is using an assigned schedule:{' '}
|
||||
<RouterLink
|
||||
to="/schedules/$scheduleId"
|
||||
params={{ scheduleId: channel!.scheduleId! }}
|
||||
>
|
||||
{schedulesQuery.data.name}
|
||||
</RouterLink>{' '}
|
||||
. Programming cannot be edited directly for channels that are assigned
|
||||
schedules. The schedule itself should be edited to change programming.
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
<PaddedPaper>
|
||||
<ChannelLineupList
|
||||
type="direct"
|
||||
programList={lineup}
|
||||
enableDnd={false}
|
||||
enableRowEdit={false}
|
||||
enableRowDelete={false}
|
||||
virtualListProps={{
|
||||
height: 600,
|
||||
itemSize: 35,
|
||||
overscanCount: 10,
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</PaddedPaper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
@@ -24,8 +26,7 @@ import {
|
||||
type ScheduleSlot,
|
||||
} from '@tunarr/types/api';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { capitalize, find, isNil, maxBy } from 'lodash-es';
|
||||
import { capitalize, isNil, maxBy, range, uniq } from 'lodash-es';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { SubmitErrorHandler, SubmitHandler } from 'react-hook-form';
|
||||
import {
|
||||
@@ -41,12 +42,14 @@ import {
|
||||
updateScheduleSlotMutation,
|
||||
} from '../../generated/@tanstack/react-query.gen.ts';
|
||||
import { betterHumanize } from '../../helpers/dayjs.ts';
|
||||
import type { ProgramOption } from '../../helpers/slotSchedulerUtil.ts';
|
||||
import {
|
||||
ProgramOptionTypes,
|
||||
slotOrderOptions,
|
||||
} from '../../helpers/slotSchedulerUtil.ts';
|
||||
import { useDayjs } from '../../hooks/useDayjs.ts';
|
||||
import { ShowSearchSlotProgrammingForm } from '../slot_scheduler/ShowSearchSlotProgrammingForm.tsx';
|
||||
import { SlotFillerDialogPanel } from '../slot_scheduler/SlotFillerDialogPanel.tsx';
|
||||
import { TabPanel } from '../TabPanel.tsx';
|
||||
import { NumericFormControllerText } from '../util/TypedController.tsx';
|
||||
|
||||
type Props = {
|
||||
@@ -83,90 +86,82 @@ const newSlotForType = (
|
||||
// programOptions,
|
||||
// (opt): opt is CustomShowProgramOption => opt.type === 'custom-show',
|
||||
// );
|
||||
return (
|
||||
match(type)
|
||||
.returnType<MaterializedScheduleSlot>()
|
||||
.with('custom-show', () => defaultCustomShowSlot(schedule))
|
||||
// .with('movie', () => ({
|
||||
// type: 'movie',
|
||||
// order: 'alphanumeric',
|
||||
// direction: 'asc',
|
||||
// title: 'Movies',
|
||||
// }))
|
||||
.with('filler', () => {
|
||||
return {
|
||||
type: 'filler',
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
// order: 'shuffle_prefer_short',
|
||||
// decayFactor: 0.5,
|
||||
// durationWeighting: 'linear',
|
||||
// recoveryFactor: 0.05,
|
||||
fillerListId: '',
|
||||
fillerList: null,
|
||||
isMissing: false,
|
||||
};
|
||||
})
|
||||
.with('flex', () => ({
|
||||
type: 'flex',
|
||||
return match(type)
|
||||
.returnType<MaterializedScheduleSlot>()
|
||||
.with('custom-show', () => defaultCustomShowSlot(schedule))
|
||||
.with('filler', () => {
|
||||
return {
|
||||
type: 'filler',
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
}))
|
||||
.with('redirect', () => {
|
||||
// const opt = programOptions.find((opt) => opt.type === 'redirect');
|
||||
return {
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
type: 'redirect',
|
||||
redirectChannelId: '', // opt?.channelId ?? '',
|
||||
// order: 'shuffle_prefer_short',
|
||||
// decayFactor: 0.5,
|
||||
// durationWeighting: 'linear',
|
||||
// recoveryFactor: 0.05,
|
||||
fillerListId: '',
|
||||
fillerList: null,
|
||||
isMissing: false,
|
||||
};
|
||||
})
|
||||
.with('flex', () => ({
|
||||
type: 'flex',
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
}))
|
||||
.with('redirect', () => {
|
||||
// const opt = programOptions.find((opt) => opt.type === 'redirect');
|
||||
return {
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
type: 'redirect',
|
||||
redirectChannelId: '', // opt?.channelId ?? '',
|
||||
order: 'next',
|
||||
direction: 'asc',
|
||||
title: '', // `Redirect to Channel ${opt?.channelName ?? ''}`,
|
||||
channel: null,
|
||||
isMissing: false,
|
||||
};
|
||||
})
|
||||
.with('show', () => {
|
||||
// const opt = programOptions.find((opt) => opt.type === 'show');
|
||||
return {
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
type: 'show' as const,
|
||||
showId: '', // opt?.showId ?? '',
|
||||
// order: 'next',
|
||||
// direction: 'asc',
|
||||
slotConfig: {
|
||||
order: 'next',
|
||||
direction: 'asc',
|
||||
title: '', // `Redirect to Channel ${opt?.channelName ?? ''}`,
|
||||
channel: null,
|
||||
isMissing: false,
|
||||
};
|
||||
})
|
||||
.with('show', () => {
|
||||
// const opt = programOptions.find((opt) => opt.type === 'show');
|
||||
return {
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
type: 'show' as const,
|
||||
showId: '', // opt?.showId ?? '',
|
||||
// order: 'next',
|
||||
// direction: 'asc',
|
||||
slotConfig: {
|
||||
order: 'next',
|
||||
direction: 'asc',
|
||||
seasonFilter: [],
|
||||
},
|
||||
show: null,
|
||||
fillMode: 'fill',
|
||||
fillValue: 0,
|
||||
} satisfies MaterializedShowScheduleSlot;
|
||||
})
|
||||
.with('smart-collection', () => {
|
||||
// const opt = programOptions.find(
|
||||
// (opt) => opt.type === 'smart-collection',
|
||||
// );
|
||||
return {
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
type: 'smart-collection' as const,
|
||||
order: 'next',
|
||||
direction: 'asc',
|
||||
smartCollectionId: '', //opt?.collectionId ?? '',
|
||||
smartCollection: null,
|
||||
isMissing: false,
|
||||
};
|
||||
})
|
||||
.exhaustive()
|
||||
);
|
||||
seasonFilter: [],
|
||||
},
|
||||
show: null,
|
||||
fillMode: 'fill',
|
||||
fillValue: 0,
|
||||
} satisfies MaterializedShowScheduleSlot;
|
||||
})
|
||||
.with('smart-collection', () => {
|
||||
// const opt = programOptions.find(
|
||||
// (opt) => opt.type === 'smart-collection',
|
||||
// );
|
||||
return {
|
||||
cooldownMs: 0,
|
||||
slotIndex: 0,
|
||||
weight: 0,
|
||||
type: 'smart-collection' as const,
|
||||
order: 'next',
|
||||
direction: 'asc',
|
||||
smartCollectionId: '', //opt?.collectionId ?? '',
|
||||
smartCollection: null,
|
||||
isMissing: false,
|
||||
};
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
export const EditScheduleSlotForm = ({ schedule, slot, isNew }: Props) => {
|
||||
@@ -192,6 +187,9 @@ export const EditScheduleSlotForm = ({ schedule, slot, isNew }: Props) => {
|
||||
'slotConfig',
|
||||
'anchorMode',
|
||||
]);
|
||||
|
||||
const dayjs = useDayjs();
|
||||
const [tab, setTab] = useState(0);
|
||||
const seasonFilter = slotConfig?.seasonFilter;
|
||||
const order = slotConfig?.order;
|
||||
const [typeSelectValue, setTypeSelectValue] = useState(type);
|
||||
@@ -253,7 +251,7 @@ export const EditScheduleSlotForm = ({ schedule, slot, isNew }: Props) => {
|
||||
...addSlotToScheduleMutation(),
|
||||
onSuccess: async (data) => {
|
||||
reset((prev) => ({ ...prev, ...data }));
|
||||
await queryClient.invalidateQueries({
|
||||
await queryClient.resetQueries({
|
||||
queryKey: getScheduleByIdQueryKey({ path: { id: schedule.uuid } }),
|
||||
});
|
||||
},
|
||||
@@ -263,7 +261,7 @@ export const EditScheduleSlotForm = ({ schedule, slot, isNew }: Props) => {
|
||||
...updateScheduleSlotMutation(),
|
||||
onSuccess: async (data) => {
|
||||
reset((prev) => ({ ...prev, ...data }));
|
||||
await queryClient.invalidateQueries({
|
||||
await queryClient.resetQueries({
|
||||
queryKey: getScheduleByIdQueryKey({ path: { id: schedule.uuid } }),
|
||||
});
|
||||
},
|
||||
@@ -299,255 +297,322 @@ export const EditScheduleSlotForm = ({ schedule, slot, isNew }: Props) => {
|
||||
return (
|
||||
<Box component={'form'} onSubmit={handleSubmit(onSubmit, onSubmitError)}>
|
||||
<FormProvider {...form}>
|
||||
<Stack spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
label="Type"
|
||||
value={typeSelectValue}
|
||||
onChange={(e) =>
|
||||
handleTypeChange(e.target.value as ProgramOption['type'])
|
||||
}
|
||||
>
|
||||
{ProgramOptionTypes.map(({ value, description }) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{type === 'show' && (
|
||||
<ShowSearchSlotProgrammingForm
|
||||
show={show}
|
||||
onShowChange={onShowChange}
|
||||
onSeasonFilterChange={onSeasonFilterChange}
|
||||
seasonFilter={seasonFilter ?? []}
|
||||
/>
|
||||
)}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slotConfig.order"
|
||||
render={({ field }) => {
|
||||
const opts = slotOrderOptions(type);
|
||||
const helperText = find(opts, {
|
||||
value: field.value,
|
||||
})?.helperText;
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, tab: number) => setTab(tab)}
|
||||
sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}
|
||||
>
|
||||
<Tab label="Programming" />
|
||||
<Tab label="Filler" />
|
||||
</Tabs>
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Stack spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
label="Type"
|
||||
value={typeSelectValue}
|
||||
onChange={(e) =>
|
||||
handleTypeChange(e.target.value as ScheduleSlot['type'])
|
||||
}
|
||||
>
|
||||
{ProgramOptionTypes.filter(
|
||||
({ value }) => value !== 'movie',
|
||||
).map(({ value, description }) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{type === 'show' && (
|
||||
<ShowSearchSlotProgrammingForm
|
||||
show={show}
|
||||
onShowChange={onShowChange}
|
||||
onSeasonFilterChange={onSeasonFilterChange}
|
||||
seasonFilter={seasonFilter ?? []}
|
||||
/>
|
||||
)}
|
||||
{type !== 'redirect' && type !== 'flex' && (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slotConfig.order"
|
||||
render={({ field }) => {
|
||||
const opts = slotOrderOptions(type);
|
||||
const helperText = opts.find(
|
||||
(opt) => opt.value === field.value,
|
||||
)?.helperText;
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Order</InputLabel>
|
||||
<Select label="Order" {...field}>
|
||||
{opts.map(({ description, value }) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Choose how to sort the programs in this slot.
|
||||
<br />
|
||||
<Box component="ul" sx={{ pl: 1 }}>
|
||||
<li>
|
||||
<strong>Next:</strong> play in order depending on
|
||||
slot type
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ordered Shuffle:</strong> like "Next" but
|
||||
with a random start point
|
||||
</li>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
{helperText && (
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{(order === 'alphanumeric' ||
|
||||
order === 'chronological' ||
|
||||
order === 'ordered_shuffle') && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="slotConfig.direction"
|
||||
render={({ field }) => (
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
value={field.value}
|
||||
onChange={(_, value) =>
|
||||
isNonEmptyString(value)
|
||||
? field.onChange(value)
|
||||
: void 0
|
||||
}
|
||||
>
|
||||
<ToggleButton value="asc">Asc</ToggleButton>
|
||||
<ToggleButton value="desc">Desc</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Start Type</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name="anchorMode"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label="Start Type"
|
||||
{...field}
|
||||
value={field.value ?? 'dynamic'}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === 'dynamic' ? null : e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="dynamic">Dynamic</MenuItem>
|
||||
{AnchorModeSchema.options.map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{capitalize(type)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
<Box component="ul" sx={{ pl: 1 }}>
|
||||
<li>
|
||||
<strong>Dynamic:</strong> this slot begins when the
|
||||
preceeding slot ends.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anchored:</strong> begin this slot at a fixed
|
||||
time.
|
||||
</li>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
{startType === 'hard' && (
|
||||
<Stack sx={{ width: '100%' }} gap={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="anchorTime"
|
||||
render={({ field }) => (
|
||||
<TimePicker
|
||||
label="Start Time"
|
||||
{...field}
|
||||
value={dayjs()
|
||||
.startOf('day')
|
||||
.add(field.value ?? 0)}
|
||||
onChange={(value) =>
|
||||
updateSlotTime(value, field.onChange)
|
||||
}
|
||||
slotProps={{
|
||||
textField: { fullWidth: true },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="anchorDays"
|
||||
render={({ field }) => (
|
||||
<ToggleButtonGroup
|
||||
value={field.value ?? []}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{range(0, 7).map((i) => {
|
||||
const weekday = dayjs
|
||||
.utc()
|
||||
.startOf('week')
|
||||
.add(i, 'days');
|
||||
const x = dayjs.utc().localeData().weekdaysShort()[i];
|
||||
return (
|
||||
<ToggleButton
|
||||
key={x}
|
||||
value={i}
|
||||
sx={{ flex: 1 }}
|
||||
onChange={(_, v: number) => {
|
||||
if (field.value?.includes(v)) {
|
||||
field.onChange(
|
||||
field.value.filter(
|
||||
(existing) => existing !== v,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
field.onChange(
|
||||
uniq([...(field.value ?? []), v]),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{weekday.format('ddd')}
|
||||
</ToggleButton>
|
||||
);
|
||||
})}
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="fillMode"
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Order</InputLabel>
|
||||
<Select label="Order" {...field}>
|
||||
{opts.map(({ description, value }) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{description}
|
||||
<InputLabel>Fill Mode</InputLabel>
|
||||
<Select
|
||||
label="Fill Mode"
|
||||
value={field.value}
|
||||
onChange={(e) =>
|
||||
updateFillMode(
|
||||
e.target.value as FillMode,
|
||||
field.onChange,
|
||||
)
|
||||
}
|
||||
>
|
||||
{FillModes.map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{capitalize(type)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Choose how to sort the programs in this slot.
|
||||
<br />
|
||||
<Box component="ul" sx={{ pl: 1 }}>
|
||||
<li>
|
||||
<strong>Next:</strong> play in order depending on slot
|
||||
type
|
||||
<strong>Fill:</strong> continuously pick programs from
|
||||
this slot until the next time-anchored slot.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ordered Shuffle:</strong> like "Next" but with
|
||||
a random start point
|
||||
<strong>Count:</strong> pick N programs.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Duration:</strong> pick programs in order to
|
||||
fill a specific time duration.
|
||||
</li>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
{helperText && (
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{(order === 'alphanumeric' ||
|
||||
order === 'chronological' ||
|
||||
order === 'ordered_shuffle') && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="slotConfig.direction"
|
||||
render={({ field }) => (
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
value={field.value}
|
||||
onChange={(_, value) =>
|
||||
isNonEmptyString(value) ? field.onChange(value) : void 0
|
||||
}
|
||||
>
|
||||
<ToggleButton value="asc">Asc</ToggleButton>
|
||||
<ToggleButton value="desc">Desc</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Start Type</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name="anchorMode"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label="Start Type"
|
||||
{...field}
|
||||
value={field.value ?? 'dynamic'}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === 'dynamic' ? null : e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="dynamic">Dynamic</MenuItem>
|
||||
{AnchorModeSchema.options.map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{capitalize(type)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
<Box component="ul" sx={{ pl: 1 }}>
|
||||
<li>
|
||||
<strong>Dynamic:</strong> this slot begins when the
|
||||
preceeding slot ends.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anchored:</strong> begin this slot at a fixed time.
|
||||
</li>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
{startType === 'hard' && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="anchorTime"
|
||||
render={({ field }) => (
|
||||
<TimePicker
|
||||
format="H[h] m[m] s[s]"
|
||||
{...field}
|
||||
value={dayjs()
|
||||
.startOf('day')
|
||||
.add(field.value ?? 0)}
|
||||
onChange={(value) => updateSlotTime(value, field.onChange)}
|
||||
slotProps={{
|
||||
textField: { fullWidth: true, helperText: ' ' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="fillMode"
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Fill Mode</InputLabel>
|
||||
<Select
|
||||
label="Fill Mode"
|
||||
value={field.value}
|
||||
onChange={(e) =>
|
||||
updateFillMode(e.target.value as FillMode, field.onChange)
|
||||
}
|
||||
>
|
||||
{FillModes.map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{capitalize(type)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
<Box component="ul" sx={{ pl: 1 }}>
|
||||
<li>
|
||||
<strong>Fill:</strong> continuously pick programs from
|
||||
this slot until the next time-anchored slot.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Count:</strong> pick N programs.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Duration:</strong> pick programs in order to
|
||||
fill a specific time duration.
|
||||
</li>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
{fillMode === 'count' && (
|
||||
<NumericFormControllerText
|
||||
control={control}
|
||||
name="fillValue"
|
||||
rules={{ min: fillMode === 'count' ? 1 : undefined }}
|
||||
TextFieldProps={{
|
||||
fullWidth: true,
|
||||
label: 'Count',
|
||||
helperText: ' ',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{fillMode === 'count' && (
|
||||
<NumericFormControllerText
|
||||
control={control}
|
||||
name="fillValue"
|
||||
rules={{ min: fillMode === 'count' ? 1 : undefined }}
|
||||
TextFieldProps={{
|
||||
fullWidth: true,
|
||||
label: 'Count',
|
||||
helperText: ' ',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{fillMode === 'duration' && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="fillValue"
|
||||
rules={{
|
||||
min: fillMode === 'duration' ? 1 : undefined,
|
||||
}}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<TimeField
|
||||
format="H[h] m[m] s[s]"
|
||||
{...field}
|
||||
value={dayjs().startOf('day').add(field.value)}
|
||||
onChange={(value) =>
|
||||
updateSlotTime(value, field.onChange)
|
||||
}
|
||||
label="Duration"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
error: !isNil(error),
|
||||
helperText: betterHumanize(
|
||||
dayjs.duration(field.value),
|
||||
{
|
||||
exact: true,
|
||||
style: 'full',
|
||||
},
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{fillMode === 'duration' && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="fillValue"
|
||||
rules={{
|
||||
min: fillMode === 'duration' ? 1 : undefined,
|
||||
}}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<TimeField
|
||||
format="H[h] m[m] s[s]"
|
||||
{...field}
|
||||
value={dayjs().startOf('day').add(field.value)}
|
||||
onChange={(value) =>
|
||||
updateSlotTime(value, field.onChange)
|
||||
}
|
||||
label="Duration"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
error: !isNil(error),
|
||||
helperText: betterHumanize(
|
||||
dayjs.duration(field.value),
|
||||
{
|
||||
exact: true,
|
||||
style: 'full',
|
||||
},
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={2} direction="row" justifyContent="right">
|
||||
{(isDirty || (isDirty && !isSubmitting)) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
Reset Changes
|
||||
</Button>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={1}>
|
||||
<SlotFillerDialogPanel />
|
||||
</TabPanel>
|
||||
<Stack spacing={2} direction="row" justifyContent="right">
|
||||
{(isDirty || (isDirty && !isSubmitting)) && (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isValid || isSubmitting || (!isDirty && !!schedule)}
|
||||
type="submit"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Reset Changes
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isValid || isSubmitting || (!isDirty && !!schedule)}
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</Box>
|
||||
|
||||
@@ -35,7 +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 { SlotFillerDialogPanel } from './SlotFillerDialogPanel.tsx';
|
||||
import { SlotFillerDialogPanel } from './LegacySlotFillerDialogPanel.tsx';
|
||||
|
||||
type EditRandomSlotDialogContentProps = {
|
||||
slot: SlotViewModel;
|
||||
|
||||
@@ -29,7 +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 { SlotFillerDialogPanel } from './SlotFillerDialogPanel.tsx';
|
||||
import { SlotFillerDialogPanel } from './LegacySlotFillerDialogPanel.tsx';
|
||||
import { TimeSlotConfigDialogPanel } from './TimeSlotConfigDialogPanel.tsx';
|
||||
|
||||
const DaysOfWeekMenuItems = [
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
Add,
|
||||
Delete,
|
||||
FirstPage,
|
||||
LastPage,
|
||||
LowPriority,
|
||||
Repeat,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Autocomplete,
|
||||
Button,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import type { BaseSlot } from '@tunarr/types/api';
|
||||
import { find, isEmpty, map, some } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { slotOrderOptions } from '../../helpers/slotSchedulerUtil.ts';
|
||||
import { useFillerLists } from '../../hooks/useFillerLists.ts';
|
||||
|
||||
export const SlotFillerDialogPanel = () => {
|
||||
const { control, watch } = useFormContext<BaseSlot>();
|
||||
const fillerFields = useFieldArray({ control, name: 'filler' });
|
||||
const { data: fillerLists } = useFillerLists();
|
||||
|
||||
const [chosenFillerLists, setChosenFillerLists] = useState<string[]>([]);
|
||||
|
||||
// Insanely stupid hack we have to do in order to re-render on nested value
|
||||
// set.
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = watch((value, info) => {
|
||||
if (
|
||||
value.type &&
|
||||
(value.type === 'movie' ||
|
||||
value.type === 'custom-show' ||
|
||||
value.type === 'show') &&
|
||||
info.name?.startsWith('filler')
|
||||
) {
|
||||
const fillerListIds = seq.collect(
|
||||
value.filler,
|
||||
(filler) => filler?.fillerListId,
|
||||
);
|
||||
console.log(fillerListIds);
|
||||
setChosenFillerLists(fillerListIds);
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [watch]);
|
||||
|
||||
const fillerListOptions = useMemo(() => {
|
||||
if (chosenFillerLists.length <= 1) {
|
||||
return fillerLists;
|
||||
}
|
||||
|
||||
return fillerLists.filter(
|
||||
(list) => !some(chosenFillerLists, (field) => field === list.id),
|
||||
);
|
||||
}, [chosenFillerLists, fillerLists]);
|
||||
|
||||
const handleAddNewFillerList = useCallback(() => {
|
||||
const unselected = fillerLists.find(
|
||||
(list) => !some(chosenFillerLists, (field) => field === list.id),
|
||||
);
|
||||
if (!unselected) {
|
||||
return;
|
||||
}
|
||||
|
||||
fillerFields.append({
|
||||
types: ['pre'],
|
||||
fillerListId: unselected.id,
|
||||
fillerOrder: 'shuffle_prefer_short',
|
||||
});
|
||||
}, [fillerLists, fillerFields, chosenFillerLists]);
|
||||
|
||||
if (fillerLists.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
variant="outlined"
|
||||
disabled={isEmpty(fillerListOptions)}
|
||||
onClick={handleAddNewFillerList}
|
||||
>
|
||||
Add filler
|
||||
</Button>
|
||||
{fillerFields.fields.map((fillerField, idx) => (
|
||||
<>
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
key={fillerField.id}
|
||||
sx={{ alignItems: 'center' }}
|
||||
>
|
||||
<Grid size={{ xs: 4 }}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`filler.${idx}.fillerListId` as const}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
disableClearable={true}
|
||||
options={fillerListOptions}
|
||||
getOptionKey={(list) => list.id}
|
||||
getOptionLabel={(list) => list.name}
|
||||
value={find(fillerLists, { id: field.value })}
|
||||
onChange={(_, list) => field.onChange(list?.id)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Filler List" />
|
||||
)}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size="auto">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`filler.${idx}.types`}
|
||||
rules={{ validate: { nonempty: (v) => (v ?? []).length > 0 } }}
|
||||
render={({ field }) => (
|
||||
<ToggleButtonGroup
|
||||
key={fillerField.id}
|
||||
value={field.value}
|
||||
onChange={(_, formats) => field.onChange(formats)}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<ToggleButton value="head">
|
||||
<FirstPage />
|
||||
Head
|
||||
</ToggleButton>
|
||||
<ToggleButton value={'pre'}>
|
||||
<LowPriority
|
||||
sx={{
|
||||
rotate: '180deg',
|
||||
transform: 'scale(-1, 1)',
|
||||
mr: 1,
|
||||
}}
|
||||
/>{' '}
|
||||
Pre
|
||||
</ToggleButton>
|
||||
<ToggleButton value="post">
|
||||
<LowPriority sx={{ mr: 1 }} /> Post
|
||||
</ToggleButton>
|
||||
<ToggleButton value="tail">
|
||||
<LastPage sx={{ mr: 1 }} /> Tail
|
||||
</ToggleButton>
|
||||
<ToggleButton value="fallback">
|
||||
<Repeat /> Fallback
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size="auto" offset="auto" justifyContent="flex-end">
|
||||
<IconButton
|
||||
onClick={() => fillerFields.remove(idx)}
|
||||
disableRipple
|
||||
>
|
||||
<Delete />{' '}
|
||||
</IconButton>
|
||||
</Grid>
|
||||
{/* <Box sx={{ flexBasis: '100%', height: 0, p: 0, m: 0 }}></Box> */}
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Stack direction="row" flex={1} sx={{ ml: 3 }}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`filler.${idx}.fillerOrder`}
|
||||
render={({ field }) => {
|
||||
const opts = slotOrderOptions('filler');
|
||||
const helperText = find(opts, {
|
||||
value: field.value,
|
||||
})?.helperText;
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Order</InputLabel>
|
||||
<Select label="Order" {...field}>
|
||||
{map(opts, ({ description, value }) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{helperText && (
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{idx < fillerFields.fields.length - 1 && <Divider />}
|
||||
</>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -57,13 +57,6 @@ export const ShowSearchSlotProgrammingForm = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <Controller
|
||||
control={control}
|
||||
name="showId"
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
)}
|
||||
/> */}
|
||||
<ProgramSearchAutocomplete
|
||||
value={show}
|
||||
searchQuery={search}
|
||||
@@ -71,19 +64,10 @@ export const ShowSearchSlotProgrammingForm = ({
|
||||
includeItem={(item) => item.type === 'show'}
|
||||
onChange={(show) => {
|
||||
onShowChange(show);
|
||||
// field.onChange(show.uuid);
|
||||
// setValue('show', show);
|
||||
// setValue('seasonFilter', []);
|
||||
}}
|
||||
onQueryChange={setSearchQuery}
|
||||
label="Show"
|
||||
/>
|
||||
{/* <Controller
|
||||
control={control}
|
||||
name="seasonFilter"
|
||||
render={({ field }) => (
|
||||
)}
|
||||
/> */}
|
||||
<Autocomplete
|
||||
options={seasonAutocompleteOpts}
|
||||
value={
|
||||
@@ -105,13 +89,7 @@ export const ShowSearchSlotProgrammingForm = ({
|
||||
helperText="Optionally schedule only specific seasons of the selected show"
|
||||
/>
|
||||
)}
|
||||
onChange={
|
||||
(_, seasons) => onSeasonFilterChange(seasons)
|
||||
// setValue(
|
||||
// 'seasonFilter',
|
||||
// seasons.map((s) => s.index),
|
||||
// )
|
||||
}
|
||||
onChange={(_, seasons) => onSeasonFilterChange(seasons)}
|
||||
filterSelectedOptions
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
FormControl,
|
||||
@@ -23,77 +24,245 @@ import {
|
||||
ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import type { BaseSlot } from '@tunarr/types/api';
|
||||
import { find, isEmpty, map, some } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type {
|
||||
BaseSlot,
|
||||
FillerPlaybackMode,
|
||||
SlotFiller,
|
||||
} from '@tunarr/types/api';
|
||||
import { SlotFillerTypes } from '@tunarr/types/api';
|
||||
import { find, isEmpty, map } from 'lodash-es';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { slotOrderOptions } from '../../helpers/slotSchedulerUtil.ts';
|
||||
import { useFillerLists } from '../../hooks/useFillerLists.ts';
|
||||
|
||||
const ALL_FILLER_TYPES = SlotFillerTypes.options;
|
||||
|
||||
type RowProps = {
|
||||
index: number;
|
||||
};
|
||||
|
||||
const SlotFillerOrder = ({ index }: RowProps) => {
|
||||
const { control, watch } = useFormContext<{
|
||||
fillerConfig: { fillers: SlotFiller[] };
|
||||
}>();
|
||||
|
||||
return (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Stack direction="row" flex={1} sx={{ ml: 3 }} spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`fillerConfig.fillers.${index}.fillerOrder`}
|
||||
render={({ field }) => {
|
||||
const opts = slotOrderOptions('filler');
|
||||
const helperText = find(opts, {
|
||||
value: field.value,
|
||||
})?.helperText;
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Order</InputLabel>
|
||||
<Select label="Order" {...field}>
|
||||
{map(opts, ({ description, value }) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{helperText && <FormHelperText>{helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`fillerConfig.fillers.${index}.playbackMode`}
|
||||
render={({ field }) => {
|
||||
const mode = (field.value ?? {
|
||||
type: 'relaxed',
|
||||
}) as FillerPlaybackMode;
|
||||
return (
|
||||
<>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Playback Mode</InputLabel>
|
||||
<Select
|
||||
label="Playback Mode"
|
||||
value={mode.type}
|
||||
onChange={(e) => {
|
||||
const t = e.target.value as FillerPlaybackMode['type'];
|
||||
switch (t) {
|
||||
case 'relaxed':
|
||||
field.onChange({ type: 'relaxed' });
|
||||
break;
|
||||
case 'count':
|
||||
field.onChange({ type: 'count', count: 1 });
|
||||
break;
|
||||
case 'duration':
|
||||
field.onChange({
|
||||
type: 'duration',
|
||||
durationMs: 30000,
|
||||
});
|
||||
break;
|
||||
case 'random_count':
|
||||
field.onChange({ type: 'random_count' });
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="relaxed">Relaxed</MenuItem>
|
||||
<MenuItem value="count">Count</MenuItem>
|
||||
<MenuItem value="duration">Duration</MenuItem>
|
||||
<MenuItem value="random_count">Random Count</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
<Box component="ul" sx={{ pl: 1 }}>
|
||||
<li>
|
||||
<strong>Relaxed:</strong> play filler freely until the
|
||||
slot's remaining time is filled.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Count:</strong> play exactly N filler items.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Duration:</strong> play filler up to a fixed
|
||||
time duration.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Random Count:</strong> play a random number of
|
||||
filler items between an optional min and max.
|
||||
</li>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
{mode.type === 'count' && (
|
||||
<TextField
|
||||
label="Count"
|
||||
type="number"
|
||||
value={mode.count}
|
||||
slotProps={{
|
||||
htmlInput: { min: 1 },
|
||||
}}
|
||||
onChange={(e) =>
|
||||
field.onChange({
|
||||
type: 'count',
|
||||
count: Math.max(1, parseInt(e.target.value, 10) || 1),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{mode.type === 'duration' && (
|
||||
<TextField
|
||||
label="Duration (seconds)"
|
||||
type="number"
|
||||
value={mode.durationMs / 1000}
|
||||
slotProps={{
|
||||
htmlInput: { min: 1 },
|
||||
}}
|
||||
onChange={(e) =>
|
||||
field.onChange({
|
||||
type: 'duration',
|
||||
durationMs: Math.max(
|
||||
1000,
|
||||
(parseFloat(e.target.value) || 1) * 1000,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{mode.type === 'random_count' && (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
label="Min"
|
||||
type="number"
|
||||
value={mode.min ?? ''}
|
||||
placeholder="1"
|
||||
slotProps={{
|
||||
htmlInput: { min: 1 },
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
field.onChange({
|
||||
...mode,
|
||||
min: isNaN(val) ? undefined : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Max"
|
||||
type="number"
|
||||
value={mode.max ?? ''}
|
||||
placeholder="all"
|
||||
slotProps={{
|
||||
htmlInput: { min: 1 },
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
field.onChange({
|
||||
...mode,
|
||||
max: isNaN(val) ? undefined : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const SlotFillerDialogPanel = () => {
|
||||
const { control, watch } = useFormContext<BaseSlot>();
|
||||
const fillerFields = useFieldArray({ control, name: 'filler' });
|
||||
const { control, watch } = useFormContext<{
|
||||
fillerConfig: { fillers: SlotFiller[] };
|
||||
type: BaseSlot['type'];
|
||||
}>();
|
||||
const fillerFields = useFieldArray({ control, name: 'fillerConfig.fillers' });
|
||||
const { data: fillerLists } = useFillerLists();
|
||||
|
||||
const [chosenFillerLists, setChosenFillerLists] = useState<string[]>([]);
|
||||
const fillerFieldValues = watch('fillerConfig.fillers');
|
||||
|
||||
// Insanely stupid hack we have to do in order to re-render on nested value
|
||||
// set.
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = watch((value, info) => {
|
||||
if (
|
||||
value.type &&
|
||||
(value.type === 'movie' ||
|
||||
value.type === 'custom-show' ||
|
||||
value.type === 'show') &&
|
||||
info.name?.startsWith('filler')
|
||||
) {
|
||||
const fillerListIds = seq.collect(
|
||||
value.filler,
|
||||
(filler) => filler?.fillerListId,
|
||||
);
|
||||
setChosenFillerLists(fillerListIds);
|
||||
// Set of "listId:type" combos already in use
|
||||
const usedCombos = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
seq.collect(fillerFieldValues, (f) =>
|
||||
f?.fillerListId && f?.type ? `${f.fillerListId}:${f.type}` : null,
|
||||
),
|
||||
),
|
||||
[fillerFieldValues],
|
||||
);
|
||||
|
||||
const maxCombos = fillerLists.length * ALL_FILLER_TYPES.length;
|
||||
|
||||
const handleAddNewFillerEntry = useCallback(() => {
|
||||
for (const list of fillerLists) {
|
||||
for (const type of ALL_FILLER_TYPES) {
|
||||
if (!usedCombos.has(`${list.id}:${type}`)) {
|
||||
fillerFields.append({
|
||||
type,
|
||||
fillerListId: list.id,
|
||||
fillerOrder: 'shuffle_prefer_short',
|
||||
playbackMode: { type: 'relaxed' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [watch]);
|
||||
|
||||
const fillerListOptions = useMemo(() => {
|
||||
if (chosenFillerLists.length <= 1) {
|
||||
return fillerLists;
|
||||
}
|
||||
|
||||
return fillerLists.filter(
|
||||
(list) => !some(chosenFillerLists, (field) => field === list.id),
|
||||
);
|
||||
}, [chosenFillerLists, fillerLists]);
|
||||
|
||||
const handleAddNewFillerList = useCallback(() => {
|
||||
const unselected = fillerLists.find(
|
||||
(list) => !some(chosenFillerLists, (field) => field === list.id),
|
||||
);
|
||||
if (!unselected) {
|
||||
return;
|
||||
}
|
||||
|
||||
fillerFields.append({
|
||||
types: ['pre'],
|
||||
fillerListId: unselected.id,
|
||||
fillerOrder: 'shuffle_prefer_short',
|
||||
});
|
||||
}, [fillerLists, fillerFields, chosenFillerLists]);
|
||||
}, [fillerLists, usedCombos, fillerFields]);
|
||||
|
||||
if (fillerLists.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={2} sx={{ mb: 2 }}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
variant="outlined"
|
||||
disabled={isEmpty(fillerListOptions)}
|
||||
onClick={handleAddNewFillerList}
|
||||
disabled={isEmpty(fillerLists) || usedCombos.size >= maxCombos}
|
||||
onClick={handleAddNewFillerEntry}
|
||||
>
|
||||
Add filler
|
||||
</Button>
|
||||
@@ -108,13 +277,13 @@ export const SlotFillerDialogPanel = () => {
|
||||
<Grid size={{ xs: 4 }}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`filler.${idx}.fillerListId` as const}
|
||||
name={`fillerConfig.fillers.${idx}.fillerListId` as const}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
disableClearable
|
||||
options={fillerListOptions}
|
||||
options={fillerLists}
|
||||
getOptionKey={(list) => list.id}
|
||||
getOptionLabel={(list) => list.name}
|
||||
value={find(fillerLists, { id: field.value })}
|
||||
@@ -130,13 +299,16 @@ export const SlotFillerDialogPanel = () => {
|
||||
<Grid size="auto">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`filler.${idx}.types`}
|
||||
rules={{ validate: { nonempty: (v) => (v ?? []).length > 0 } }}
|
||||
name={`fillerConfig.fillers.${idx}.type`}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<ToggleButtonGroup
|
||||
key={fillerField.id}
|
||||
exclusive
|
||||
value={field.value}
|
||||
onChange={(_, formats) => field.onChange(formats)}
|
||||
onChange={(_, value) => {
|
||||
if (value !== null) field.onChange(value);
|
||||
}}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<ToggleButton value="head">
|
||||
@@ -174,36 +346,7 @@ export const SlotFillerDialogPanel = () => {
|
||||
<Delete />{' '}
|
||||
</IconButton>
|
||||
</Grid>
|
||||
{/* <Box sx={{ flexBasis: '100%', height: 0, p: 0, m: 0 }}></Box> */}
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Stack direction="row" flex={1} sx={{ ml: 3 }}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`filler.${idx}.fillerOrder`}
|
||||
render={({ field }) => {
|
||||
const opts = slotOrderOptions('filler');
|
||||
const helperText = find(opts, {
|
||||
value: field.value,
|
||||
})?.helperText;
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Order</InputLabel>
|
||||
<Select label="Order" {...field}>
|
||||
{map(opts, ({ description, value }) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{helperText && (
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<SlotFillerOrder index={idx} />
|
||||
</Grid>
|
||||
{idx < fillerFields.fields.length - 1 && <Divider />}
|
||||
</>
|
||||
|
||||
@@ -316,7 +316,7 @@ export const TimeSlotTable = () => {
|
||||
},
|
||||
Cell: ({ cell }) => {
|
||||
const filler = cell.getValue<SlotFiller[]>();
|
||||
const fillerKinds = uniq(filler.flatMap((f) => f.types));
|
||||
const fillerKinds = uniq(filler.map((f) => f.type));
|
||||
if (fillerKinds.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
84
web/src/helpers/converters.ts
Normal file
84
web/src/helpers/converters.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import type { FillerProgram } from '@tunarr/types';
|
||||
import type { InfiniteScheduleGenerationResponse } from '@tunarr/types/api';
|
||||
import { match, P } from 'ts-pattern';
|
||||
import type {
|
||||
UIChannelProgram,
|
||||
UIFlexProgram,
|
||||
UIRedirectProgram,
|
||||
} from '../types/index.ts';
|
||||
import type { Nilable } from '../types/util.ts';
|
||||
import { isNonEmptyString } from './util.ts';
|
||||
|
||||
export function scheduleGenerationResponseToLineupList(
|
||||
response: InfiniteScheduleGenerationResponse,
|
||||
): UIChannelProgram[] {
|
||||
return seq.collect(response?.items, (scheduleItem, idx) => {
|
||||
return (
|
||||
match(scheduleItem)
|
||||
.returnType<Nilable<UIChannelProgram>>()
|
||||
.with(
|
||||
{ itemType: 'content', programUuid: P.when(isNonEmptyString) },
|
||||
(c) => {
|
||||
const program = response?.contentPrograms[c.programUuid];
|
||||
if (!program) return;
|
||||
return {
|
||||
...program,
|
||||
uiIndex: idx,
|
||||
originalIndex: idx,
|
||||
startTime: c.startTimeMs,
|
||||
} satisfies UIChannelProgram;
|
||||
},
|
||||
)
|
||||
.with(
|
||||
{ itemType: 'filler', programUuid: P.when(isNonEmptyString) },
|
||||
(f) => {
|
||||
const program = response?.contentPrograms[f.programUuid];
|
||||
if (!program) return null;
|
||||
return {
|
||||
type: 'filler' as const,
|
||||
id: f.programUuid,
|
||||
fillerListId: f.fillerListId,
|
||||
program,
|
||||
duration: f.durationMs,
|
||||
persisted: true,
|
||||
uiIndex: idx,
|
||||
originalIndex: idx,
|
||||
startTime: f.startTimeMs,
|
||||
} satisfies UIChannelProgram<FillerProgram>;
|
||||
},
|
||||
)
|
||||
.with(
|
||||
{ itemType: 'flex' },
|
||||
(f) =>
|
||||
({
|
||||
duration: f.durationMs,
|
||||
originalIndex: idx,
|
||||
uiIndex: idx,
|
||||
persisted: false,
|
||||
type: 'flex',
|
||||
startTime: f.startTimeMs,
|
||||
}) satisfies UIFlexProgram,
|
||||
)
|
||||
// TODO: materialize redirect
|
||||
.with(
|
||||
{ itemType: 'redirect' },
|
||||
(rdir) =>
|
||||
({
|
||||
...rdir,
|
||||
type: 'redirect',
|
||||
channelName: '',
|
||||
channelNumber: -1,
|
||||
channel: rdir.redirectChannelId,
|
||||
duration: rdir.durationMs,
|
||||
uiIndex: idx,
|
||||
originalIndex: idx,
|
||||
persisted: false,
|
||||
startTime: rdir.startTimeMs,
|
||||
}) satisfies UIRedirectProgram,
|
||||
)
|
||||
// TODO: materialize other item types
|
||||
.otherwise(() => null)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function channelProgramUniqueId(program: ChannelProgram): string {
|
||||
case 'redirect':
|
||||
return `redirect.${program.channel}`;
|
||||
case 'filler':
|
||||
return `filler.${program.fillerListId}`;
|
||||
return `filler.${program.fillerListId}.${program.id}`;
|
||||
case 'flex':
|
||||
return 'flex';
|
||||
}
|
||||
@@ -349,3 +349,21 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
|
||||
return `${isNegative ? '-' : ''}${formatted} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function addIndexesAndCalculateOffsets<T extends { duration: number }>(
|
||||
items: T[],
|
||||
firstOffset: number = 0,
|
||||
firstIndex: number = 0,
|
||||
): (T & UIIndex & { startTimeOffset: number })[] {
|
||||
let runningOffset = firstOffset;
|
||||
return map(items, (item, index) => {
|
||||
const newItem = {
|
||||
...item,
|
||||
originalIndex: firstIndex + index,
|
||||
uiIndex: firstIndex + index,
|
||||
startTimeOffset: runningOffset,
|
||||
};
|
||||
runningOffset += item.duration;
|
||||
return newItem;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,5 +2,11 @@ import { DayjsContext } from '@/providers/DayjsContext';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export const useDayjs = () => {
|
||||
return useContext(DayjsContext).dayjs;
|
||||
return useContext(DayjsContext);
|
||||
};
|
||||
|
||||
export const useDayjsUtc = () => {};
|
||||
|
||||
export const useLocaleData = () => {
|
||||
return useDayjs()().localeData();
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ ColorSpace.register(sRGB);
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(localeData);
|
||||
dayjs.locale('en-gb');
|
||||
dayjs.locale('en-us');
|
||||
|
||||
// Initialize the languages database with English names
|
||||
// TODO: localize this and make it a context provider
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RandomSlotForm } from './SlotModels.ts';
|
||||
|
||||
import {
|
||||
BaseSlotOrdering,
|
||||
SlotFiller,
|
||||
LegacySlotFiller,
|
||||
SlotProgrammingFillerOrder,
|
||||
} from '@tunarr/types/api';
|
||||
import {
|
||||
@@ -16,7 +16,7 @@ import z from 'zod';
|
||||
import type { TimeSlotForm } from './TimeSlotModels.ts';
|
||||
|
||||
export const WithSlotFiller = z.object({
|
||||
filler: z.array(SlotFiller).optional(),
|
||||
filler: z.array(LegacySlotFiller).optional(),
|
||||
});
|
||||
|
||||
export const CommonMovieSlotViewModel = z.object({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect } from 'react';
|
||||
import { RouterButtonLink } from '../../components/base/RouterButtonLink.tsx';
|
||||
import { RouterLink } from '../../components/base/RouterLink.tsx';
|
||||
import { ChannelProgrammingConfig } from '../../components/channel_config/ChannelProgrammingConfig.tsx';
|
||||
import { ChannelScheduleViewer } from '../../components/channel_config/ChannelScheduleViewer.tsx';
|
||||
import UnsavedNavigationAlert from '../../components/settings/UnsavedNavigationAlert.tsx';
|
||||
import { resetLineup } from '../../store/channelEditor/actions.ts';
|
||||
import { useChannelEditor } from '../../store/selectors.ts';
|
||||
@@ -71,20 +72,18 @@ export default function ChannelProgrammingPage() {
|
||||
channel stop adhering to that schedule.
|
||||
</Alert>
|
||||
)}
|
||||
<Paper sx={{ p: 2 }}>
|
||||
{channel.scheduleId ? (
|
||||
<Box></Box>
|
||||
) : (
|
||||
<>
|
||||
<ChannelProgrammingConfig />
|
||||
<UnsavedNavigationAlert
|
||||
isDirty={programsDirty}
|
||||
exceptTargetPaths={['/channels/$channelId/programming/add']}
|
||||
onProceed={() => resetLineup()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
{channel.scheduleId ? (
|
||||
<ChannelScheduleViewer />
|
||||
) : (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<ChannelProgrammingConfig />
|
||||
<UnsavedNavigationAlert
|
||||
isDirty={programsDirty}
|
||||
exceptTargetPaths={['/channels/$channelId/programming/add']}
|
||||
onProceed={() => resetLineup()}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Preview } from '@mui/icons-material';
|
||||
import { Box, Button, Stack } from '@mui/material';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { isNonEmptyString, seq } from '@tunarr/shared/util';
|
||||
import type {
|
||||
InfiniteSchedulePreviewResponse,
|
||||
InfiniteScheduleGenerationResponse,
|
||||
MaterializedSchedule2,
|
||||
} from '@tunarr/types/api';
|
||||
import { useToggle } from '@uidotdev/usehooks';
|
||||
@@ -11,16 +10,11 @@ import dayjs from 'dayjs';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import pluralize from 'pluralize';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { match, P } from 'ts-pattern';
|
||||
import { RotatingLoopIcon } from '../../components/base/LoadingIcon.tsx';
|
||||
import ChannelLineupList from '../../components/channel_config/ChannelLineupList.tsx';
|
||||
import { previewScheduleMutation } from '../../generated/@tanstack/react-query.gen.ts';
|
||||
import type {
|
||||
UIChannelProgram,
|
||||
UIFlexProgram,
|
||||
UIRedirectProgram,
|
||||
} from '../../types/index.ts';
|
||||
import type { Nilable } from '../../types/util.ts';
|
||||
|
||||
import { scheduleGenerationResponseToLineupList } from '../../helpers/converters.ts';
|
||||
import { type Nullable } from '../../types/util.ts';
|
||||
|
||||
type Props = {
|
||||
@@ -29,7 +23,7 @@ type Props = {
|
||||
|
||||
export const SchedulePreview = ({ schedule }: Props) => {
|
||||
const [preview, setPreview] =
|
||||
useState<Nullable<InfiniteSchedulePreviewResponse>>(null);
|
||||
useState<Nullable<InfiniteScheduleGenerationResponse>>(null);
|
||||
const previewMutation = useMutation({
|
||||
...previewScheduleMutation(),
|
||||
onSuccess(data) {
|
||||
@@ -90,53 +84,11 @@ export const SchedulePreview = ({ schedule }: Props) => {
|
||||
}, [previewMutation, schedule.uuid, setIsCalculatingPreview, snackbar]);
|
||||
|
||||
const programList = useMemo(() => {
|
||||
return seq.collect(preview?.items, (scheduleItem, idx) => {
|
||||
return match(scheduleItem)
|
||||
.returnType<Nilable<UIChannelProgram>>()
|
||||
.with(
|
||||
{ itemType: 'content', programUuid: P.when(isNonEmptyString) },
|
||||
(c) => {
|
||||
const program = preview?.contentPrograms[c.programUuid];
|
||||
if (!program) return;
|
||||
return {
|
||||
...program,
|
||||
uiIndex: idx,
|
||||
originalIndex: idx,
|
||||
startTime: c.startTimeMs,
|
||||
} satisfies UIChannelProgram;
|
||||
},
|
||||
)
|
||||
.with(
|
||||
{ itemType: 'flex' },
|
||||
(f) =>
|
||||
({
|
||||
duration: f.durationMs,
|
||||
originalIndex: idx,
|
||||
uiIndex: idx,
|
||||
persisted: false,
|
||||
type: 'flex',
|
||||
startTime: f.startTimeMs,
|
||||
}) satisfies UIFlexProgram,
|
||||
)
|
||||
.with(
|
||||
{ itemType: 'redirect' },
|
||||
(rdir) =>
|
||||
({
|
||||
...rdir,
|
||||
type: 'redirect',
|
||||
channelName: '',
|
||||
channelNumber: -1,
|
||||
channel: rdir.redirectChannelId,
|
||||
duration: rdir.durationMs,
|
||||
uiIndex: idx,
|
||||
originalIndex: idx,
|
||||
persisted: false,
|
||||
startTime: rdir.startTimeMs,
|
||||
}) satisfies UIRedirectProgram,
|
||||
)
|
||||
.otherwise(() => null);
|
||||
});
|
||||
}, [preview?.contentPrograms, preview?.items]);
|
||||
if (!preview) {
|
||||
return [];
|
||||
}
|
||||
return scheduleGenerationResponseToLineupList(preview);
|
||||
}, [preview]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Edit } from '@mui/icons-material';
|
||||
import { Stack } from '@mui/material';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import { prettifySnakeCaseString } from '@tunarr/shared/util';
|
||||
import type {
|
||||
MaterializedSchedule2,
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
MaterialReactTable,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table';
|
||||
import { useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { FieldArrayWithId } from 'react-hook-form';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { v4 } from 'uuid';
|
||||
import {
|
||||
@@ -26,11 +27,17 @@ type Props = {
|
||||
schedule: MaterializedSchedule2;
|
||||
};
|
||||
|
||||
type SlotRowType = FieldArrayWithId<Schedule, 'slots'>;
|
||||
|
||||
export const ScheduleSlotTable = ({ schedule }: Props) => {
|
||||
const scheduleForm = useFormContext<Schedule>();
|
||||
const [slotPlaybackOrder] = scheduleForm.watch(['slotPlaybackOrder']);
|
||||
const slotArray = useFieldArray({
|
||||
control: scheduleForm.control,
|
||||
name: 'slots',
|
||||
});
|
||||
|
||||
const columns = useMemo((): MRT_ColumnDef<MaterializedScheduleSlot>[] => {
|
||||
const columns = useMemo((): MRT_ColumnDef<SlotRowType>[] => {
|
||||
return [
|
||||
{
|
||||
header: 'Type',
|
||||
@@ -55,7 +62,6 @@ export const ScheduleSlotTable = ({ schedule }: Props) => {
|
||||
{ type: 'smart-collection' },
|
||||
(sc) => sc.smartCollection?.name,
|
||||
)
|
||||
.with({ type: 'movie' }, () => 'Movie')
|
||||
.with({ type: 'filler' }, () => 'Filler')
|
||||
.with({ type: 'redirect' }, (r) => r.channel?.name)
|
||||
.exhaustive();
|
||||
@@ -90,19 +96,27 @@ export const ScheduleSlotTable = ({ schedule }: Props) => {
|
||||
Cell({ row: { original } }) {
|
||||
const mode = original.anchorMode;
|
||||
const time = original.anchorTime;
|
||||
const days = original.anchorDays ?? [];
|
||||
if (!mode || !time) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const utc = dayjs().utc();
|
||||
const dayStrs: string[] = [];
|
||||
for (const day of days) {
|
||||
dayStrs.push(utc.weekday(day).format('dd'));
|
||||
}
|
||||
const dayStr = dayStrs.length > 0 ? ` ${dayStrs.join(',')}` : '';
|
||||
|
||||
const pretty = capitalize(mode);
|
||||
return `${pretty} @ ${dayjs().startOf('day').add(time).format('LT')}`;
|
||||
return `${pretty} @ ${dayjs().startOf('day').add(time).format('LT')}${dayStr}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
data: schedule.slots,
|
||||
data: slotArray.fields,
|
||||
columns,
|
||||
getRowId: (row) => row.uuid ?? v4(),
|
||||
enableRowActions: true,
|
||||
@@ -111,6 +125,7 @@ export const ScheduleSlotTable = ({ schedule }: Props) => {
|
||||
enableFullScreenToggle: false,
|
||||
enableRowDragging: slotPlaybackOrder === 'ordered',
|
||||
enableSorting: slotPlaybackOrder !== 'ordered',
|
||||
enableRowOrdering: slotPlaybackOrder === 'ordered',
|
||||
renderRowActions: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
@@ -130,12 +145,12 @@ export const ScheduleSlotTable = ({ schedule }: Props) => {
|
||||
visibleInShowHideMenu: false,
|
||||
},
|
||||
},
|
||||
muiRowDragHandleProps: () => ({
|
||||
muiRowDragHandleProps: ({ table }) => ({
|
||||
onDragEnd: () => {
|
||||
// const { draggingRow, hoveredRow } = table.getState();
|
||||
// if (hoveredRow && draggingRow && !isUndefined(hoveredRow.index)) {
|
||||
// prefFields.swap(hoveredRow.index, draggingRow.index);
|
||||
// }
|
||||
const { draggingRow, hoveredRow } = table.getState();
|
||||
if (hoveredRow && draggingRow) {
|
||||
slotArray.swap(hoveredRow.index!, draggingRow.index);
|
||||
}
|
||||
},
|
||||
}),
|
||||
renderTopToolbarCustomActions() {
|
||||
@@ -163,5 +178,18 @@ export const ScheduleSlotTable = ({ schedule }: Props) => {
|
||||
}),
|
||||
});
|
||||
|
||||
return <MaterialReactTable table={table} />;
|
||||
const stopBubble: React.DragEventHandler = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
onDragStart={stopBubble}
|
||||
onDragEnter={stopBubble}
|
||||
onDragOver={stopBubble}
|
||||
onDrop={stopBubble}
|
||||
>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import originalDayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
|
||||
const defaultContextType: ContextType = {
|
||||
dayjs(date?: originalDayjs.ConfigType) {
|
||||
return originalDayjs(date);
|
||||
},
|
||||
};
|
||||
const defaultContextType: typeof originalDayjs = originalDayjs;
|
||||
|
||||
export const DayjsContext =
|
||||
React.createContext<ContextType>(defaultContextType);
|
||||
React.createContext<typeof originalDayjs>(defaultContextType);
|
||||
|
||||
export type ContextType = {
|
||||
dayjs: (date?: originalDayjs.ConfigType) => originalDayjs.Dayjs;
|
||||
};
|
||||
// export type ContextType = {
|
||||
// dayjs: (date?: originalDayjs.ConfigType) => originalDayjs.Dayjs;
|
||||
// };
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import useStore from '@/store';
|
||||
import originalDayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import React, { useMemo } from 'react';
|
||||
import type { ContextType } from './DayjsContext.tsx';
|
||||
import { DayjsContext } from './DayjsContext.tsx';
|
||||
|
||||
originalDayjs.extend(utc);
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
};
|
||||
@@ -12,11 +14,12 @@ export const DayjsProvider = ({ children }: Props) => {
|
||||
const locale = useStore((store) => store.settings.ui.i18n.locale);
|
||||
const value = useMemo(() => {
|
||||
originalDayjs.locale(locale);
|
||||
return {
|
||||
dayjs: (date?: originalDayjs.ConfigType) => {
|
||||
return originalDayjs(date);
|
||||
},
|
||||
} satisfies ContextType;
|
||||
return originalDayjs;
|
||||
// return {
|
||||
// dayjs: (date?: originalDayjs.ConfigType) => {
|
||||
// return originalDayjs(date);
|
||||
// },
|
||||
// } satisfies ContextType;
|
||||
}, [locale]);
|
||||
return (
|
||||
<DayjsContext.Provider value={value}>{children}</DayjsContext.Provider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, notFound } from '@tanstack/react-router';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { getScheduleByIdOptions } from '../../../generated/@tanstack/react-query.gen.ts';
|
||||
@@ -24,6 +25,9 @@ export const Route = createFileRoute('/schedules_/$scheduleId/')({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const schedule = Route.useLoaderData();
|
||||
const params = Route.useParams();
|
||||
const { data: schedule } = useSuspenseQuery({
|
||||
...getScheduleByIdOptions({ path: { id: params.scheduleId } }),
|
||||
});
|
||||
return <EditSchedulePage schedule={schedule} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unwrapNil } from '@/helpers/util.ts';
|
||||
import { addIndexesAndCalculateOffsets, unwrapNil } from '@/helpers/util.ts';
|
||||
import { ApiProgramMinter } from '@tunarr/shared';
|
||||
import { forProgramType, seq } from '@tunarr/shared/util';
|
||||
import {
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
isNil,
|
||||
isUndefined,
|
||||
last,
|
||||
map,
|
||||
mapValues,
|
||||
omitBy,
|
||||
sumBy,
|
||||
@@ -26,11 +25,7 @@ import {
|
||||
} from 'lodash-es';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { Emby, Imported, Jellyfin, Plex } from '../../helpers/constants.ts';
|
||||
import {
|
||||
type AddedMedia,
|
||||
type UIChannelProgram,
|
||||
type UIIndex,
|
||||
} from '../../types/index.ts';
|
||||
import { type AddedMedia, type UIChannelProgram } from '../../types/index.ts';
|
||||
import type { State } from '../index.ts';
|
||||
import useStore from '../index.ts';
|
||||
import { initialChannelEditorState } from './store.ts';
|
||||
@@ -52,24 +47,6 @@ export const resetChannelEditorState = () =>
|
||||
return newState;
|
||||
});
|
||||
|
||||
function addIndexesAndCalculateOffsets<T extends { duration: number }>(
|
||||
items: T[],
|
||||
firstOffset: number = 0,
|
||||
firstIndex: number = 0,
|
||||
): (T & UIIndex & { startTimeOffset: number })[] {
|
||||
let runningOffset = firstOffset;
|
||||
return map(items, (item, index) => {
|
||||
const newItem = {
|
||||
...item,
|
||||
originalIndex: firstIndex + index,
|
||||
uiIndex: firstIndex + index,
|
||||
startTimeOffset: runningOffset,
|
||||
};
|
||||
runningOffset += item.duration;
|
||||
return newItem;
|
||||
});
|
||||
}
|
||||
|
||||
function updateProgramList(
|
||||
state: Draft<State>,
|
||||
programming: CondensedChannelProgramming,
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"./src/router.d.ts",
|
||||
"./src/components/slot_scheduler/LegacySlotFillerEditor.tsx",
|
||||
],
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
|
||||
Reference in New Issue
Block a user