docs+openapi: regenerate v1.json/endpoint-index/types + record flat schedule DTO decision (#126 #207 #212)

- Regenerated ErsatzTV/wwwroot/openapi/v1.json, docs/endpoint-index.md, and
  web/src/api/generated/v1.d.ts for the flat schedule-item DTO + discovery endpoints
- docs/decisions.md: flat ScheduleItemResponseModel + NamedIdResponseModel rationale
- docs/api-conventions.md: shared {id,name} embed, polymorphic-VM flattening, and
  optional enum query-filter conventions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 23:54:12 +02:00
co-authored by Claude Fable 5
parent b13fb0ffc7
commit 223797a6cb
5 changed files with 699 additions and 708 deletions
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -52,7 +52,24 @@ Exemplars:
`ToReplaceCommand(int index)`) method that maps it to the Application-layer command type.
- **`ErsatzTV.Application` has no nullable context** (no `<Nullable>` = C# default `disable` for
that TFM in this repo — confirms CS8632 would otherwise fire) — do **not** add `?` nullable
annotations to types living there; that's a Core/Api-layer-only convention.
annotations to types living there; that's a Core/Api-layer-only convention. A static mapper that
lives in `ErsatzTV.Application` but returns a Core/Api response DTO with nullable members is fine
(e.g. `ScheduleItemResponseMapper`) — the nullability lives on the DTO record, not the mapper.
- **Shared `{id, name}` embeds**: use `ErsatzTV.Core/Api/NamedIdResponseModel.cs`
(`record NamedIdResponseModel(int Id, string Name)`) when a response DTO needs to embed a list of
named references (e.g. a schedule item's `watermarks` / `graphicsElements`) rather than minting a
one-off `(int, string)` record per domain.
- **Flatten polymorphic VMs for the wire**: when an Application ViewModel is an abstract/polymorphic
record (subtypes carrying extra fields), the OpenAPI schema only captures the base shape — promote
every subtype field to a nullable top-level member on a flat response DTO and pattern-match the
concrete VM in the mapper. Exemplar: `ScheduleItemResponseModel` (issue #126, see
`docs/decisions.md` 2026-07-10). Keep the flat DTO's **mutation** fields named 1:1 with the
matching request DTO so GET→PUT is lossless (guard with a round-trip handler test).
- **Optional enum filter via query param**: to filter a list endpoint by an enum, add a nullable
enum parameter to the query record (default `null`) and bind it with `[FromQuery] TEnum? name` on
the action; filter server-side only when it has a value. Exemplar: `?fillerKind=` on
`GET /api/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid
enum value is rejected by model binding (400) — no handler-side guard needed.
## 3. Error mapping
+20
View File
@@ -225,3 +225,23 @@ TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s t
"Add All" (query-wide) mirrors Blazor's two-step: materialize ids
via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add
command exists server-side. Issues #208/#209.
## 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)
`GET/POST/PUT /api/schedules/{id}/items` return `ScheduleItemResponseModel` /
`ScheduleItemsResponseModel` (`ErsatzTV.Core/Api/Scheduling/`), **not** the Application-layer
`ProgramScheduleItemViewModel` hierarchy (One/Flood/Multiple/Duration subtypes). The polymorphic VM
only described its base shape in OpenAPI, so the SPA couldn't see the subtype fields (issue #126).
The flat DTO promotes every subtype field to a nullable top-level member — `multipleMode`,
`multipleCount` (renamed from the VM's `Count`), `playoutDuration`, `tailMode`,
`discardToFillAttempts` — mapped by pattern-matching the concrete VM in
`ScheduleItemResponseMapper` (`ErsatzTV.Application/ProgramSchedules/`). Its **mutation fields are
named 1:1 with `ScheduleItemRequest`** so a GET maps losslessly back to a PUT/POST
(`ScheduleItemResponseRoundTripTests` is the release gate proving the fixed point). It also carries
picker-hydration fields the editor needs: `collectionName`/`smartCollectionName`/…/`playlistName`,
`playlistGroupId` (to preselect the playlist's group), per-filler names, `watermarks` /
`graphicsElements` as `NamedIdResponseModel` lists, the computed `name`, and `durationEstimate`.
`GetProgramScheduleItemsHandler.EnforceProperties` still rewrites StartType→Dynamic, Flood→One and
Playlist/Rerun→PlaybackOrder None when `ShuffleScheduleItems` is on — that lossy normalization is
deliberate and lives on the read side (documented + tested). New shared `NamedIdResponseModel`
(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212.
+9 -1
View File
@@ -2,7 +2,7 @@
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
130 endpoints, 204 operations.
133 endpoints, 207 operations.
## Artists
@@ -59,7 +59,9 @@
| POST | `/api/channels/bulk/group` | | Move channels to a group |
| POST | `/api/channels/bulk/renumber` | | Renumber channels |
| POST | `/api/channels/from-lineup` | CreateChannelFromLineup | Create a channel from a library lineup |
| GET | `/api/channels/music-video-credits-templates` | GetMusicVideoCreditsTemplates | Get available music video credits template names |
| GET | `/api/channels/state` | | Get channel runtime state |
| GET | `/api/channels/stream-selectors` | GetChannelStreamSelectors | Get available channel stream selector names |
| POST | `/api/channels/{channelNumber}/playout/reset` | | Reset channel playout |
| DELETE | `/api/channels/{id}` | | Delete a channel |
| GET | `/api/channels/{id}` | GetChannelById | Get a channel by id |
@@ -147,6 +149,12 @@
| GET | `/api/images/folders` | GetImageFolders | List image library folders |
| PUT | `/api/images/folders/{id}/duration` | UpdateImageFolderDuration | Set or clear an image folder's playout duration |
## Languages
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/languages` | GetLanguages | Get all available language codes |
## Libraries
| Method | Path | Operation | Summary |
+67 -119
View File
@@ -685,24 +685,7 @@ export interface components {
"FillerPresetResponseModel": {
"id": number;
"name": null | string;
};
"FillerPresetViewModel": {
"id": number;
"name": null | string;
"fillerKind": components["schemas"]["FillerKind"];
"fillerMode": components["schemas"]["FillerMode"];
"duration": null | string;
"count": null | number;
"padToNearestMinute": null | number;
"allowWatermarks": boolean;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"mediaItemId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"playlist": components["schemas"]["PlaylistViewModel"];
"expression": null | string;
"useChaptersAsMediaItems": boolean;
};
"FillWithGroupMode": "None" | "FillWithOrderedGroups" | "FillWithShuffledGroups";
"FilterMode": "HardwareIfPossible" | "Software";
@@ -710,11 +693,6 @@ export interface components {
"GraphicsElementResponseModel": {
"id": number;
"name": null | string;
};
"GraphicsElementViewModel": {
"id": number;
"name": null | string;
"fileName": null | string;
};
"GuideMode": "Normal" | "Filler";
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
@@ -742,6 +720,10 @@ export interface components {
"subfolderCount": number;
"imageCount": number;
"durationSeconds": null | number;
};
"LanguageCodeResponseModel": {
"code": null | string;
"englishName": null | string;
};
"LibraryBrowseItemResponseModel": {
"id": number;
@@ -889,33 +871,15 @@ export interface components {
"name": string;
"scheduleAsGroup": boolean;
"playbackOrder": components["schemas"]["PlaybackOrder"];
};
"MultiCollectionItemViewModel": {
"multiCollectionId": number;
"collection": components["schemas"]["MediaCollectionViewModel"];
"scheduleAsGroup": boolean;
"playbackOrder": components["schemas"]["PlaybackOrder"];
};
"MultiCollectionResponseModel": {
"id": number;
"name": string;
"items": Array<components["schemas"]["MultiCollectionItemResponseModel"]>;
};
"MultiCollectionSmartItemViewModel": {
"multiCollectionId": number;
"smartCollection": components["schemas"]["SmartCollectionViewModel"];
"scheduleAsGroup": boolean;
"playbackOrder": components["schemas"]["PlaybackOrder"];
};
"MultiCollectionViewModel": {
"id": number;
"name": null | string;
"items": null | Array<components["schemas"]["MultiCollectionItemViewModel"]>;
"smartItems": null | Array<components["schemas"]["MultiCollectionSmartItemViewModel"]>;
};
"MultipleMode": "Count" | "CollectionSize" | "PlaylistItemSize" | "MultiEpisodeGroupSize";
"NamedMediaItemViewModel": {
"mediaItemId": number;
"NamedIdResponseModel": {
"id": number;
"name": null | string;
};
"NormalizeLoudnessMode": "Off" | "LoudNorm";
@@ -999,12 +963,6 @@ export interface components {
"playlistGroupId": number;
"name": string;
"isSystem": boolean;
};
"PlaylistViewModel": {
"id": number;
"playlistGroupId": number;
"name": null | string;
"isSystem": boolean;
};
"PlayoutAlternateScheduleItemRequest": {
"id": number;
@@ -1138,48 +1096,6 @@ export interface components {
"status"?: null | number;
"detail"?: null | string;
"instance"?: null | string;
};
"ProgramScheduleItemsWithDurationViewModel": {
"items": null | Array<components["schemas"]["ProgramScheduleItemViewModel"]>;
"totalDurationEstimate": null | string;
};
"ProgramScheduleItemViewModel": {
"id"?: number;
"index"?: number;
"startType"?: components["schemas"]["StartType"];
"startTime"?: null | string;
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
"playoutMode"?: components["schemas"]["PlayoutMode"];
"collectionType"?: components["schemas"]["CollectionType"];
"collection"?: components["schemas"]["MediaCollectionViewModel"];
"multiCollection"?: components["schemas"]["MultiCollectionViewModel"];
"smartCollection"?: components["schemas"]["SmartCollectionViewModel"];
"rerunCollection"?: components["schemas"]["RerunCollectionViewModel"];
"playlist"?: components["schemas"]["PlaylistViewModel"];
"mediaItem"?: components["schemas"]["NamedMediaItemViewModel"];
"searchTitle"?: null | string;
"searchQuery"?: null | string;
"playbackOrder"?: components["schemas"]["PlaybackOrder"];
"marathonGroupBy"?: components["schemas"]["MarathonGroupBy"];
"marathonShuffleGroups"?: boolean;
"marathonShuffleItems"?: boolean;
"marathonBatchSize"?: null | number;
"fillWithGroupMode"?: components["schemas"]["FillWithGroupMode"];
"customTitle"?: null | string;
"guideMode"?: components["schemas"]["GuideMode"];
"preRollFiller"?: components["schemas"]["FillerPresetViewModel"];
"midRollFiller"?: components["schemas"]["FillerPresetViewModel"];
"postRollFiller"?: components["schemas"]["FillerPresetViewModel"];
"tailFiller"?: components["schemas"]["FillerPresetViewModel"];
"fallbackFiller"?: components["schemas"]["FillerPresetViewModel"];
"watermarks"?: null | Array<components["schemas"]["WatermarkViewModel"]>;
"graphicsElements"?: null | Array<components["schemas"]["GraphicsElementViewModel"]>;
"preferredAudioLanguageCode"?: null | string;
"preferredAudioTitle"?: null | string;
"preferredSubtitleLanguageCode"?: null | string;
"subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"];
"durationEstimate"?: null | string;
"name"?: null | string;
};
"ProgramScheduleViewModel": {
"id": number;
@@ -1249,17 +1165,6 @@ export interface components {
"selectedName": null | string;
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
};
"RerunCollectionViewModel": {
"id": number;
"name": null | string;
"collectionType": components["schemas"]["CollectionType"];
"collection": components["schemas"]["MediaCollectionViewModel"];
"multiCollection": components["schemas"]["MultiCollectionViewModel"];
"smartCollection": components["schemas"]["SmartCollectionViewModel"];
"mediaItem": components["schemas"]["NamedMediaItemViewModel"];
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
};
"ResolutionResponseModel": {
"id": number;
@@ -1321,6 +1226,67 @@ export interface components {
"preferredAudioTitle": null | string;
"preferredSubtitleLanguageCode": null | string;
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
};
"ScheduleItemResponseModel": {
"id": number;
"index": number;
"startType": components["schemas"]["StartType"];
"startTime": null | string;
"fixedStartTimeBehavior": null | components["schemas"]["FixedStartTimeBehavior"];
"playoutMode": components["schemas"]["PlayoutMode"];
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"rerunCollectionId": null | number;
"mediaItemId": null | number;
"playlistId": null | number;
"searchTitle": null | string;
"searchQuery": null | string;
"playbackOrder": components["schemas"]["PlaybackOrder"];
"marathonGroupBy": components["schemas"]["MarathonGroupBy"];
"marathonShuffleGroups": boolean;
"marathonShuffleItems": boolean;
"marathonBatchSize": null | number;
"fillWithGroupMode": components["schemas"]["FillWithGroupMode"];
"multipleMode": null | components["schemas"]["MultipleMode"];
"multipleCount": null | string;
"playoutDuration": null | string;
"tailMode": null | components["schemas"]["TailMode"];
"discardToFillAttempts": null | number;
"customTitle": null | string;
"guideMode": components["schemas"]["GuideMode"];
"preRollFillerId": null | number;
"midRollFillerId": null | number;
"postRollFillerId": null | number;
"tailFillerId": null | number;
"fallbackFillerId": null | number;
"watermarkIds": Array<number>;
"graphicsElementIds": Array<number>;
"preferredAudioLanguageCode": null | string;
"preferredAudioTitle": null | string;
"preferredSubtitleLanguageCode": null | string;
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
"collectionName": null | string;
"multiCollectionName": null | string;
"smartCollectionName": null | string;
"rerunCollectionName": null | string;
"playlistName": null | string;
"playlistGroupId": null | number;
"mediaItemName": null | string;
"preRollFillerName": null | string;
"midRollFillerName": null | string;
"postRollFillerName": null | string;
"tailFillerName": null | string;
"fallbackFillerName": null | string;
"watermarks": Array<components["schemas"]["NamedIdResponseModel"]>;
"graphicsElements": Array<components["schemas"]["NamedIdResponseModel"]>;
"name": null | string;
"durationEstimate": null | string;
};
"ScheduleItemsResponseModel": {
"items": Array<components["schemas"]["ScheduleItemResponseModel"]>;
"totalDurationEstimate": null | string;
};
"SchedulingPickerOptionResponseModel": {
"id": number;
@@ -1711,24 +1677,6 @@ export interface components {
"name": null | string;
};
"WatermarkSize": "Scaled" | "ActualSize";
"WatermarkViewModel": {
"id": number;
"image": components["schemas"]["ArtworkContentTypeModel"];
"name": null | string;
"mode": components["schemas"]["ChannelWatermarkMode"];
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
"location": components["schemas"]["WatermarkLocation"];
"size": components["schemas"]["WatermarkSize"];
"width": number;
"horizontalMargin": number;
"verticalMargin": number;
"frequencyMinutes": number;
"durationSeconds": number;
"opacity": number;
"placeWithinSourceContent": boolean;
"opacityExpression": null | string;
"zIndex": number;
};
"XmltvBlockBehavior": "SplitTimeEvenly" | "UseActualTimes";
"XmltvSettingsResponseModel": {
"daysToBuild": number;