From e66b926206cb3ba2020a39b8d9e82589aaef5815 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 22:10:45 +0200 Subject: [PATCH] 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 --- ErsatzTV.Application/Playouts/Mapper.cs | 1 + .../Playouts/PlayoutItemViewModel.cs | 1 + .../Queries/GetFuturePlayoutItemsByIdHandler.cs | 2 ++ .../Api/Playouts/PlayoutItemResponseModel.cs | 2 ++ .../Controllers/PlayoutControllerTests.cs | 5 +++++ ErsatzTV/Controllers/Api/PlayoutController.cs | 1 + ErsatzTV/wwwroot/openapi/v1.json | 16 +++++++++------- web/src/api/generated/v1.d.ts | 5 +++-- 8 files changed, 24 insertions(+), 9 deletions(-) diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 18d10475e..da82809b5 100644 --- a/ErsatzTV.Application/Playouts/Mapper.cs +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -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, diff --git a/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs index dbc2e4e12..641cb6060 100644 --- a/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs +++ b/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs @@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler; namespace ErsatzTV.Application.Playouts; public record PlayoutItemViewModel( + int? Id, string Title, DateTimeOffset Start, DateTimeOffset Finish, diff --git a/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs index 84fe286fc..af80f53ae 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs @@ -106,7 +106,9 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory 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, diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs index 6b080221a..24ae22fb1 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs @@ -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, diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index 9a813f168..cd285aad6 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -519,6 +519,7 @@ public class PlayoutControllerTests .Returns(Option.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().Value.ShouldBeOfType(); 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.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), diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index eefe8b647..d7da83203 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -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, diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index ab6821a91..cb5b2da7b 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -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": [ diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 8e02dfa7d..81d187617 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -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; };