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) => internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new( new(
playoutItem.Id,
GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle), GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset, playoutItem.StartOffset,
playoutItem.FinishOffset, playoutItem.FinishOffset,
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public record PlayoutItemViewModel( public record PlayoutItemViewModel(
int? Id,
string Title, string Title,
DateTimeOffset Start, DateTimeOffset Start,
DateTimeOffset Finish, DateTimeOffset Finish,
@@ -106,7 +106,9 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
var gap = playoutGaps.Single(g => g.Id == c.Id); var gap = playoutGaps.Single(g => g.Id == c.Id);
TimeSpan gapDuration = gap.Finish - gap.Start; TimeSpan gapDuration = gap.Finish - gap.Start;
// gaps are synthesized rows, not PlayoutItems, so they carry no row id
return new PlayoutItemViewModel( return new PlayoutItemViewModel(
null,
"UNSCHEDULED", "UNSCHEDULED",
gap.StartOffset, gap.StartOffset,
gap.FinishOffset, gap.FinishOffset,
@@ -1,8 +1,10 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Playouts; namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemResponseModel( public record PlayoutItemResponseModel(
int? Id,
string Title, string Title,
DateTimeOffset Start, DateTimeOffset Start,
DateTimeOffset Finish, DateTimeOffset Finish,
@@ -519,6 +519,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9))); .Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var item = new PlayoutItemViewModel( var item = new PlayoutItemViewModel(
77,
"Movie", "Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
@@ -526,6 +527,7 @@ public class PlayoutControllerTests
string.Empty, string.Empty,
Some(FillerKind.MidRoll)); Some(FillerKind.MidRoll));
var gap = new PlayoutItemViewModel( var gap = new PlayoutItemViewModel(
null,
"UNSCHEDULED", "UNSCHEDULED",
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 30, 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>(); var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
result.TotalCount.ShouldBe(2); result.TotalCount.ShouldBe(2);
result.Page[0].Id.ShouldBe(77);
result.Page[0].Title.ShouldBe("Movie"); result.Page[0].Title.ShouldBe("Movie");
result.Page[0].Duration.ShouldBe("1:00:00"); result.Page[0].Duration.ShouldBe("1:00:00");
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll); result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
result.Page[1].Id.ShouldBeNull();
result.Page[1].Title.ShouldBe("UNSCHEDULED"); result.Page[1].Title.ShouldBe("UNSCHEDULED");
result.Page[1].FillerKind.ShouldBeNull(); result.Page[1].FillerKind.ShouldBeNull();
@@ -558,6 +562,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9))); .Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var withContext = new PlayoutItemViewModel( var withContext = new PlayoutItemViewModel(
42,
"Movie", "Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 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) => private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
new( new(
vm.Id,
vm.Title, vm.Title,
vm.Start, vm.Start,
vm.Finish, vm.Finish,
+9 -7
View File
@@ -19334,6 +19334,7 @@
}, },
"PlayoutItemResponseModel": { "PlayoutItemResponseModel": {
"required": [ "required": [
"id",
"title", "title",
"start", "start",
"finish", "finish",
@@ -19343,11 +19344,15 @@
], ],
"type": "object", "type": "object",
"properties": { "properties": {
"title": { "id": {
"type": [ "type": [
"null", "null",
"string" "integer"
] ],
"format": "int32"
},
"title": {
"type": "string"
}, },
"start": { "start": {
"type": "string", "type": "string",
@@ -19358,10 +19363,7 @@
"format": "date-time" "format": "date-time"
}, },
"duration": { "duration": {
"type": [ "type": "string"
"null",
"string"
]
}, },
"fillerKind": { "fillerKind": {
"oneOf": [ "oneOf": [
+3 -2
View File
@@ -1044,10 +1044,11 @@ export interface components {
"details": string; "details": string;
}; };
"PlayoutItemResponseModel": { "PlayoutItemResponseModel": {
"title": null | string; "id": null | number;
"title": string;
"start": string; "start": string;
"finish": string; "finish": string;
"duration": null | string; "duration": string;
"fillerKind": null | components["schemas"]["FillerKind"]; "fillerKind": null | components["schemas"]["FillerKind"];
"hasSchedulingContext": boolean; "hasSchedulingContext": boolean;
}; };