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:
+585
-587
File diff suppressed because it is too large
Load Diff
+18
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Vendored
+67
-119
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user