Files
ersatztv/web/src/schedules/itemRules.ts
T
timothyandClaude Opus 4.8 d2d678aae8
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m10s
test/docs(404): add unrelated-edit round-trip test; correct MultiCollection-only rationale
Review + live-E2E follow-ups (no behavior change):
- Add a test proving the canonical silent-reset trap directly: a rename-only save
  round-trips both weights untouched (the prior test only edited the weight it
  asserted). Cold review nit.
- Correct the rationale in itemRules.ts + decisions.md: WeightedShuffle is
  MultiCollection-only in the SPA for *meaningfulness* (per-source weights need
  2+ sources), NOT because the classic write path rejects it — live-E2E confirmed
  the classic engine ACCEPTS it on a plain Collection (200) and degrades to
  fair-share. The rejection is on the separate playlist/block write paths, whose
  editors keep their own order lists and already omit it.

Live-E2E (real API): weighted multi-collection create + read round-trips weights;
rename-only PUT preserves them (no silent reset); WeightedShuffle persists on a
classic MultiCollection schedule item. Ratio itself is pinned by the existing
PlayoutBuildGoldenTests.Classic_weighted (3:1), untouched by this SPA change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:39:29 +02:00

494 lines
20 KiB
TypeScript

// Pure, exhaustively-tested rules for the schedule-item editor. Encodes the Blazor
// ProgramScheduleItemEditViewModel gating + forced-reset behavior (the parity standard for #207).
// No React, no fetch — every function is a deterministic transform so itemRules.test.ts can pin it.
import type { components } from '../api/generated/v1';
export type CollectionType = components['schemas']['CollectionType'];
export type PlaybackOrder = components['schemas']['PlaybackOrder'];
export type PlayoutMode = components['schemas']['PlayoutMode'];
export type MultipleMode = components['schemas']['MultipleMode'];
export type StartType = components['schemas']['StartType'];
export type TailMode = components['schemas']['TailMode'];
export type FillWithGroupMode = components['schemas']['FillWithGroupMode'];
export type MarathonGroupBy = components['schemas']['MarathonGroupBy'];
export type GuideMode = components['schemas']['GuideMode'];
export type FixedStartTimeBehavior = components['schemas']['FixedStartTimeBehavior'];
export type SubtitleMode = components['schemas']['ChannelSubtitleMode'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ScheduleItem = components['schemas']['ScheduleItemResponseModel'];
export type NamedId = components['schemas']['NamedIdResponseModel'];
// A local, editable schedule item. Carries every ScheduleItemRequest mutation field (named 1:1 with
// the DTO) plus display-only hydration the search-based pickers need to render the current selection
// without re-fetching, plus a stable local key for React + selection. `index` is intentionally NOT a
// field — array order is the source of truth (server indexes from array position on PUT).
export interface DraftItem extends ScheduleItemRequest {
_key: string;
collectionName: null | string;
multiCollectionName: null | string;
smartCollectionName: null | string;
rerunCollectionName: null | string;
playlistName: null | string;
playlistGroupId: null | number;
mediaItemName: null | string;
name: null | string;
durationEstimate: null | string;
}
let keyCounter = 0;
export function nextDraftKey(): string {
keyCounter += 1;
return `draft-${keyCounter}`;
}
// ---- Option lists --------------------------------------------------------
// WeightedShuffle (#70) is offered ONLY for MultiCollection: it distributes airtime across per-source
// weights, which live on MultiCollectionItem/SmartItem, so it is only *meaningful* with 2+ weighted
// sources. On a single collection the classic engine still accepts it but it degrades to fair-share
// (≈ Shuffle), a confusing no-op — hence MultiCollection-only here. (Distinct concern: the playlist and
// block editors — which keep their own order lists, not this one — must also omit it because those
// write paths reject it outright; docs/decisions.md 2026-07-17.)
const MULTI_COLLECTION_ORDERS: PlaybackOrder[] = ['Shuffle', 'ShuffleInOrder', 'WeightedShuffle'];
const COLLECTION_LIKE_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon'];
const TV_SHOW_ORDERS: PlaybackOrder[] = ['Chronological', 'SeasonEpisode', 'Random', 'Shuffle', 'MultiEpisodeShuffle'];
const SEASON_ARTIST_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle'];
const NONE_ORDERS: PlaybackOrder[] = ['None'];
// Never offered anywhere (Blazor parity): RandomRotation.
export function playbackOrderOptions(collectionType: CollectionType): PlaybackOrder[] {
switch (collectionType) {
case 'MultiCollection':
return MULTI_COLLECTION_ORDERS;
case 'TelevisionShow':
return TV_SHOW_ORDERS;
case 'TelevisionSeason':
case 'Artist':
return SEASON_ARTIST_ORDERS;
case 'Playlist':
case 'RerunFirstRun':
case 'RerunRerun':
return NONE_ORDERS;
case 'Collection':
case 'SmartCollection':
case 'SearchQuery':
return COLLECTION_LIKE_ORDERS;
default:
return COLLECTION_LIKE_ORDERS;
}
}
// The playback-order selector is disabled entirely for Playlist / Rerun collection types.
export function playbackOrderSelectDisabled(collectionType: CollectionType): boolean {
return collectionType === 'Playlist' || collectionType === 'RerunFirstRun' || collectionType === 'RerunRerun';
}
// The lone `None` option is labeled "Playlist" for a Playlist collection type (Blazor parity).
export function playbackOrderLabel(order: PlaybackOrder, collectionType: CollectionType): string {
if (order === 'None' && collectionType === 'Playlist') {
return 'Playlist';
}
return humanizeEnum(order);
}
export const MARATHON_GROUP_BY_OPTIONS: MarathonGroupBy[] = ['Album', 'Artist', 'Season', 'Show', 'Director'];
export const TAIL_MODE_OPTIONS: TailMode[] = ['None', 'Offline', 'Filler'];
export const GUIDE_MODE_OPTIONS: GuideMode[] = ['Normal', 'Filler'];
export const FILL_WITH_GROUP_MODE_OPTIONS: FillWithGroupMode[] = ['None', 'FillWithOrderedGroups', 'FillWithShuffledGroups'];
// subtitleMode is nullable ("Inherit" = null).
export const SUBTITLE_MODE_OPTIONS: (SubtitleMode | null)[] = [null, 'None', 'Forced', 'Default', 'Any'];
export const COLLECTION_TYPE_OPTIONS: CollectionType[] = [
'Collection',
'TelevisionShow',
'TelevisionSeason',
'Artist',
'MultiCollection',
'SmartCollection',
'Playlist',
'RerunFirstRun',
'RerunRerun',
'SearchQuery'
];
export function startTypeOptions(shuffleScheduleItems: boolean): StartType[] {
return shuffleScheduleItems ? ['Dynamic'] : ['Dynamic', 'Fixed'];
}
export function playoutModeOptions(shuffleScheduleItems: boolean): PlayoutMode[] {
return shuffleScheduleItems ? ['One', 'Multiple', 'Duration'] : ['Flood', 'One', 'Multiple', 'Duration'];
}
export function multipleModeOptions(collectionType: CollectionType, playbackOrder: PlaybackOrder): MultipleMode[] {
const options: MultipleMode[] = ['Count'];
options.push(collectionType === 'Playlist' ? 'PlaylistItemSize' : 'CollectionSize');
if (playbackOrder === 'Chronological') {
options.push('MultiEpisodeGroupSize');
}
return options;
}
// ---- Enable gates (UI disables the control, save nulls the value) --------
export function startTimeEnabled(item: DraftItem): boolean {
return item.startType === 'Fixed';
}
export function fixedBehaviorEnabled(item: DraftItem): boolean {
return item.startType === 'Fixed';
}
export function multipleModeEnabled(item: DraftItem): boolean {
return item.playoutMode === 'Multiple';
}
export function multipleCountEnabled(item: DraftItem): boolean {
return item.playoutMode === 'Multiple' && item.multipleMode === 'Count';
}
export function durationFieldsEnabled(item: DraftItem): boolean {
return item.playoutMode === 'Duration';
}
export function marathonVisible(item: DraftItem): boolean {
return item.playbackOrder === 'Marathon';
}
// Playlist collections show a "Shuffle Playlist Items" checkbox bound to marathonShuffleGroups
// (dual-purpose column — Blazor parity).
export function shufflePlaylistItemsVisible(item: DraftItem): boolean {
return item.collectionType === 'Playlist';
}
// fillWithGroupMode is eligible iff Multiple/Duration && playbackOrder not in {ShuffleInOrder,
// WeightedShuffle} && collectionType in {Collection, MultiCollection, SmartCollection}.
// WeightedShuffle joins ShuffleInOrder in the exclusion: fillWithGroup splits the multi-collection
// into per-group enumerators scheduled one group at a time (PlayoutBuilder), which is incompatible
// with WeightedShuffle's whole-collection per-source share of airtime (#70/#404).
export function fillWithGroupModeEligible(item: DraftItem): boolean {
const modeOk = item.playoutMode === 'Multiple' || item.playoutMode === 'Duration';
const orderOk = item.playbackOrder !== 'ShuffleInOrder' && item.playbackOrder !== 'WeightedShuffle';
const typeOk =
item.collectionType === 'Collection' ||
item.collectionType === 'MultiCollection' ||
item.collectionType === 'SmartCollection';
return modeOk && orderOk && typeOk;
}
// ---- Forced-reset transforms --------------------------------------------
function enforceFillWithGroup(item: DraftItem): DraftItem {
if (!fillWithGroupModeEligible(item) && item.fillWithGroupMode !== 'None') {
return { ...item, fillWithGroupMode: 'None' };
}
return item;
}
// Changing the collection type nulls every source ref (+ its display name) and re-seeds
// multipleMode / playbackOrder per the Blazor rules, then reconciles fillWithGroup eligibility.
export function applyCollectionTypeChange(item: DraftItem, newType: CollectionType): DraftItem {
let next: DraftItem = {
...item,
collectionType: newType,
collectionId: null,
multiCollectionId: null,
smartCollectionId: null,
rerunCollectionId: null,
mediaItemId: null,
playlistId: null,
searchTitle: null,
searchQuery: null,
collectionName: null,
multiCollectionName: null,
smartCollectionName: null,
rerunCollectionName: null,
playlistName: null,
playlistGroupId: null,
mediaItemName: null
};
// Reconcile playbackOrder into the set the new type offers. If the current order is not offered
// (e.g. Marathon on a Collection → TelevisionShow, or any order → Playlist/Rerun which only offer
// None), snap to the first offered order via applyPlaybackOrderChange so state matches what the
// native <select> renders — a stale invalid order would otherwise display the first option while
// state kept the old value (F2 / docs/decisions.md). applyPlaybackOrderChange also clears the now-
// orphaned Marathon/MultiEpisode fields.
const orderOptions = playbackOrderOptions(newType);
if (!orderOptions.includes(next.playbackOrder)) {
next = applyPlaybackOrderChange(next, orderOptions[0]);
}
// Reconcile multipleMode into the valid set for the new (type, order) state — e.g. CollectionSize
// is invalid after switching to Playlist (which offers PlaylistItemSize), and PlaylistItemSize is
// invalid when leaving Playlist. Snap to the first valid mode when the current one is not offered.
const modeOptions = multipleModeOptions(next.collectionType, next.playbackOrder);
if (!modeOptions.includes(next.multipleMode)) {
next = { ...next, multipleMode: modeOptions[0] };
}
return enforceFillWithGroup(next);
}
// Changing playback order resets MultiEpisodeGroupSize when not Chronological, resets the marathon
// fields when not Marathon (marathonShuffleGroups is deliberately NOT reset — it is dual-purpose),
// then reconciles fillWithGroup eligibility.
export function applyPlaybackOrderChange(item: DraftItem, newOrder: PlaybackOrder): DraftItem {
let next: DraftItem = { ...item, playbackOrder: newOrder };
if (newOrder !== 'Chronological' && next.multipleMode === 'MultiEpisodeGroupSize') {
next = { ...next, multipleMode: 'Count' };
}
if (newOrder !== 'Marathon') {
next = {
...next,
marathonGroupBy: 'None',
marathonShuffleItems: false,
marathonBatchSize: null
};
}
return enforceFillWithGroup(next);
}
// A change to playoutMode can flip fillWithGroup eligibility (Multiple/Duration only).
export function applyPlayoutModeChange(item: DraftItem, newMode: PlayoutMode): DraftItem {
return enforceFillWithGroup({ ...item, playoutMode: newMode });
}
// ---- Factories -----------------------------------------------------------
// Add-item defaults: startType Dynamic, playoutMode One, playbackOrder Shuffle,
// collectionType Collection, empty watermarks/graphics.
export function newDraftItem(): DraftItem {
return {
_key: nextDraftKey(),
// Never round-tripped from the server — the save projection must emit `id: null` for this item so
// the server treats it as an insert rather than colliding with (or misattributing) an existing row.
id: null,
startType: 'Dynamic',
startTime: null,
fixedStartTimeBehavior: null,
playoutMode: 'One',
collectionType: 'Collection',
collectionId: null,
multiCollectionId: null,
smartCollectionId: null,
rerunCollectionId: null,
mediaItemId: null,
playlistId: null,
searchTitle: null,
searchQuery: null,
playbackOrder: 'Shuffle',
marathonGroupBy: 'None',
marathonShuffleGroups: false,
marathonShuffleItems: false,
marathonBatchSize: null,
fillWithGroupMode: 'None',
multipleMode: 'Count',
multipleCount: null,
playoutDuration: null,
tailMode: 'None',
discardToFillAttempts: null,
customTitle: null,
guideMode: 'Normal',
preRollFillerId: null,
midRollFillerId: null,
postRollFillerId: null,
tailFillerId: null,
fallbackFillerId: null,
watermarkIds: [],
graphicsElementIds: [],
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
subtitleMode: null,
collectionName: null,
multiCollectionName: null,
smartCollectionName: null,
rerunCollectionName: null,
playlistName: null,
playlistGroupId: null,
mediaItemName: null,
name: null,
durationEstimate: null
};
}
// Deep copy of ALL fields, including the multi/smart/rerun collection references (+ names) that the
// Blazor CopyItem omitted — deliberate bug fix, see docs/decisions.md 2026-07-11.
export function copyDraftItem(item: DraftItem): DraftItem {
return {
...item,
_key: nextDraftKey(),
// The copy is a new, never-persisted row — it must not carry the source item's server id, or the
// save projection would submit the same id twice (server would treat the copy as the same row).
id: null,
watermarkIds: [...(item.watermarkIds ?? [])],
graphicsElementIds: [...(item.graphicsElementIds ?? [])]
};
}
// Maps a loaded GET response item into an editable draft (straight field copy — the DTO's mutation
// fields are named 1:1 with the request).
export function fromResponse(model: ScheduleItem): DraftItem {
return {
_key: nextDraftKey(),
// Round-trip the server id so a subsequent save reconciles this row by identity (#259) rather
// than by array position.
id: model.id,
startType: model.startType,
startTime: model.startTime,
fixedStartTimeBehavior: model.fixedStartTimeBehavior,
playoutMode: model.playoutMode,
collectionType: model.collectionType,
collectionId: model.collectionId,
multiCollectionId: model.multiCollectionId,
smartCollectionId: model.smartCollectionId,
rerunCollectionId: model.rerunCollectionId,
mediaItemId: model.mediaItemId,
playlistId: model.playlistId,
searchTitle: model.searchTitle,
searchQuery: model.searchQuery,
playbackOrder: model.playbackOrder,
marathonGroupBy: model.marathonGroupBy,
marathonShuffleGroups: model.marathonShuffleGroups,
marathonShuffleItems: model.marathonShuffleItems,
marathonBatchSize: model.marathonBatchSize,
fillWithGroupMode: model.fillWithGroupMode,
multipleMode: model.multipleMode ?? 'Count',
multipleCount: model.multipleCount,
playoutDuration: model.playoutDuration,
tailMode: model.tailMode ?? 'None',
discardToFillAttempts: model.discardToFillAttempts,
customTitle: model.customTitle,
guideMode: model.guideMode,
preRollFillerId: model.preRollFillerId,
midRollFillerId: model.midRollFillerId,
postRollFillerId: model.postRollFillerId,
tailFillerId: model.tailFillerId,
fallbackFillerId: model.fallbackFillerId,
watermarkIds: (model.watermarks ?? []).map((w) => w.id),
graphicsElementIds: (model.graphicsElements ?? []).map((g) => g.id),
preferredAudioLanguageCode: model.preferredAudioLanguageCode,
preferredAudioTitle: model.preferredAudioTitle,
preferredSubtitleLanguageCode: model.preferredSubtitleLanguageCode,
subtitleMode: model.subtitleMode,
collectionName: model.collectionName,
multiCollectionName: model.multiCollectionName,
smartCollectionName: model.smartCollectionName,
rerunCollectionName: model.rerunCollectionName,
playlistName: model.playlistName,
playlistGroupId: model.playlistGroupId,
mediaItemName: model.mediaItemName,
name: model.name,
durationEstimate: model.durationEstimate
};
}
// ---- Save projection -----------------------------------------------------
// Projects a draft to the request body, applying Blazor's getter-gating: gated-off values are
// nulled (or defaulted to the enum's None) so the payload matches what a non-editing Blazor VM emits.
export function normalizeForSave(item: DraftItem): ScheduleItemRequest {
const isFixed = item.startType === 'Fixed';
const isMultipleCount = item.playoutMode === 'Multiple' && item.multipleMode === 'Count';
const isDuration = item.playoutMode === 'Duration';
const isMarathon = item.playbackOrder === 'Marathon';
const fillWithGroupMode = fillWithGroupModeEligible(item) ? item.fillWithGroupMode : 'None';
return {
// null for a draft item never round-tripped from the server (new / copied) — the server treats a
// null id as an insert. A non-null id reconciles the existing row by identity (#259).
id: item.id,
startType: item.startType,
startTime: isFixed ? item.startTime : null,
fixedStartTimeBehavior: isFixed ? item.fixedStartTimeBehavior : null,
playoutMode: item.playoutMode,
collectionType: item.collectionType,
collectionId: item.collectionId,
multiCollectionId: item.multiCollectionId,
smartCollectionId: item.smartCollectionId,
rerunCollectionId: item.rerunCollectionId,
mediaItemId: item.mediaItemId,
playlistId: item.playlistId,
searchTitle: item.searchTitle,
searchQuery: item.searchQuery,
playbackOrder: item.playbackOrder,
marathonGroupBy: isMarathon ? item.marathonGroupBy : 'None',
marathonShuffleGroups: item.marathonShuffleGroups,
marathonShuffleItems: isMarathon ? item.marathonShuffleItems : false,
marathonBatchSize: isMarathon ? item.marathonBatchSize : null,
fillWithGroupMode,
multipleMode: item.multipleMode,
multipleCount: isMultipleCount ? item.multipleCount : null,
playoutDuration: isDuration ? item.playoutDuration : null,
tailMode: isDuration ? item.tailMode : 'None',
discardToFillAttempts: isDuration ? item.discardToFillAttempts : null,
customTitle: item.customTitle,
guideMode: item.guideMode,
preRollFillerId: item.preRollFillerId,
midRollFillerId: item.midRollFillerId,
postRollFillerId: item.postRollFillerId,
tailFillerId: item.tailFillerId,
fallbackFillerId: item.fallbackFillerId,
watermarkIds: item.watermarkIds ?? [],
graphicsElementIds: item.graphicsElementIds ?? [],
preferredAudioLanguageCode: item.preferredAudioLanguageCode,
preferredAudioTitle: item.preferredAudioTitle,
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode,
subtitleMode: item.subtitleMode
};
}
// ---- Client-side validation (mirrors the server) -------------------------
// Returns a human message for the first blocking problem, or null when the item is submittable.
export function validateItem(item: DraftItem): null | string {
if (item.startType === 'Fixed' && !item.startTime) {
return 'Fixed start items require a start time';
}
if (item.playoutMode === 'Multiple' && item.multipleMode === 'Count' && !item.multipleCount) {
return 'Multiple / Count items require a count expression';
}
if (item.playoutMode === 'Duration') {
if (!item.playoutDuration) {
return 'Duration items require a playout duration';
}
if (item.discardToFillAttempts == null || item.discardToFillAttempts < 0) {
return 'Duration items require discard-to-fill attempts (0 or more)';
}
}
return null;
}
export function validateDraft(items: DraftItem[]): null | string {
for (let i = 0; i < items.length; i += 1) {
const message = validateItem(items[i]);
if (message) {
return `Item ${i + 1}: ${message}`;
}
}
return null;
}
// ---- Display helpers -----------------------------------------------------
export function humanizeEnum(value: string): string {
return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
}
export function draftItemLabel(item: DraftItem): string {
return (
item.name ??
item.collectionName ??
item.multiCollectionName ??
item.smartCollectionName ??
item.rerunCollectionName ??
item.playlistName ??
item.mediaItemName ??
item.searchTitle ??
item.searchQuery ??
'Unnamed item'
);
}