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
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>
494 lines
20 KiB
TypeScript
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'
|
|
);
|
|
}
|