diff --git a/ErsatzTV.Application/Filler/Mapper.cs b/ErsatzTV.Application/Filler/Mapper.cs index 19ef528c1..da46733ba 100644 --- a/ErsatzTV.Application/Filler/Mapper.cs +++ b/ErsatzTV.Application/Filler/Mapper.cs @@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Filler; internal static class Mapper { internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) => - new(fillerPreset.Id, fillerPreset.Name); + new(fillerPreset.Id, fillerPreset.Name, fillerPreset.FillerKind); internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) => new( diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs index 3252aae1f..8001d901d 100644 --- a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs +++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Core.Domain.Filler; namespace ErsatzTV.Application.Filler; -public record GetAllFillerPresetsForApi : IRequest>; +public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest>; diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs index b0c6edaf6..80f3dd504 100644 --- a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs +++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs @@ -14,9 +14,13 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory dbCon CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - List fillerPresets = await dbContext.FillerPresets - .AsNoTracking() - .ToListAsync(cancellationToken); + IQueryable query = dbContext.FillerPresets.AsNoTracking(); + if (request.FillerKind is { } fillerKind) + { + query = query.Where(fp => fp.FillerKind == fillerKind); + } + + List fillerPresets = await query.ToListAsync(cancellationToken); return fillerPresets.Map(ProjectToResponseModel).ToList(); } } diff --git a/ErsatzTV.Application/ProgramSchedules/ScheduleItemResponseMapper.cs b/ErsatzTV.Application/ProgramSchedules/ScheduleItemResponseMapper.cs new file mode 100644 index 000000000..abdfb0634 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ScheduleItemResponseMapper.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Api; +using ErsatzTV.Core.Api.Scheduling; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules; + +/// +/// Maps the polymorphic hierarchy to the flat, +/// fully-described API DTO (issue #126). Subtype-specific +/// fields (Multiple's mode/count, Duration's playoutDuration/tailMode/discardToFillAttempts) are +/// read by pattern-matching the concrete VM type; they are null for other subtypes. +/// +public static class ScheduleItemResponseMapper +{ + public static ScheduleItemResponseModel ProjectToResponseModel(ProgramScheduleItemViewModel item) + { + MultipleMode? multipleMode = null; + string multipleCount = null; + TimeSpan? playoutDuration = null; + TailMode? tailMode = null; + int? discardToFillAttempts = null; + + switch (item) + { + case ProgramScheduleItemMultipleViewModel multiple: + multipleMode = multiple.MultipleMode; + multipleCount = multiple.Count; + break; + case ProgramScheduleItemDurationViewModel duration: + playoutDuration = duration.PlayoutDuration; + tailMode = duration.TailMode; + discardToFillAttempts = duration.DiscardToFillAttempts; + break; + } + + List watermarkIds = item.Watermarks?.Map(w => w.Id).ToList() ?? new List(); + List graphicsElementIds = item.GraphicsElements?.Map(g => g.Id).ToList() ?? new List(); + List watermarks = + item.Watermarks?.Map(w => new NamedIdResponseModel(w.Id, w.Name)).ToList() + ?? new List(); + List graphicsElements = + item.GraphicsElements?.Map(g => new NamedIdResponseModel(g.Id, g.Name)).ToList() + ?? new List(); + + return new ScheduleItemResponseModel( + item.Id, + item.Index, + item.StartType, + item.StartTime, + item.FixedStartTimeBehavior, + item.PlayoutMode, + item.CollectionType, + item.Collection?.Id, + item.MultiCollection?.Id, + item.SmartCollection?.Id, + item.RerunCollection?.Id, + item.MediaItem?.MediaItemId, + item.Playlist?.Id, + item.SearchTitle, + item.SearchQuery, + item.PlaybackOrder, + item.MarathonGroupBy, + item.MarathonShuffleGroups, + item.MarathonShuffleItems, + item.MarathonBatchSize, + item.FillWithGroupMode, + multipleMode, + multipleCount, + playoutDuration, + tailMode, + discardToFillAttempts, + item.CustomTitle, + item.GuideMode, + item.PreRollFiller?.Id, + item.MidRollFiller?.Id, + item.PostRollFiller?.Id, + item.TailFiller?.Id, + item.FallbackFiller?.Id, + watermarkIds, + graphicsElementIds, + item.PreferredAudioLanguageCode, + item.PreferredAudioTitle, + item.PreferredSubtitleLanguageCode, + item.SubtitleMode, + item.Collection?.Name, + item.MultiCollection?.Name, + item.SmartCollection?.Name, + item.RerunCollection?.Name, + item.Playlist?.Name, + item.Playlist?.PlaylistGroupId, + item.MediaItem?.Name, + item.PreRollFiller?.Name, + item.MidRollFiller?.Name, + item.PostRollFiller?.Name, + item.TailFiller?.Name, + item.FallbackFiller?.Name, + watermarks, + graphicsElements, + item.Name, + item.DurationEstimate); + } + + public static ScheduleItemsResponseModel ProjectToResponseModel(ProgramScheduleItemsWithDurationViewModel vm) => + new( + vm.Items.Map(ProjectToResponseModel).ToList(), + vm.TotalDurationEstimate); +} diff --git a/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs index a237a0b3b..8ac01485a 100644 --- a/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs +++ b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs @@ -1,3 +1,5 @@ +using ErsatzTV.Core.Domain.Filler; + namespace ErsatzTV.Core.Api.Filler; -public record FillerPresetResponseModel(int Id, string Name); +public record FillerPresetResponseModel(int Id, string Name, FillerKind FillerKind); diff --git a/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs b/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs new file mode 100644 index 000000000..d57b9c316 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaItems; + +public record LanguageCodeResponseModel(string Code, string EnglishName); diff --git a/ErsatzTV.Core/Api/NamedIdResponseModel.cs b/ErsatzTV.Core/Api/NamedIdResponseModel.cs new file mode 100644 index 000000000..db19e49c2 --- /dev/null +++ b/ErsatzTV.Core/Api/NamedIdResponseModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api; + +/// +/// A lightweight {id, name} pair used when an API response needs to embed a reference to a +/// named entity (e.g. a schedule item's watermarks or graphics elements) without projecting +/// the full entity DTO. +/// +public record NamedIdResponseModel(int Id, string Name); diff --git a/ErsatzTV.Core/Api/Scheduling/ScheduleItemResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/ScheduleItemResponseModel.cs new file mode 100644 index 000000000..07be61189 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/ScheduleItemResponseModel.cs @@ -0,0 +1,70 @@ +#nullable enable +using System.Collections.Generic; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; + +namespace ErsatzTV.Core.Api.Scheduling; + +/// +/// A flat, non-polymorphic projection of a program schedule item (issue #126). Unlike the +/// Application-layer ProgramScheduleItemViewModel hierarchy (One/Flood/Multiple/Duration +/// subtypes), every subtype-specific field is a nullable member here so the OpenAPI schema fully +/// describes the shape. The mutation fields are named 1:1 with +/// ScheduleItemRequest so a GET can be mapped losslessly back to a PUT/POST request. +/// +public record ScheduleItemResponseModel( + int Id, + int Index, + StartType StartType, + TimeSpan? StartTime, + FixedStartTimeBehavior? FixedStartTimeBehavior, + PlayoutMode PlayoutMode, + CollectionType CollectionType, + int? CollectionId, + int? MultiCollectionId, + int? SmartCollectionId, + int? RerunCollectionId, + int? MediaItemId, + int? PlaylistId, + string? SearchTitle, + string? SearchQuery, + PlaybackOrder PlaybackOrder, + MarathonGroupBy MarathonGroupBy, + bool MarathonShuffleGroups, + bool MarathonShuffleItems, + int? MarathonBatchSize, + FillWithGroupMode FillWithGroupMode, + MultipleMode? MultipleMode, + string? MultipleCount, + TimeSpan? PlayoutDuration, + TailMode? TailMode, + int? DiscardToFillAttempts, + string? CustomTitle, + GuideMode GuideMode, + int? PreRollFillerId, + int? MidRollFillerId, + int? PostRollFillerId, + int? TailFillerId, + int? FallbackFillerId, + List WatermarkIds, + List GraphicsElementIds, + string? PreferredAudioLanguageCode, + string? PreferredAudioTitle, + string? PreferredSubtitleLanguageCode, + ChannelSubtitleMode? SubtitleMode, + string? CollectionName, + string? MultiCollectionName, + string? SmartCollectionName, + string? RerunCollectionName, + string? PlaylistName, + int? PlaylistGroupId, + string? MediaItemName, + string? PreRollFillerName, + string? MidRollFillerName, + string? PostRollFillerName, + string? TailFillerName, + string? FallbackFillerName, + List Watermarks, + List GraphicsElements, + string? Name, + TimeSpan? DurationEstimate); diff --git a/ErsatzTV.Core/Api/Scheduling/ScheduleItemsResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/ScheduleItemsResponseModel.cs new file mode 100644 index 000000000..3e7ea789b --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/ScheduleItemsResponseModel.cs @@ -0,0 +1,12 @@ +#nullable enable +using System.Collections.Generic; + +namespace ErsatzTV.Core.Api.Scheduling; + +/// +/// GET envelope for a schedule's items. Carries the flat +/// list plus a best-effort total runtime estimate (null when unbounded/unknown). +/// +public record ScheduleItemsResponseModel( + List Items, + TimeSpan? TotalDurationEstimate); diff --git a/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs b/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs index 6c810cffd..57b8783b9 100644 --- a/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs @@ -32,8 +32,24 @@ public class FillerPresetHandlerTests await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None); result.Count.ShouldBe(2); - result.ShouldContain(new FillerPresetResponseModel(1, "Intro")); - result.ShouldContain(new FillerPresetResponseModel(2, "Outro")); + result.ShouldContain(new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll)); + result.ShouldContain(new FillerPresetResponseModel(2, "Outro", FillerKind.PreRoll)); + } + + [Test] + public async Task GetAllFillerPresetsForApi_Should_Filter_By_FillerKind() + { + await SeedPreset(1, "Intro", FillerKind.PreRoll); + await SeedPreset(2, "Mid", FillerKind.MidRoll); + await SeedPreset(3, "Outro", FillerKind.PostRoll); + + var handler = new GetAllFillerPresetsForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllFillerPresetsForApi(FillerKind.MidRoll), CancellationToken.None); + + result.Count.ShouldBe(1); + result.ShouldContain(new FillerPresetResponseModel(2, "Mid", FillerKind.MidRoll)); } [Test] @@ -47,14 +63,14 @@ public class FillerPresetHandlerTests result.ShouldBeEmpty(); } - private async Task SeedPreset(int id, string name) + private async Task SeedPreset(int id, string name, FillerKind fillerKind = FillerKind.PreRoll) { await using TvContext context = _db.CreateContext(); context.FillerPresets.Add(new FillerPreset { Id = id, Name = name, - FillerKind = FillerKind.PreRoll, + FillerKind = fillerKind, FillerMode = FillerMode.Duration, CollectionType = CollectionType.Collection }); diff --git a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs index 01e6df84f..b49f8e6bd 100644 --- a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs @@ -63,17 +63,30 @@ public class FillerPresetControllerTests { List models = [ - new FillerPresetResponseModel(1, "Intro"), - new FillerPresetResponseModel(2, "Outro") + new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll), + new FillerPresetResponseModel(2, "Outro", FillerKind.PostRoll) ]; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(models); - List result = await _controller.GetAll(CancellationToken.None); + List result = await _controller.GetAll(null, CancellationToken.None); result.ShouldBe(models); } + [Test] + public async Task GetAll_Should_Forward_FillerKind_Filter() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([]); + + await _controller.GetAll(FillerKind.MidRoll, CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => q.FillerKind == FillerKind.MidRoll), + Arg.Any()); + } + [Test] public async Task GetById_Should_Return_200_For_Some() { diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 9082d7f3b..a699f2051 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Playouts; +using ErsatzTV.Application.Templates; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; @@ -43,6 +44,22 @@ public class ChannelController(ChannelWriter workerCh CancellationToken cancellationToken) => await mediator.Send(new GetChannelGuideData(start, end), cancellationToken); + [HttpGet("/api/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")] + [Tags("Channels")] + [EndpointSummary("Get available music video credits template names")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) => + await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken); + + [HttpGet("/api/channels/stream-selectors", Name = "GetChannelStreamSelectors")] + [Tags("Channels")] + [EndpointSummary("Get available channel stream selector names")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetStreamSelectors(CancellationToken cancellationToken) => + await mediator.Send(new GetChannelStreamSelectors(), cancellationToken); + [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] [Tags("Channels")] [EndpointSummary("Get a channel by id")] diff --git a/ErsatzTV/Controllers/Api/FillerPresetController.cs b/ErsatzTV/Controllers/Api/FillerPresetController.cs index d07ee035a..8fa51aabb 100644 --- a/ErsatzTV/Controllers/Api/FillerPresetController.cs +++ b/ErsatzTV/Controllers/Api/FillerPresetController.cs @@ -3,6 +3,7 @@ using ErsatzTV.Application.Filler; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; @@ -16,10 +17,13 @@ public class FillerPresetController(IMediator mediator) : ControllerBase [HttpGet("/api/filler-presets", Name = "GetFillerPresets")] [Tags("Filler Presets")] [EndpointSummary("Get all filler presets")] + [EndpointDescription("Optionally filter to a single filler kind via the fillerKind query parameter.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] - public async Task> GetAll(CancellationToken cancellationToken) => - await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken); + public async Task> GetAll( + [FromQuery] FillerKind? fillerKind, + CancellationToken cancellationToken) => + await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken); [HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")] [Tags("Filler Presets")] diff --git a/ErsatzTV/Controllers/Api/LanguagesController.cs b/ErsatzTV/Controllers/Api/LanguagesController.cs new file mode 100644 index 000000000..069caddb0 --- /dev/null +++ b/ErsatzTV/Controllers/Api/LanguagesController.cs @@ -0,0 +1,25 @@ +using System.Linq; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core.Api.MediaItems; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class LanguagesController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/languages", Name = "GetLanguages")] + [Tags("Languages")] + [EndpointSummary("Get all available language codes")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetLanguages(CancellationToken cancellationToken) + { + List languages = await mediator.Send(new GetAllLanguageCodes(), cancellationToken); + return languages + .Select(l => new LanguageCodeResponseModel(l.ThreeLetterISOLanguageName, l.EnglishName)) + .ToList(); + } +} diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs index 2a8d6f5ac..0a46e4e16 100644 --- a/ErsatzTV/Controllers/Api/ScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; +using System.Linq; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; +using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; @@ -104,7 +106,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase "nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " + "derived from referenced collection/media runtimes and are null when unbounded or unknown.")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { @@ -116,14 +118,14 @@ public class ScheduleController(IMediator mediator) : ControllerBase ProgramScheduleItemsWithDurationViewModel items = await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken); - return new OkObjectResult(items); + return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items)); } [HttpPost("/api/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Add a schedule item")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(ProgramScheduleItemViewModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ScheduleItemResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task AddItem( @@ -133,14 +135,16 @@ public class ScheduleController(IMediator mediator) : ControllerBase { Either result = await mediator.Send(request.ToAddCommand(id), cancellationToken); - return result.ToCreatedResult(item => $"/api/schedules/{id}/items/{item.Id}", item => item); + return result.ToCreatedResult( + item => $"/api/schedules/{id}/items/{item.Id}", + item => ScheduleItemResponseMapper.ProjectToResponseModel(item)); } [HttpPut("/api/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Replace schedule items")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplaceItems( @@ -150,7 +154,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase { Either> result = await mediator.Send(request.ToCommand(id), cancellationToken); - return result.ToUpdatedResult(); + return result + .Map(items => items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList()) + .ToUpdatedResult(); } [HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]