checkpoint

This commit is contained in:
Christian Benincasa
2026-03-10 21:29:43 -04:00
parent dcf9947675
commit 7b0133cac2
44 changed files with 3617 additions and 1801 deletions

2
.gitignore vendored
View File

@@ -41,4 +41,4 @@ tunarr-openapi.json
web/.tanstack
:memory:*
.serena/
.serena/

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,6 +144,7 @@ export class SlotSchedulerHelper {
continue;
}
seenContentIds.add(program.uuid);
slotPrograms.push({
...program,
parentCustomShows: customShowContexts[program.uuid] ?? [],

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -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') {

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,7 @@
"vite.config.ts",
"vitest.config.ts",
"./src/router.d.ts",
"./src/components/slot_scheduler/LegacySlotFillerEditor.tsx",
],
"include": [
"./src/**/*.ts",