feat(api): expose PlayoutItem row id on the playout-items list DTO (#210)

The SPA needs the item's row id to call
GET /api/playouts/items/{id}/scheduling-context. Plumbed through
PlayoutItemViewModel -> PlayoutItemResponseModel as a nullable Id
(null for synthesized UNSCHEDULED gap rows, which are PlayoutGaps,
not PlayoutItems). Additive for existing consumers (Playouts.razor
reads the VM by property). Regenerated v1.json + v1.d.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:10:45 +02:00
co-authored by Claude Fable 5
parent f1b3879d51
commit e66b926206
8 changed files with 24 additions and 9 deletions
+1
View File
@@ -23,6 +23,7 @@ internal static class Mapper
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
playoutItem.Id,
GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset,
playoutItem.FinishOffset,
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Playouts;
public record PlayoutItemViewModel(
int? Id,
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
@@ -106,7 +106,9 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
var gap = playoutGaps.Single(g => g.Id == c.Id);
TimeSpan gapDuration = gap.Finish - gap.Start;
// gaps are synthesized rows, not PlayoutItems, so they carry no row id
return new PlayoutItemViewModel(
null,
"UNSCHEDULED",
gap.StartOffset,
gap.FinishOffset,
@@ -1,8 +1,10 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemResponseModel(
int? Id,
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
@@ -519,6 +519,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var item = new PlayoutItemViewModel(
77,
"Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
@@ -526,6 +527,7 @@ public class PlayoutControllerTests
string.Empty,
Some(FillerKind.MidRoll));
var gap = new PlayoutItemViewModel(
null,
"UNSCHEDULED",
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero),
@@ -539,9 +541,11 @@ public class PlayoutControllerTests
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
result.TotalCount.ShouldBe(2);
result.Page[0].Id.ShouldBe(77);
result.Page[0].Title.ShouldBe("Movie");
result.Page[0].Duration.ShouldBe("1:00:00");
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
result.Page[1].Id.ShouldBeNull();
result.Page[1].Title.ShouldBe("UNSCHEDULED");
result.Page[1].FillerKind.ShouldBeNull();
@@ -558,6 +562,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var withContext = new PlayoutItemViewModel(
42,
"Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
@@ -743,6 +743,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
new(
vm.Id,
vm.Title,
vm.Start,
vm.Finish,
+9 -7
View File
@@ -19334,6 +19334,7 @@
},
"PlayoutItemResponseModel": {
"required": [
"id",
"title",
"start",
"finish",
@@ -19343,11 +19344,15 @@
],
"type": "object",
"properties": {
"title": {
"id": {
"type": [
"null",
"string"
]
"integer"
],
"format": "int32"
},
"title": {
"type": "string"
},
"start": {
"type": "string",
@@ -19358,10 +19363,7 @@
"format": "date-time"
},
"duration": {
"type": [
"null",
"string"
]
"type": "string"
},
"fillerKind": {
"oneOf": [
+3 -2
View File
@@ -1044,10 +1044,11 @@ export interface components {
"details": string;
};
"PlayoutItemResponseModel": {
"title": null | string;
"id": null | number;
"title": string;
"start": string;
"finish": string;
"duration": null | string;
"duration": string;
"fillerKind": null | components["schemas"]["FillerKind"];
"hasSchedulingContext": boolean;
};