#197 Bundle C: OpenAPI contract honesty (#287) + wrap raw VMs / nullable DTOs (#288) + channel re-key #296

Merged
timothy merged 8 commits from bundle-c-197-contract into main 2026-07-12 12:25:21 +02:00
74 changed files with 7050 additions and 1076 deletions
+35
View File
@@ -38,6 +38,41 @@ internal static class Mapper
channel.IsEnabled,
channel.ShowInEpg);
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(Channel channel, int playoutCount)
{
ArtworkContentTypeModel logo = GetLogo(channel);
return new ChannelDetailResponseModel(
channel.Id,
channel.Number,
channel.Name,
channel.Group,
channel.Categories,
channel.FFmpegProfileId,
channel.SlugSeconds,
new ChannelLogoResponseModel(logo.Path, logo.ContentType),
channel.StreamSelectorMode,
channel.StreamSelector,
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.PlayoutSource,
channel.PlayoutMode,
channel.MirrorSourceChannelId,
channel.PlayoutOffset,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
playoutCount,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate,
channel.SongVideoMode,
channel.TranscodeMode,
channel.IdleBehavior,
channel.IsEnabled,
channel.ShowInEpg);
}
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
channel.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelByIdForApi(int Id) : IRequest<Option<ChannelDetailResponseModel>>;
@@ -0,0 +1,15 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByIdForApi, Option<ChannelDetailResponseModel>>
{
public Task<Option<ChannelDetailResponseModel>> Handle(
GetChannelByIdForApi request,
CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0));
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutIdByChannelId(int ChannelId) : IRequest<Option<int>>;
@@ -0,0 +1,18 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class GetPlayoutIdByChannelIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPlayoutIdByChannelId, Option<int>>
{
public async Task<Option<int>> Handle(GetPlayoutIdByChannelId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Filter(p => p.Channel.Id == request.ChannelId)
.Map(p => p.Id)
.ToListAsync(cancellationToken)
.Map(list => list.HeadOrNone());
}
}
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search;
public record GetSearchResults(string Query, int PageSize) : IRequest<SearchResultsResponseModel>;
public record GetSearchResults(string Query, int PageNum, int PageSize) : IRequest<SearchResultsResponseModel>;
@@ -19,7 +19,7 @@ public class GetSearchResultsHandler(IMediator mediator)
async Task<SearchResultGroupResponseModel> ForKind(LibraryBrowseMediaType kind)
{
PagedLibraryBrowseItemsResponseModel paged = await mediator.Send(
new GetLibraryBrowseItems(request.Query, null, kind, 0, request.PageSize),
new GetLibraryBrowseItems(request.Query, null, kind, request.PageNum, request.PageSize),
cancellationToken);
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
}
@@ -0,0 +1,55 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Channels;
// Faithful channel-detail DTO for the SPA channel editor (GET/POST/PUT /api/channels[/{id}]).
// Exposes the raw editable field set the editor reads (raw FFmpegProfileId / WatermarkId /
// FallbackFillerId ids and the mode enums), unlike the lean list ChannelResponseModel which
// resolves the profile to a display name and drops the editable ids. Mirrors the effective
// nullability of the (now-removed-from-the-schema) ChannelViewModel so the SPA form logic is
// unaffected. WebEncodedName is intentionally dropped: the SPA never reads it.
public record ChannelDetailResponseModel(
int Id,
string Number,
string Name,
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ChannelLogoResponseModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
int PlayoutCount,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg);
// Wire-compatible mirror of the Application-layer ArtworkContentTypeModel (which lives in
// ErsatzTV.Application and therefore can't be referenced from Core). Same serialized shape the
// SPA's UpdateChannelRequest.logo already expects, so draftFromChannel's round-trip is lossless.
public record ChannelLogoResponseModel(string Path, string ContentType)
{
public bool IsExternalUrl => Domain.Artwork.IsExternalUrl(Path);
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
// The artwork serve routes sniff the content type from the stored file and no longer honor a
// client-supplied ?contentType= (issue #283), so the directly-usable URL is just the path.
public string UrlWithContentType => Path;
}
@@ -1,4 +1,5 @@
namespace ErsatzTV.Core.Api.FFmpegProfiles;
#nullable enable
namespace ErsatzTV.Core.Api.FFmpegProfiles;
public record FFmpegProfileResponseModel(
int Id,
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Filler;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Graphics;
public record GraphicsElementResponseModel(int Id, string Name);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Libraries;
public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
@@ -0,0 +1,14 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaCollections;
// API response DTO for a manual collection. Exposes only the collection's own consumer-facing
// fields; deliberately drops the Application VM's MediaCardViewModel scaffolding (Title/Subtitle/
// SortTitle/Poster/HasMediaInfo) and the header-only optimistic-concurrency token (issue #253,
// #288). The ETag carries Version; the SPA reads it from the response header, never the body.
public record MediaCollectionResponseModel(
int Id,
string Name,
CollectionType CollectionType,
bool UseCustomPlaybackOrder);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaItems;
public record LanguageCodeResponseModel(string Code, string EnglishName);
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record LocalLibraryPathResponseModel(int Id, string Path, int MediaItemCount);
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record LocalPathCheckResponseModel(bool Exists);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record PathReplacementResponseModel(int Id, string RemotePath, string LocalPath);
public record PathReplacementResponseModel(int Id, string? RemotePath, string? LocalPath);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record PlexPinFlowResponseModel(string AuthUrl);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
// NEVER include the raw API key in this DTO — the secure connection contract (design #202 finding 1)
@@ -1,5 +1,6 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
public record RemoteLibraryResponseModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems);
public record RemoteLibraryResponseModel(int Id, string? Name, LibraryMediaKind MediaKind, bool ShouldSyncItems);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record RemoteMediaSourceItemResponseModel(int Id, string Name, string Address);
public record RemoteMediaSourceItemResponseModel(int Id, string? Name, string? Address);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record RemoteMediaSourceStateResponseModel(
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api;
/// <summary>
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutItemsResponseModel(
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutsResponseModel(
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutBuildStatusResponseModel(
@@ -0,0 +1,16 @@
#nullable enable
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
// API response DTO for a program schedule. Mirrors the Application ProgramScheduleViewModel's
// config fields but omits the header-only optimistic-concurrency token (issue #253, #288): the
// schedule-items GET/PUT carry Version as an ETag header, so the SPA never reads it from the body.
public record ProgramScheduleResponseModel(
int Id,
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
public record SearchResultAllItemsResponseModel(
@@ -1,3 +1,4 @@
#nullable enable
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
@@ -1,3 +1,4 @@
#nullable enable
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.SmartCollections;
public record SmartCollectionResponseModel(int Id, string Name, string Query);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkResponseModel(int Id, string Name);
@@ -11,6 +11,7 @@ public class ChannelRepository(IDbContextFactory<TvContext> dbContextFactory) :
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Channels
.AsNoTracking()
.Include(c => c.FFmpegProfile)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.OrderBy(c => c.Id)
@@ -0,0 +1,73 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelByIdForApiHandlerTests
{
[Test]
public async Task Should_Project_DetailResponseModel_When_Found()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetChannel(7)
.Returns(Option<Channel>.Some(new Channel(Guid.NewGuid())
{
Id = 7,
Number = "7.1",
SortNumber = 7.1,
Name = "Retro Cartoons",
Group = "Kids",
Categories = "animation",
FFmpegProfileId = 3,
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
Artwork = [],
PreferredAudioLanguageCode = "eng",
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
WatermarkId = 11,
FallbackFillerId = 12,
IsEnabled = false,
ShowInEpg = false
}));
var handler = new GetChannelByIdForApiHandler(repository);
Option<ChannelDetailResponseModel> result =
await handler.Handle(new GetChannelByIdForApi(7), CancellationToken.None);
result.IsSome.ShouldBeTrue();
ChannelDetailResponseModel channel = result.IfNone(() => throw new InvalidOperationException());
channel.Id.ShouldBe(7);
channel.Number.ShouldBe("7.1");
channel.Name.ShouldBe("Retro Cartoons");
channel.Group.ShouldBe("Kids");
channel.Categories.ShouldBe("animation");
// Detail model exposes the raw editable ids the editor re-drafts (not a resolved name).
channel.FFmpegProfileId.ShouldBe(3);
channel.WatermarkId.ShouldBe(11);
channel.FallbackFillerId.ShouldBe(12);
channel.PreferredAudioLanguageCode.ShouldBe("eng");
channel.StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingSegmenter);
channel.PlayoutCount.ShouldBe(0);
channel.IsEnabled.ShouldBeFalse();
channel.ShowInEpg.ShouldBeFalse();
}
[Test]
public async Task Should_Return_None_When_Missing()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetChannel(Arg.Any<int>()).Returns(Option<Channel>.None);
var handler = new GetChannelByIdForApiHandler(repository);
Option<ChannelDetailResponseModel> result =
await handler.Handle(new GetChannelByIdForApi(99), CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
}
@@ -0,0 +1,59 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Playouts;
[TestFixture]
public class GetPlayoutIdByChannelIdHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private GetPlayoutIdByChannelIdHandler CreateHandler() => new(_db.Factory);
private async Task<(int ChannelId, int PlayoutId)> SeedChannelWithPlayout(string number)
{
await using TvContext context = _db.CreateContext();
var channel = new Channel(Guid.NewGuid()) { Number = number, Name = $"Channel {number}" };
context.Channels.Add(channel);
await context.SaveChangesAsync();
var playout = new Playout { ChannelId = channel.Id, ScheduleKind = PlayoutScheduleKind.Classic };
context.Playouts.Add(playout);
await context.SaveChangesAsync();
return (channel.Id, playout.Id);
}
[Test]
public async Task Handle_Should_Return_Playout_Id_For_Channel_Id()
{
(int channelId, int playoutId) = await SeedChannelWithPlayout("5.1");
Option<int> result = await CreateHandler().Handle(new GetPlayoutIdByChannelId(channelId), CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfNone(-1).ShouldBe(playoutId);
}
[Test]
public async Task Handle_Should_Return_None_When_Channel_Has_No_Playout()
{
(int channelId, _) = await SeedChannelWithPlayout("5.1");
Option<int> result =
await CreateHandler().Handle(new GetPlayoutIdByChannelId(channelId + 1000), CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
}
@@ -97,20 +97,20 @@ public class ChannelControllerTests
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
public async Task Create_Should_Return_201_With_Location_And_ResponseModel_Body()
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
ChannelViewModel vm = MakeVm(5);
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(vm));
ChannelDetailResponseModel model = MakeDetailModel(5);
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelDetailResponseModel>.Some(model));
IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/channels/5");
created.Value.ShouldBe(vm);
created.Value.ShouldBe(model);
}
[Test]
@@ -118,8 +118,8 @@ public class ChannelControllerTests
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(MakeVm(5)));
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelDetailResponseModel>.Some(MakeDetailModel(5)));
await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None);
@@ -188,18 +188,23 @@ public class ChannelControllerTests
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
public async Task Update_Should_Return_200_With_ResponseModel_And_Map_Route_Id()
{
ChannelViewModel vm = MakeVm(7);
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelViewModel>(vm));
.Returns(Right<BaseError, ChannelViewModel>(MakeVm(7)));
ChannelDetailResponseModel model = MakeDetailModel(7);
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelDetailResponseModel>.Some(model));
IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
await _mediator.Received(1).Send(
Arg.Is<UpdateChannel>(c => c.ChannelId == 7),
Arg.Any<CancellationToken>());
await _mediator.Received(1).Send(
Arg.Is<GetChannelByIdForApi>(q => q.Id == 7),
Arg.Any<CancellationToken>());
}
[Test]
@@ -354,22 +359,22 @@ public class ChannelControllerTests
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
public async Task GetById_Should_Return_200_With_ResponseModel_For_Some()
{
ChannelViewModel vm = MakeVm(4);
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(vm));
ChannelDetailResponseModel model = MakeDetailModel(4);
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelDetailResponseModel>.Some(model));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.None);
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelDetailResponseModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
@@ -379,13 +384,21 @@ public class ChannelControllerTests
problemDetails.Title.ShouldBe("Resource not found");
}
[Test]
public void ResetPlayout_Route_Is_Keyed_By_Int_Id()
{
MethodInfo reset = typeof(ChannelController).GetMethod(nameof(ChannelController.ResetPlayout))!;
reset.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
.ShouldBe("/api/channels/{id:int}/playout/reset");
}
[Test]
public async Task ResetPlayout_Should_Return_ProblemDetails_404_For_Missing_Channel()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout(404, mode: null, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
@@ -393,6 +406,22 @@ public class ChannelControllerTests
problemDetails.Title.ShouldBe("Resource not found");
}
[Test]
public async Task ResetPlayout_Should_Resolve_Playout_By_Channel_Id()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, PlayoutScheduleKind.Classic)));
IActionResult result = await _controller.ResetPlayout(42, mode: null, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
await _mediator.Received(1).Send(
Arg.Is<GetPlayoutIdByChannelId>(q => q.ChannelId == 42),
Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)]
[TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)]
[TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)]
@@ -400,12 +429,12 @@ public class ChannelControllerTests
PlayoutScheduleKind scheduleKind,
PlayoutBuildMode expectedMode)
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, scheduleKind)));
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout(5, mode: null, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
@@ -417,11 +446,11 @@ public class ChannelControllerTests
[Test]
public async Task ResetPlayout_Should_Return_409_When_Playout_Locked()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout(5, mode: null, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
@@ -431,10 +460,10 @@ public class ChannelControllerTests
[Test]
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout(5, PlayoutBuildMode.Continue, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
@@ -457,6 +486,38 @@ public class ChannelControllerTests
null,
0);
private static ChannelDetailResponseModel MakeDetailModel(int id) =>
new(
id,
"5",
"Test",
"ErsatzTV",
string.Empty,
1,
null,
new ChannelLogoResponseModel(string.Empty, string.Empty),
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
0,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
private static ChannelViewModel MakeVm(int id) =>
new(
id,
@@ -4,6 +4,7 @@ using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
@@ -124,7 +125,7 @@ public class CollectionControllerTests
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/collections/5");
created.Value.ShouldBe(vm);
created.Value.ShouldBe(new MediaCollectionResponseModel(5, "Movies", CollectionType.Collection, false));
}
[Test]
@@ -165,7 +166,8 @@ public class CollectionControllerTests
new UpdateCollectionRequest("Updated", true),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBe(new MediaCollectionResponseModel(7, "Updated", CollectionType.Collection, false));
await _mediator.Received(1).Send(
Arg.Is<UpdateCollection>(c =>
c.CollectionId == 7 &&
@@ -274,9 +276,13 @@ public class CollectionControllerTests
_mediator.Send(Arg.Any<GetAllCollections>(), Arg.Any<CancellationToken>())
.Returns(vms);
List<MediaCollectionViewModel> result = await _controller.GetAll(CancellationToken.None);
List<MediaCollectionResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(vms);
result.ShouldBe(
[
new MediaCollectionResponseModel(1, "Movies", CollectionType.Collection, false),
new MediaCollectionResponseModel(2, "Shows", CollectionType.Collection, false)
]);
}
[Test]
@@ -288,7 +294,8 @@ public class CollectionControllerTests
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBe(new MediaCollectionResponseModel(4, "Movies", CollectionType.Collection, false));
}
[Test]
@@ -0,0 +1,151 @@
using ErsatzTV.Serialization;
using ErsatzTV.Tests.Support;
using Microsoft.OpenApi;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Pins the #287 "contract honesty by construction" transformers against the document the app would
/// actually serve (generated in-process; see <see cref="GeneratedOpenApiDocument" />): the ApiKey
/// security scheme + per-operation security/401, a stable operationId on every operation, and the
/// DayOfWeek string enum. If a transformer regresses (or is dropped from the "v1" registration) these
/// go red without needing the committed v1.json to be regenerated first.
/// </summary>
[TestFixture]
public class OpenApiContractHonestyTests
{
private static OpenApiDocument _document = null!;
[OneTimeSetUp]
public async Task BuildDocument() => _document = await GeneratedOpenApiDocument.BuildV1Async();
[Test]
public void Should_Declare_ApiKey_Security_Scheme_In_Header()
{
_document.Components.ShouldNotBeNull();
_document.Components!.SecuritySchemes.ShouldNotBeNull();
_document.Components!.SecuritySchemes!.TryGetValue("ApiKey", out IOpenApiSecurityScheme? scheme)
.ShouldBeTrue();
var concrete = scheme.ShouldBeOfType<OpenApiSecurityScheme>();
concrete.Type.ShouldBe(SecuritySchemeType.ApiKey);
concrete.In.ShouldBe(ParameterLocation.Header);
concrete.Name.ShouldBe("X-Api-Key");
}
[Test]
public void Every_Operation_Should_Have_A_NonEmpty_OperationId()
{
List<string> missing = EnumerateOperations()
.Where(op => string.IsNullOrEmpty(op.Operation.OperationId))
.Select(op => $"{op.Method} {op.Path}")
.ToList();
missing.ShouldBeEmpty();
}
[Test]
public void Operation_Ids_Should_Be_Unique()
{
List<string> ids = EnumerateOperations()
.Select(op => op.Operation.OperationId!)
.ToList();
ids.Count.ShouldBeGreaterThan(150);
ids.Distinct(StringComparer.Ordinal).Count().ShouldBe(ids.Count);
}
[Test]
public void Gated_Operations_Should_Declare_Security_And_401()
{
// Under the default posture (Api:RequireKeyForReads=true) every documented operation requires the
// key, so each must carry the ApiKey security requirement AND a documented 401.
foreach ((string method, string path, OpenApiOperation operation) in EnumerateOperations())
{
operation.Security.ShouldNotBeNull($"{method} {path} should declare security");
operation.Security!.Count.ShouldBeGreaterThan(0, $"{method} {path} should declare security");
operation.Responses.ShouldNotBeNull();
operation.Responses!.ContainsKey("401").ShouldBeTrue($"{method} {path} should document 401");
}
}
[Test]
public void Colliding_Troubleshoot_HeadGet_Pairs_Should_Carry_VerbDerived_OperationIds()
{
// The 3 Troubleshoot HEAD/GET pairs share a controller+action and differ only by verb. Their
// synthesized operationIds must be verb-suffixed (structural, order-independent) — NOT one bare
// base + one arbitrary suffix that depends on ApiExplorer visitation order. See #197 Bundle C.
var ids = EnumerateOperations().Select(op => op.Operation.OperationId!).ToHashSet(StringComparer.Ordinal);
foreach (string expected in new[]
{
"TroubleshootTroubleshootPlaybackGet",
"TroubleshootTroubleshootPlaybackHead",
"TroubleshootTroubleshootPlaybackArchiveGet",
"TroubleshootTroubleshootPlaybackArchiveHead",
"TroubleshootTroubleshootPlaybackSampleGet",
"TroubleshootTroubleshootPlaybackSampleHead"
})
{
ids.ShouldContain(expected);
}
// The old order-dependent forms (bare base + "...GET" suffix) must be gone.
foreach (string stale in new[]
{
"TroubleshootTroubleshootPlayback",
"TroubleshootTroubleshootPlaybackArchive",
"TroubleshootTroubleshootPlaybackSample",
"TroubleshootTroubleshootPlaybackGET",
"TroubleshootTroubleshootPlaybackArchiveGET",
"TroubleshootTroubleshootPlaybackSampleGET"
})
{
ids.ShouldNotContain(stale);
}
}
[Test]
public void ValidationProblemDetails_Schema_And_400_Should_Be_Documented_For_Binding_Operations()
{
_document.Components!.Schemas!.ContainsKey(
ApiSecuritySchemeDocumentTransformer.ValidationProblemDetailsSchemaId).ShouldBeTrue();
// Any operation that binds a body or parameters must document a 400 (model-binding / validation).
foreach ((string method, string path, OpenApiOperation operation) in EnumerateOperations())
{
bool binds = operation.RequestBody is not null || operation.Parameters is { Count: > 0 };
if (binds)
{
operation.Responses!.ContainsKey("400").ShouldBeTrue($"{method} {path} should document 400");
}
}
}
[Test]
public void DayOfWeek_Schema_Should_Be_A_String_Enum()
{
_document.Components!.Schemas!.TryGetValue("DayOfWeek", out IOpenApiSchema? schema).ShouldBeTrue();
schema!.Type.ShouldBe(JsonSchemaType.String);
schema.Enum.ShouldNotBeNull();
schema.Enum!.Select(n => n!.GetValue<string>()).ShouldContain("Sunday");
}
private static IEnumerable<(string Method, string Path, OpenApiOperation Operation)> EnumerateOperations()
{
foreach (KeyValuePair<string, IOpenApiPathItem> path in _document.Paths)
{
if (path.Value.Operations is null)
{
continue;
}
foreach (KeyValuePair<HttpMethod, OpenApiOperation> operation in path.Value.Operations)
{
yield return (operation.Key.Method, path.Key, operation.Value);
}
}
}
}
@@ -114,8 +114,8 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/bulk/group", "post", "422")]
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "409")]
[TestCase("/api/channels/{id}/playout/reset", "post", "404")]
[TestCase("/api/channels/{id}/playout/reset", "post", "409")]
[TestCase("/api/channel-templates/default", "get", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
@@ -1,6 +1,4 @@
using System.Text.Json;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.MediaItems;
using ErsatzTV.Core.Api.Settings;
@@ -35,13 +33,15 @@ public class OpenApiSerializerContractTests
private static IEnumerable<TestCaseData> Cases()
{
yield return new TestCaseData(FullyPopulatedChannel(), "ChannelViewModel").SetName("ChannelViewModel");
yield return new TestCaseData(FullyPopulatedChannelDetail(), "ChannelDetailResponseModel")
.SetName("ChannelDetailResponseModel");
yield return new TestCaseData(FullyPopulatedFFmpegSettings(), "FFmpegSettingsResponseModel")
.SetName("FFmpegSettingsResponseModel");
// WatermarkViewModel was dropped from the API surface by #126: schedule-item endpoints no longer
// expose the polymorphic ProgramScheduleItemViewModel (which transitively pulled in WatermarkViewModel
// and the other collection VMs) — they return the flat ScheduleItemResponseModel, embedding watermarks
// as NamedIdResponseModel. ChannelViewModel below still exercises the VM path of the schema transformer.
// as NamedIdResponseModel. ChannelDetailResponseModel above still exercises the "ffmpegProfileId"
// special-case naming path of the schema transformer.
yield return new TestCaseData(FullyPopulatedMediaItemInfo(), "MediaItemInfoResponseModel")
.SetName("MediaItemInfoResponseModel");
@@ -73,7 +73,7 @@ public class OpenApiSerializerContractTests
$"OpenAPI schema '{schemaName}' property names must match the runtime Newtonsoft JSON keys.");
}
private static ChannelViewModel FullyPopulatedChannel() => new(
private static ChannelDetailResponseModel FullyPopulatedChannelDetail() => new(
1,
"1",
"Name",
@@ -81,7 +81,7 @@ public class OpenApiSerializerContractTests
"Categories",
1,
1.0,
new ArtworkContentTypeModel("path", "image/png"),
new ChannelLogoResponseModel("path", "image/png"),
default,
"selector",
"en",
@@ -47,6 +47,31 @@ public class ResolutionControllerTests
]);
}
[Test]
public async Task GetResolutionByName_Should_Return_ResponseModel_Not_ViewModel()
{
_mediator.Send(Arg.Any<GetResolutionByName>(), Arg.Any<CancellationToken>())
.Returns(Option<ResolutionViewModel>.Some(new ResolutionViewModel(1, "1920x1080", 1920, 1080, false)));
ActionResult<ResolutionResponseModel> result =
await _controller.GetResolutionByName("1920x1080", CancellationToken.None);
var ok = result.Result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBe(new ResolutionResponseModel(1, "1920x1080", 1920, 1080, false));
}
[Test]
public async Task GetResolutionByName_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetResolutionByName>(), Arg.Any<CancellationToken>())
.Returns(Option<ResolutionViewModel>.None);
ActionResult<ResolutionResponseModel> result =
await _controller.GetResolutionByName("nope", CancellationToken.None);
result.Result.ShouldBeOfType<NotFoundResult>();
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
@@ -79,7 +79,8 @@ public class ScheduleControllerTests
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/schedules/5");
created.Value.ShouldBe(vm);
created.Value.ShouldBe(
new ProgramScheduleResponseModel(5, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible));
}
[Test]
@@ -128,7 +129,8 @@ public class ScheduleControllerTests
IActionResult result = await _controller.Update(7, MakeUpdateScheduleRequest("Updated"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
new ProgramScheduleResponseModel(7, "Updated", true, true, false, false, FixedStartTimeBehavior.Flexible));
await _mediator.Received(1).Send(
Arg.Is<UpdateProgramSchedule>(c => c.ProgramScheduleId == 7 && c.Name == "Updated"),
Arg.Any<CancellationToken>());
@@ -179,7 +181,8 @@ public class ScheduleControllerTests
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
new ProgramScheduleResponseModel(4, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible));
}
[Test]
@@ -42,7 +42,7 @@ public class SearchControllerTests
[Test]
public async Task Search_Should_Return_422_For_Empty_Query()
{
IActionResult result = await _controller.Search(" ", 50, CancellationToken.None);
IActionResult result = await _controller.Search(" ", 0, 50, CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.StatusCode.ShouldBe(422);
@@ -55,7 +55,7 @@ public class SearchControllerTests
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>())
.Returns(EmptyResults());
IActionResult result = await _controller.Search("star", 500, CancellationToken.None);
IActionResult result = await _controller.Search("star", 0, 500, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
@@ -63,13 +63,31 @@ public class SearchControllerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task Search_Should_Thread_PageNum_And_Clamp_Negative()
{
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>())
.Returns(EmptyResults());
// A valid page number is threaded through; a negative one clamps to 0.
await _controller.Search("star", 3, 50, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageNum == 3 && q.PageSize == 50),
Arg.Any<CancellationToken>());
await _controller.Search("star", -7, 50, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageNum == 0 && q.PageSize == 50),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Search_Should_Return_Grouped_Results()
{
SearchResultsResponseModel results = EmptyResults();
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>()).Returns(results);
IActionResult result = await _controller.Search("star", 50, CancellationToken.None);
IActionResult result = await _controller.Search("star", 0, 50, CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBe(results);
@@ -54,7 +54,7 @@ public class SmartCollectionControllerTests
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/smart-collections/7");
created.Value.ShouldBe(vm);
created.Value.ShouldBe(new SmartCollectionResponseModel(7, "Kids", "tag:family"));
}
[Test]
@@ -98,7 +98,8 @@ public class SmartCollectionControllerTests
new UpdateSmartCollectionRequest("Updated", "tag:updated"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBe(new SmartCollectionResponseModel(8, "Updated", "tag:updated"));
await _mediator.Received(1).Send(
Arg.Is<UpdateSmartCollection>(c =>
c.Id == 8 && c.Name == "Updated" && c.Query == "tag:updated"),
@@ -166,7 +167,8 @@ public class SmartCollectionControllerTests
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBe(new SmartCollectionResponseModel(4, "Kids", "tag:kids"));
}
[Test]
@@ -0,0 +1,132 @@
using System.Collections.Generic;
using ErsatzTV.Filters;
using ErsatzTV.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Filters;
/// <summary>
/// Proves the shared <see cref="ApiKeyAuthorizationFilter.EndpointRequiresKey" /> predicate — the one
/// the OpenAPI security/401 transformer consumes — agrees with what the filter actually enforces at
/// runtime, across a representative matrix. If the two ever diverged, the spec could claim an endpoint
/// is open while the filter gates it (or vice-versa); this test is the anti-drift guard (#287).
/// </summary>
[TestFixture]
public class ApiKeyEndpointRequiresKeyTests
{
private const string Key = "secret";
private sealed class FakeApiKeyProvider(bool requireKeyForReads) : IApiKeyProvider
{
public string ApiKey => Key;
public bool RequireKeyForReads { get; } = requireKeyForReads;
}
// (method, requiresApiKey, skip, requireKeyForReads, expectedRequiresKey)
private static readonly object[] Matrix =
[
new object[] { "POST", false, false, false, true }, // write always gated
new object[] { "PUT", false, false, false, true },
new object[] { "PATCH", false, false, false, true },
new object[] { "DELETE", false, false, false, true },
new object[] { "GET", false, false, true, true }, // read gated when reads-required
new object[] { "GET", false, false, false, false }, // read open when reads-not-required
new object[] { "GET", true, false, false, true }, // [RequiresApiKey] gates read even so
new object[] { "HEAD", false, false, false, false }, // read verb, open
new object[] { "OPTIONS", false, false, true, false },// preflight always exempt
new object[] { "POST", false, true, false, false } // [SkipApiKeyAuthorization] exempt
];
[TestCaseSource(nameof(Matrix))]
public void Predicate_Matches_Expected(
string method,
bool requiresApiKey,
bool skip,
bool requireKeyForReads,
bool expected)
{
var metadata = new List<object>();
if (requiresApiKey)
{
metadata.Add(new RequiresApiKeyAttribute());
}
if (skip)
{
metadata.Add(new SkipApiKeyAuthorizationAttribute());
}
ApiKeyAuthorizationFilter.EndpointRequiresKey(method, metadata, requireKeyForReads)
.ShouldBe(expected);
}
[TestCaseSource(nameof(Matrix))]
public void Predicate_Agrees_With_Filter_Enforcement(
string method,
bool requiresApiKey,
bool skip,
bool requireKeyForReads,
bool expected)
{
// The filter, on an /api path with the header MISSING, produces a 401 exactly when the predicate
// says the endpoint requires a key. Drive the real filter and compare its decision to the predicate.
AuthorizationFilterContext context = MakeContext(method, requiresApiKey, skip);
new ApiKeyAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads)).OnAuthorization(context);
bool filterGated = context.Result is not null;
filterGated.ShouldBe(expected);
filterGated.ShouldBe(
ApiKeyAuthorizationFilter.EndpointRequiresKey(method, Metadata(requiresApiKey, skip), requireKeyForReads));
}
[Test]
public void Filter_Never_Gates_Non_Api_Paths_Even_When_Predicate_Would()
{
// The predicate assumes an /api endpoint; the filter's path scoping precedes it. A mutating request
// outside /api must pass untouched even though the predicate (given the same method) returns true.
ApiKeyAuthorizationFilter.EndpointRequiresKey("POST", new List<object>(), requireKeyForReads: true)
.ShouldBeTrue();
AuthorizationFilterContext context = MakeContext("POST", requiresApiKey: false, skip: false, path: "/iptv/x.m3u");
new ApiKeyAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads: true)).OnAuthorization(context);
context.Result.ShouldBeNull();
}
private static List<object> Metadata(bool requiresApiKey, bool skip)
{
var metadata = new List<object>();
if (requiresApiKey)
{
metadata.Add(new RequiresApiKeyAttribute());
}
if (skip)
{
metadata.Add(new SkipApiKeyAuthorizationAttribute());
}
return metadata;
}
private static AuthorizationFilterContext MakeContext(
string method,
bool requiresApiKey,
bool skip,
string path = "/api/channels")
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = method;
httpContext.Request.Path = path;
var actionDescriptor = new ActionDescriptor { EndpointMetadata = Metadata(requiresApiKey, skip) };
var actionContext = new ActionContext(httpContext, new RouteData(), actionDescriptor);
return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
}
}
@@ -0,0 +1,70 @@
using ErsatzTV;
using ErsatzTV.Controllers.Api;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi;
using NSubstitute;
using NUnit.Framework;
namespace ErsatzTV.Tests.Support;
/// <summary>
/// Builds the "v1" OpenAPI document in-process from the real <see cref="Startup.ConfigureServices" />
/// registrations, so contract tests validate the document the running app would actually serve —
/// including every registered document/operation/schema transformer — rather than a stale committed
/// <c>wwwroot/openapi/v1.json</c> that is regenerated out-of-band. Mirrors the DI-only bootstrap that
/// <c>ApiControllerSecurityTests</c> uses to inspect <c>MvcOptions</c>.
/// </summary>
public static class GeneratedOpenApiDocument
{
public static async Task<OpenApiDocument> BuildV1Async()
{
var settings = new Dictionary<string, string?>
{
["provider"] = "sqlite",
["ConnectionStrings:Data"] = "Data Source=:memory:"
};
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var environment = Substitute.For<IWebHostEnvironment>();
environment.ApplicationName.Returns("ErsatzTV");
environment.EnvironmentName.Returns("Development");
environment.ContentRootPath.Returns(TestContext.CurrentContext.TestDirectory);
environment.WebRootPath.Returns(TestContext.CurrentContext.TestDirectory);
environment.ContentRootFileProvider.Returns(new NullFileProvider());
environment.WebRootFileProvider.Returns(new NullFileProvider());
var services = new ServiceCollection();
// The host normally provides these; supply them so the OpenAPI document service and the
// API-key provider (an operation-transformer dependency) can be constructed.
services.AddSingleton(configuration);
services.AddLogging();
services.AddSingleton(environment);
services.AddSingleton<IHostEnvironment>(environment);
new Startup(configuration, environment).ConfigureServices(services);
// In the running app ErsatzTV is the entry assembly and its controllers are discovered
// automatically; under the test runner it is not, so register it as an application part.
ApplicationPartManager? partManager = services
.FirstOrDefault(d => d.ServiceType == typeof(ApplicationPartManager))?.ImplementationInstance
as ApplicationPartManager;
if (partManager is not null &&
!partManager.ApplicationParts.OfType<AssemblyPart>()
.Any(p => p.Assembly == typeof(CollectionController).Assembly))
{
partManager.ApplicationParts.Add(new AssemblyPart(typeof(CollectionController).Assembly));
}
await using ServiceProvider provider = services.BuildServiceProvider();
IOpenApiDocumentProvider documentProvider = provider.GetRequiredKeyedService<IOpenApiDocumentProvider>("v1");
return await documentProvider.GetOpenApiDocumentAsync();
}
}
+22 -11
View File
@@ -68,11 +68,11 @@ public class ChannelController(
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ChannelDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
Option<ChannelDetailResponseModel> result = await mediator.Send(new GetChannelByIdForApi(id), cancellationToken);
return result.ToGetResult();
}
@@ -80,7 +80,7 @@ public class ChannelController(
[Tags("Channels")]
[EndpointSummary("Create a channel")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ChannelDetailResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
@@ -92,10 +92,10 @@ public class ChannelController(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<ChannelViewModel> channel =
await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken);
Option<ChannelDetailResponseModel> channel =
await mediator.Send(new GetChannelByIdForApi(created.ChannelId), cancellationToken);
return channel.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm),
Some: model => (IActionResult)new CreatedResult($"/api/channels/{model.Id}", model),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -130,7 +130,7 @@ public class ChannelController(
[Tags("Channels")]
[EndpointSummary("Update a channel")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ChannelDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
@@ -140,7 +140,18 @@ public class ChannelController(
{
Either<BaseError, ChannelViewModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
// Re-project through the read-side query so the response carries the same
// ChannelDetailResponseModel shape (raw editable ids the editor re-drafts) as GET.
Option<ChannelDetailResponseModel> channel =
await mediator.Send(new GetChannelByIdForApi(id), cancellationToken);
return channel.Match(
Some: model => (IActionResult)new OkObjectResult(model),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/channels/{id:int}")]
@@ -203,7 +214,7 @@ public class ChannelController(
return result.ToDeletedResult();
}
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[HttpPost("/api/channels/{id:int}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
[EndpointDescription(
@@ -215,12 +226,12 @@ public class ChannelController(
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout(
string channelNumber,
int id,
[FromQuery] PlayoutBuildMode? mode,
CancellationToken cancellationToken)
{
Option<int> maybePlayoutId =
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
await mediator.Send(new GetPlayoutIdByChannelId(id), cancellationToken);
foreach (int playoutId in maybePlayoutId)
{
// Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight.
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -17,20 +18,23 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Get all collections")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<MediaCollectionViewModel>), StatusCodes.Status200OK)]
public async Task<List<MediaCollectionViewModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllCollections(), cancellationToken);
[ProducesResponseType(typeof(List<MediaCollectionResponseModel>), StatusCodes.Status200OK)]
public async Task<List<MediaCollectionResponseModel>> GetAll(CancellationToken cancellationToken)
{
List<MediaCollectionViewModel> collections = await mediator.Send(new GetAllCollections(), cancellationToken);
return collections.Map(ProjectToResponseModel).ToList();
}
[HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")]
[Tags("Collections")]
[EndpointSummary("Get a collection by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MediaCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> result = await mediator.Send(new GetCollectionById(id), cancellationToken);
return result.ToGetResult();
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")]
@@ -68,7 +72,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Create a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(MediaCollectionResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
@@ -77,14 +81,14 @@ public class CollectionController(IMediator mediator) : ControllerBase
{
Either<BaseError, MediaCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", vm => vm);
return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", ProjectToResponseModel);
}
[HttpPut("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Update a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MediaCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
@@ -100,7 +104,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
Option<MediaCollectionViewModel> collection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
return collection.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -196,4 +200,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
cancellationToken);
return result.ToDeletedResult();
}
private static MediaCollectionResponseModel ProjectToResponseModel(MediaCollectionViewModel vm) =>
new(vm.Id, vm.Name, vm.CollectionType, vm.UseCustomPlaybackOrder);
}
@@ -14,11 +14,19 @@ namespace ErsatzTV.Controllers.Api;
[EndpointGroupName("general")]
public class ResolutionController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/ffmpeg/resolution/by-name/{name}", Name="GetResolutionByName")]
public async Task<ActionResult<ResolutionViewModel>> GetResolutionByName(string name, CancellationToken cancellationToken)
[HttpGet("/api/ffmpeg/resolution/by-name/{name}", Name = "GetResolutionByName")]
[Tags("Settings")]
[EndpointSummary("Get a resolution by name")]
[ProducesResponseType(typeof(ResolutionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResolutionResponseModel>> GetResolutionByName(
string name,
CancellationToken cancellationToken)
{
Option<ResolutionViewModel> result = await mediator.Send(new GetResolutionByName(name), cancellationToken);
return result.Match<ActionResult<ResolutionViewModel>>(i => Ok(i), () => NotFound());
return result.Match<ActionResult<ResolutionResponseModel>>(
i => Ok(ProjectToResponseModel(i)),
() => NotFound());
}
[HttpGet("/api/settings/resolutions", Name = "GetResolutions")]
+24 -9
View File
@@ -18,27 +18,30 @@ public class ScheduleController(IMediator mediator) : ControllerBase
[Tags("Schedules")]
[EndpointSummary("Get all schedules")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ProgramScheduleViewModel>), StatusCodes.Status200OK)]
public async Task<List<ProgramScheduleViewModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
[ProducesResponseType(typeof(List<ProgramScheduleResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ProgramScheduleResponseModel>> GetAll(CancellationToken cancellationToken)
{
List<ProgramScheduleViewModel> schedules = await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
return schedules.Map(ProjectToResponseModel).ToList();
}
[HttpGet("/api/schedules/{id:int}", Name = "GetScheduleById")]
[Tags("Schedules")]
[EndpointSummary("Get a schedule by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ProgramScheduleViewModel> result = await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
return result.ToGetResult();
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpPost("/api/schedules")]
[Tags("Schedules")]
[EndpointSummary("Create a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
@@ -54,7 +57,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
Option<ProgramScheduleViewModel> schedule =
await mediator.Send(new GetProgramScheduleById(created.ProgramScheduleId), cancellationToken);
return schedule.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/schedules/{vm.Id}", vm),
Some: vm => (IActionResult)new CreatedResult(
$"/api/schedules/{vm.Id}",
ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -63,7 +68,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
[Tags("Schedules")]
[EndpointSummary("Update a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
@@ -80,7 +85,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
Option<ProgramScheduleViewModel> schedule =
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
return schedule.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -214,4 +219,14 @@ public class ScheduleController(IMediator mediator) : ControllerBase
await mediator.Send(new DeleteProgramScheduleItem(id, itemId), cancellationToken);
return result.ToDeletedResult();
}
private static ProgramScheduleResponseModel ProjectToResponseModel(ProgramScheduleViewModel vm) =>
new(
vm.Id,
vm.Name,
vm.KeepMultiPartEpisodesTogether,
vm.TreatCollectionsAsShows,
vm.ShuffleScheduleItems,
vm.RandomStartPoint,
vm.FixedStartTimeBehavior);
}
+3 -1
View File
@@ -24,6 +24,7 @@ public class SearchController(IMediator mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Search(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default)
{
@@ -32,9 +33,10 @@ public class SearchController(IMediator mediator) : ControllerBase
return BaseError.New("A non-empty query is required").ToErrorResult();
}
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
SearchResultsResponseModel result = await mediator.Send(
new GetSearchResults(query, clampedPageSize),
new GetSearchResults(query, clampedPageNum, clampedPageSize),
cancellationToken);
return new OkObjectResult(result);
}
@@ -25,20 +25,20 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
[Tags("Smart Collections")]
[EndpointSummary("Get a smart collection by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<SmartCollectionViewModel> result =
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
return result.ToGetResult();
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpPost("/api/smart-collections")]
[Tags("Smart Collections")]
[EndpointSummary("Create a smart collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
@@ -47,14 +47,14 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
{
Either<BaseError, SmartCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", vm => vm);
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", ProjectToResponseModel);
}
[HttpPut("/api/smart-collections/{id:int}")]
[Tags("Smart Collections")]
[EndpointSummary("Update a smart collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
@@ -71,7 +71,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
Option<SmartCollectionViewModel> smartCollection =
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
return smartCollection.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -88,4 +88,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
return result.ToDeletedResult();
}
private static SmartCollectionResponseModel ProjectToResponseModel(SmartCollectionViewModel vm) =>
new(vm.Id, vm.Name, vm.Query);
}
+34 -26
View File
@@ -32,19 +32,7 @@ public class ApiKeyAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthor
return;
}
// CORS preflight carries no custom headers and is handled by the CORS middleware.
if (HttpMethods.IsOptions(request.Method))
{
return;
}
// Explicit opt-out for internal, separately-guarded endpoints (e.g. the scanner callback).
if (HasMetadata<SkipApiKeyAuthorizationAttribute>(context))
{
return;
}
if (!RequiresKey(context, request))
if (!EndpointRequiresKey(request.Method, context.ActionDescriptor.EndpointMetadata, apiKeyProvider.RequireKeyForReads))
{
return;
}
@@ -61,18 +49,43 @@ public class ApiKeyAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthor
}
}
private bool RequiresKey(AuthorizationFilterContext context, HttpRequest request)
/// <summary>
/// The single decision shared between runtime enforcement (this filter) and the OpenAPI
/// document generation (<c>ApiSecurityOperationTransformer</c>), so the spec's declared
/// <c>security</c>/<c>401</c> can never drift from what is actually enforced. Assumes the
/// endpoint is already known to be under <c>/api</c> (the caller's responsibility). Returns
/// <see langword="true" /> when the endpoint requires a valid <c>X-Api-Key</c>: any mutating
/// verb (POST/PUT/PATCH/DELETE) is always fail-closed; reads are gated when
/// <paramref name="requireKeyForReads" /> is set or the endpoint carries
/// <see cref="RequiresApiKeyAttribute" />. <c>OPTIONS</c> preflight and endpoints marked
/// <see cref="SkipApiKeyAuthorizationAttribute" /> are exempt.
/// </summary>
public static bool EndpointRequiresKey(
string httpMethod,
IEnumerable<object> endpointMetadata,
bool requireKeyForReads)
{
string method = request.Method;
bool isMutating = HttpMethods.IsPost(method)
|| HttpMethods.IsPut(method)
|| HttpMethods.IsPatch(method)
|| HttpMethods.IsDelete(method);
// CORS preflight carries no custom headers and is handled by the CORS middleware.
if (HttpMethods.IsOptions(httpMethod))
{
return false;
}
// Explicit opt-out for internal, separately-guarded endpoints (e.g. the scanner callback).
if (endpointMetadata.OfType<SkipApiKeyAuthorizationAttribute>().Any())
{
return false;
}
bool isMutating = HttpMethods.IsPost(httpMethod)
|| HttpMethods.IsPut(httpMethod)
|| HttpMethods.IsPatch(httpMethod)
|| HttpMethods.IsDelete(httpMethod);
// Writes are always fail-closed; reads are gated by the global flag or a per-endpoint opt-in.
return isMutating
|| apiKeyProvider.RequireKeyForReads
|| HasMetadata<RequiresApiKeyAttribute>(context);
|| requireKeyForReads
|| endpointMetadata.OfType<RequiresApiKeyAttribute>().Any();
}
// Compare in constant time so a remote attacker cannot use response-timing to recover the
@@ -82,9 +95,4 @@ public class ApiKeyAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthor
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided),
Encoding.UTF8.GetBytes(configured));
private static bool HasMetadata<TAttribute>(AuthorizationFilterContext context)
where TAttribute : class =>
context.Filters.OfType<TAttribute>().Any()
|| context.ActionDescriptor.EndpointMetadata.OfType<TAttribute>().Any();
}
@@ -0,0 +1,60 @@
#nullable enable
using ErsatzTV.Filters;
using ErsatzTV.Services;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace ErsatzTV.Serialization;
/// <summary>
/// OpenAPI operation transformer that documents the API-key contract <b>by construction</b>: for
/// every operation that actually requires the <c>X-Api-Key</c> header it injects the
/// <c>ApiKey</c> security requirement and a documented <c>401</c> response. The "requires a key"
/// decision is the exact same predicate the runtime filter enforces
/// (<see cref="ApiKeyAuthorizationFilter.EndpointRequiresKey" />), so the spec can never claim an
/// endpoint is open when it is gated (or vice-versa). The <c>ApiKey</c> scheme itself and the
/// <c>ProblemDetails</c> schema the <c>401</c> references are declared by
/// <see cref="ApiSecuritySchemeDocumentTransformer" />. See issues #286/#287.
/// </summary>
public sealed class ApiSecurityOperationTransformer(IApiKeyProvider apiKeyProvider) : IOpenApiOperationTransformer
{
public const string SchemeName = "ApiKey";
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
string method = context.Description.HttpMethod ?? string.Empty;
IEnumerable<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
if (!ApiKeyAuthorizationFilter.EndpointRequiresKey(method, metadata, apiKeyProvider.RequireKeyForReads))
{
return Task.CompletedTask;
}
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference(SchemeName)] = new List<string>()
});
operation.Responses ??= new OpenApiResponses();
if (!operation.Responses.ContainsKey("401"))
{
operation.Responses["401"] = new OpenApiResponse
{
Description = "API key missing or invalid.",
Content = new Dictionary<string, OpenApiMediaType>
{
["application/json"] = new OpenApiMediaType
{
Schema = new OpenApiSchemaReference("ProblemDetails")
}
}
};
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,103 @@
#nullable enable
using ErsatzTV.Filters;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace ErsatzTV.Serialization;
/// <summary>
/// OpenAPI document transformer that declares the components the per-operation transformers reference:
/// the <c>ApiKey</c> security scheme (<c>X-Api-Key</c> request header) used by
/// <see cref="ApiSecurityOperationTransformer" />, and the <c>ValidationProblemDetails</c> schema used
/// by <see cref="ValidationProblemOperationTransformer" /> for documented <c>400</c> responses. Runs on
/// the "v1" document only. See issues #286/#287.
/// </summary>
public static class ApiSecuritySchemeDocumentTransformer
{
public const string ValidationProblemDetailsSchemaId = "ValidationProblemDetails";
public const string ProblemDetailsSchemaId = "ProblemDetails";
public static Task TransformAsync(
OpenApiDocument document,
OpenApiDocumentTransformerContext context,
CancellationToken cancellationToken)
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[ApiSecurityOperationTransformer.SchemeName] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
Name = ApiKeyAuthorizationFilter.HeaderName,
In = ParameterLocation.Header,
Description =
"API key sent in the 'X-Api-Key' request header. Required for all mutating requests and, " +
"under the default posture (Api:RequireKeyForReads=true), for reads as well."
};
document.Components.Schemas ??= new Dictionary<string, IOpenApiSchema>();
if (!document.Components.Schemas.ContainsKey(ValidationProblemDetailsSchemaId))
{
document.Components.Schemas[ValidationProblemDetailsSchemaId] = BuildValidationProblemDetailsSchema();
}
// The injected 401 (ApiSecurityOperationTransformer) references the ProblemDetails schema. It
// normally already exists because other actions declare ProducesResponseType(typeof(ProblemDetails)),
// but self-provision it if absent so the 401 reference never dangles (belt-and-suspenders).
if (!document.Components.Schemas.ContainsKey(ProblemDetailsSchemaId))
{
document.Components.Schemas[ProblemDetailsSchemaId] = BuildProblemDetailsSchema();
}
return Task.CompletedTask;
}
// The RFC 7807 shape ASP.NET Core returns for a plain ProblemDetails response (the ValidationProblemDetails
// members minus the "errors" map).
private static OpenApiSchema BuildProblemDetailsSchema() =>
new()
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["type"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null },
["title"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null },
["status"] = new OpenApiSchema
{
Type = JsonSchemaType.Integer | JsonSchemaType.Null,
Format = "int32"
},
["detail"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null },
["instance"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }
}
};
// The RFC 7807 shape ASP.NET Core's [ApiController] + FluentValidation return on a model-binding /
// validation failure: the standard ProblemDetails members plus an "errors" map of field -> messages.
private static OpenApiSchema BuildValidationProblemDetailsSchema() =>
new()
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["type"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null },
["title"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null },
["status"] = new OpenApiSchema
{
Type = JsonSchemaType.Integer | JsonSchemaType.Null,
Format = "int32"
},
["detail"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null },
["instance"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null },
["errors"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
AdditionalProperties = new OpenApiSchema
{
Type = JsonSchemaType.Array,
Items = new OpenApiSchema { Type = JsonSchemaType.String }
}
}
}
};
}
@@ -0,0 +1,120 @@
#nullable enable
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using HashSet = System.Collections.Generic.HashSet<string>;
namespace ErsatzTV.Serialization;
/// <summary>
/// OpenAPI operation transformer that guarantees every operation carries a stable, non-empty
/// <c>operationId</c>. Only actions with <c>Name = "..."</c> on their HTTP-method attribute get an
/// id from the framework; the rest (~90 of ~240) would ship without one, which breaks generated
/// clients that key methods off <c>operationId</c>. Here we synthesize one from the
/// <see cref="ControllerActionDescriptor" /> (<c>ControllerName</c> + <c>ActionName</c>) whenever the
/// id is null/empty, and never rename an id the developer set explicitly.
/// <para>
/// Disambiguation is <b>structural, not visitation-order-dependent</b>: the colliding cases are the
/// HEAD/GET pairs that share a controller+action but differ only by verb. We precompute (from the
/// full <see cref="IApiDescriptionGroupCollectionProvider" />, filtered to the same
/// <c>general</c> group the "v1" document includes) the set of synthesized base ids that 2+ operations
/// share, and suffix <b>each</b> such operation by its HTTP verb (<c>&lt;base&gt;Get</c>,
/// <c>&lt;base&gt;Head</c>). A base id that is unique across the document stays unsuffixed. The result
/// is a pure function of (controller, action, verb): an SDK/framework change to discovery order can
/// no longer rename a generated client method. Explicit ids are recorded so a synthesized id can't
/// shadow one; a defensive counter remains a last-resort backstop for an impossible residual clash.
/// </para>
/// </summary>
public sealed class OperationIdOpenApiTransformer : IOpenApiOperationTransformer
{
private const string GeneralGroupName = "general";
private readonly HashSet _used = new(StringComparer.Ordinal);
private readonly HashSet _collidingBaseIds;
public OperationIdOpenApiTransformer(IApiDescriptionGroupCollectionProvider descriptionProvider)
=> _collidingBaseIds = ComputeCollidingBaseIds(descriptionProvider);
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(operation.OperationId))
{
// Keep developer-assigned ids verbatim; just reserve them so nothing synthesized collides.
_used.Add(operation.OperationId);
return Task.CompletedTask;
}
if (context.Description.ActionDescriptor is not ControllerActionDescriptor controllerAction)
{
return Task.CompletedTask;
}
string baseId = controllerAction.ControllerName + controllerAction.ActionName;
// Structural, order-independent: if 2+ synthesized operations share this base id, suffix EACH
// by its verb; otherwise keep the bare base. Pure function of (controller, action, verb).
string candidate = _collidingBaseIds.Contains(baseId)
? baseId + PascalCaseVerb(context.Description.HttpMethod)
: baseId;
// Defensive backstop only — with verb suffixing this cannot fire for the known document, but
// guarantees uniqueness if some future shape produces an unexpected clash.
int suffix = 2;
string finalId = candidate;
while (!_used.Add(finalId))
{
finalId = candidate + suffix;
suffix++;
}
operation.OperationId = finalId;
return Task.CompletedTask;
}
private static HashSet ComputeCollidingBaseIds(IApiDescriptionGroupCollectionProvider provider)
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (ApiDescriptionGroup group in provider.ApiDescriptionGroups.Items)
{
foreach (ApiDescription description in group.Items)
{
// Mirror the "v1" document's ShouldInclude predicate.
if (description.GroupName != GeneralGroupName)
{
continue;
}
if (description.ActionDescriptor is not ControllerActionDescriptor controllerAction)
{
continue;
}
// Only operations that will actually be *synthesized* participate — an explicit
// Name= sets the route name (the framework's operationId), so it is excluded.
if (!string.IsNullOrEmpty(controllerAction.AttributeRouteInfo?.Name))
{
continue;
}
string baseId = controllerAction.ControllerName + controllerAction.ActionName;
counts[baseId] = counts.TryGetValue(baseId, out int c) ? c + 1 : 1;
}
}
return counts.Where(kvp => kvp.Value >= 2).Select(kvp => kvp.Key).ToHashSet(StringComparer.Ordinal);
}
private static string PascalCaseVerb(string? httpMethod)
{
if (string.IsNullOrEmpty(httpMethod))
{
return string.Empty;
}
return char.ToUpperInvariant(httpMethod[0]) + httpMethod[1..].ToLowerInvariant();
}
}
@@ -0,0 +1,52 @@
#nullable enable
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace ErsatzTV.Serialization;
/// <summary>
/// OpenAPI operation transformer that documents the <c>400</c> a model-binding / FluentValidation
/// failure actually returns. ASP.NET Core's <c>[ApiController]</c> convention emits a
/// <c>ValidationProblemDetails</c> (an <c>errors</c> map) for any operation that binds a request body
/// or parameters, but that response was undocumented. This adds a <c>400</c> referencing the
/// <c>ValidationProblemDetails</c> schema (declared by
/// <see cref="ApiSecuritySchemeDocumentTransformer" />) to every operation that binds something and
/// does not already document a <c>400</c>. See issues #286/#287.
/// </summary>
public static class ValidationProblemOperationTransformer
{
public static Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
bool bindsSomething = operation.RequestBody is not null
|| operation.Parameters is { Count: > 0 };
if (!bindsSomething)
{
return Task.CompletedTask;
}
operation.Responses ??= new OpenApiResponses();
if (operation.Responses.ContainsKey("400"))
{
return Task.CompletedTask;
}
operation.Responses["400"] = new OpenApiResponse
{
Description = "Request validation failed (model binding or FluentValidation).",
Content = new Dictionary<string, OpenApiMediaType>
{
["application/json"] = new OpenApiMediaType
{
Schema = new OpenApiSchemaReference(
ApiSecuritySchemeDocumentTransformer.ValidationProblemDetailsSchemaId)
}
}
};
return Task.CompletedTask;
}
}
+10
View File
@@ -139,6 +139,7 @@ public class Startup
Dictionary<string, Type> enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes()
.Where(type => type.IsEnum)
.Concat([
typeof(DayOfWeek),
typeof(FFmpeg.OutputFormat.OutputFormatKind),
typeof(FFmpeg.State.WatermarkLocation),
typeof(FFmpeg.State.WatermarkSize),
@@ -229,6 +230,15 @@ public class Startup
UseStringEnumSchemas(document);
return Task.CompletedTask;
});
// Contract-honesty transformers (#287): document the API surface the way it actually
// behaves at runtime — the ApiKey security scheme + per-operation security/401 (shared
// with the enforcing filter's predicate), a synthesized operationId for every action,
// and the 400 ValidationProblemDetails a binding failure returns. "v1" document only.
options.AddDocumentTransformer(ApiSecuritySchemeDocumentTransformer.TransformAsync);
options.AddOperationTransformer<ApiSecurityOperationTransformer>();
options.AddOperationTransformer<OperationIdOpenApiTransformer>();
options.AddOperationTransformer(ValidationProblemOperationTransformer.TransformAsync);
});
services.AddOpenApi(
File diff suppressed because it is too large Load Diff
+54 -21
View File
@@ -45,13 +45,23 @@ Exemplars:
the shape of the Application-layer ViewModel for that domain — **never expose a VM type directly**
from a controller. Convert framework types the SPA can't consume as-is (e.g. `CultureInfo` →
its name string).
- Most response-model files start with `#nullable enable` (verified: 60 of 72 files under
`ErsatzTV.Core/Api/`, e.g. `ErsatzTV.Core/Api/Scheduling/BlockResponseModel.cs`,
`ErsatzTV.Core/Api/Settings/*ResponseModel.cs`). A minority of the simplest ones (e.g.
`FFmpegProfileResponseModel.cs`) have no pragma and rely on the project default. Check
`ErsatzTV.Core/ErsatzTV.Core.csproj` — the project sets `<Nullable>disable</Nullable>` — so
**add `#nullable enable` at the top of any new response-model file** that has an optional
(nullable) member; don't rely on the project default.
- As of #288, **all** response-model files under `ErsatzTV.Core/Api/` carry `#nullable enable`
(including the two enum wire-mirrors `Settings/XmltvBlockBehavior.cs` / `XmltvTimeZone.cs`), so the
pragma is now universal, not "most". The project sets `<Nullable>disable</Nullable>`
(`ErsatzTV.Core/ErsatzTV.Core.csproj`), under which a non-null `string` member emits a spurious
`nullable: true` in the schema — so **add `#nullable enable` at the top of every new response-model
file** and mark a member `?` only when the mapper can actually emit null. `FFmpegProfileResponseModel.cs`
is no longer an exception.
- **Raw-VM wrapping (#288).** The last controllers returning Application ViewModels directly were
wrapped, so no `/api/*` action returns a `*ViewModel` type anymore: `CollectionController` →
`MediaCollectionResponseModel` (`Id, Name, CollectionType, UseCustomPlaybackOrder` — drops the
`MediaCardViewModel` scaffolding and the header-only `Version`), `ScheduleController` →
`ProgramScheduleResponseModel` (VM minus `Version`), `SmartCollectionController` → the existing
`SmartCollectionResponseModel`, `ResolutionController.GetResolutionByName` → `ResolutionResponseModel`,
and the **channel detail** GETs/writes → **`ChannelDetailResponseModel`** (the full editable field set
the SPA channel editor needs — distinct from the lean list `ChannelResponseModel`; drops only the
derived `webEncodedName`). When wrapping, expose exactly what the client reads: a leaner projection is
right for a list, a faithful detail projection for an editor.
- **Request DTOs**: `ErsatzTV/Controllers/Api/Requests/*Request.cs`. No `#nullable enable` pragma —
match the existing files (e.g. `CreateFFmpegProfileRequest.cs`, `ReplaceScheduleItemsRequest.cs`).
A request record typically carries a `ToCommand()` (or `ToCommand(int parentId)`,
@@ -125,8 +135,13 @@ action. Precedent for the 409 shape: `TraktController` (its private `ConflictPro
maps it) so a client polling one playout has the same flag. The SPA reads it and, on a 409,
refreshes to pick up the flag.
- **Async-op success is 202, not 200** — an endpoint whose success path only *queues* a background
rebuild returns **202 Accepted**, not 200 (#235: `POST /api/channels/{channelNumber}/playout/reset`
queues a `BuildPlayout` → `AcceptedResult`). Reserve 200 for a synchronous durable result.
rebuild returns **202 Accepted**, not 200 (#235: `POST /api/channels/{id:int}/playout/reset`
resolves the channel's playout by the immutable channel **`Id`** (`GetPlayoutIdByChannelId`), guards
on `IsPlayoutLocked` → 409, queues a `BuildPlayout` → `AcceptedResult`, and 404s when the channel has
no playout). It is keyed on `{id:int}`, **not** `{channelNumber}` — the single-item Channel admin
contract keys on `Id`, never the user-mutable `Number` (re-keyed in #197 Bundle C; see
`docs/decisions.md` 2026-07-12). The broadcast-side lookup (`GetPlayoutIdByChannelNumber`, used by
`HlsSessionWorker`) stays number-keyed — a separate contract. Reserve 200 for a synchronous durable result.
### 3b. Map a "queue a background job" outcome to status codes with an enum, not a `bool`
@@ -246,12 +261,13 @@ file is what the SPA actually imports (`web/src/api/*.ts` files do
`import type { components } from './generated/v1'`), and CI enforces it stays in sync
(`npm run check:api` = regenerate + `git diff --exit-code`).
**Known type-generation wart**: `DayOfWeek` serializes as an integer in the OpenAPI schema but the
runtime JSON payload is actually the enum's **name string** ("Sunday".."Saturday") — the generator
gets this wrong. The SPA works around it with a manual override type; see `web/src/api/playouts.ts`
(`type WithDayNames<T> = Omit<T, 'daysOfWeek'> & { daysOfWeek: DayOfWeek[] }`, applied to
`PlayoutAlternateSchedule`, `PlayoutTemplate`, and their request types). Copy this pattern for any
new DTO with a `DayOfWeek` (or `DayOfWeek[]`) member — don't trust the generated numeric type.
**Resolved wart (#287)**: `DayOfWeek` previously serialized as an integer in the OpenAPI schema while
the runtime JSON payload is the enum's **name string** ("Sunday".."Saturday"). It is now added to
`Startup.UseStringEnumSchemas`'s hand-list, so the "v1" schema emits it as a **string enum** matching
the wire. The SPA's old manual `WithDayNames<T>` override in `web/src/api/playouts.ts` has been removed
— the generated `daysOfWeek: DayOfWeek[]` type is now correct. Do **not** re-introduce an override for
new `DayOfWeek` DTOs; if you add a *new* BCL/`System` enum that serializes as a name string, add its
`typeof(...)` to that same hand-list rather than patching the SPA.
### 5a. Runtime JSON casing vs the generated spec (the `ffmpegProfileId` wart)
@@ -340,11 +356,12 @@ Diff/Scalar aggregates (Collection, Playout ×2, MultiCollection, RerunCollectio
Each replace PUT keeps its **own** existing 200 body shape (Template/DecoTemplate return a
`…WithItemsResponseModel`, Block likewise; Playlist and schedule-items return the item array) and adds the
ETag as a **header only** — except schedules: `ScheduleController` returns the Application-layer
`ProgramScheduleViewModel` *directly* (no `ProgramScheduleResponseModel` exists), so the added
`int Version` also surfaces as a redundant `version` field in the `GET /api/schedules[/{id}]` bodies. That
is intentional and harmless (the ETag remains the authority); introducing a ResponseModel purely to hide
one field was judged disproportionate. Every replace PUT's *sibling config writers* bump `Version` too
ETag as a **header only**. As of #288 this holds for schedules and collections too: `ScheduleController`
and `CollectionController` now project through dedicated `ProgramScheduleResponseModel` /
`MediaCollectionResponseModel` DTOs, so `Version` is **header-only** on those bodies — the earlier
carve-out ("`ProgramScheduleViewModel` is returned directly, so `version` leaks into the GET body") no
longer applies. Verified against the SPA: neither editor reads body `version`; both source `If-Match`
from the ETag response header (`client.ts` `requestWithMeta`). Every replace PUT's *sibling config writers* bump `Version` too
(Playlist: the five `Add*ToPlaylist` handlers; schedule: `AddProgramScheduleItem` / `DeleteProgramScheduleItem`
/ `UpdateProgramSchedule`; Template/DecoTemplate have none).
@@ -510,4 +527,20 @@ The whole `/api` surface is gated by the `X-Api-Key` header via the global `ApiK
- The SPA sends the stored key (`ctv-api-key`) on **every** request; users enter it on the keyless
**API Key** screen (`web/src/screens/ApiKeyScreen.tsx`, route `/app/api-key`). See spa-conventions §5e.
The declarative OpenAPI security scheme + global 401 documentation are Phase-2 (#286/#287), not this tier.
**The OpenAPI "v1" document now declares this posture by construction (#287).** An `ApiKey` security
scheme (`X-Api-Key`, `in: header`) is declared in `components.securitySchemes`, and
`ApiSecurityOperationTransformer` injects a per-operation `security` requirement + a documented `401`
for exactly the operations that require the key — using the **same** shared predicate
`ApiKeyAuthorizationFilter.EndpointRequiresKey(httpMethod, endpointMetadata, requireKeyForReads)` that
the runtime filter enforces, so the spec can never drift from enforcement. The document is generated
against the effective default (`Api:RequireKeyForReads=true`), under which every documented operation
requires the key. Two companion transformers run on the "v1" document only:
`OperationIdOpenApiTransformer` synthesizes a stable `operationId` (from controller+action) for the ~90
operations that lacked a `Name=` — and disambiguates the HEAD/GET pairs that share a controller+action
**structurally, independent of ApiExplorer visitation order** (#197 Bundle C): when 2+ synthesized ops
share a base id it suffixes **each** by its verb (`…Get`/`…Head`), so discovery-order churn can't rename
a generated client method (a base id unique across the document stays unsuffixed; explicit `Name=` ids are
never touched). `ValidationProblemOperationTransformer` documents the
`400 ValidationProblemDetails` a model-binding/FluentValidation failure actually returns for any
body/param-binding operation. All four are registered in `Startup.cs` on "v1" only, mirroring
`NewtonsoftSchemaNamingTransformer`. `/api/v1` route versioning remains #286.
+31
View File
@@ -1022,3 +1022,34 @@ to is gated.
Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and `/api/v1`
versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, `search/all-items`
paging, per-key rate limiting.
## 2026-07-12 (#197 Bundle C — contract-freeze honesty)
**#287 — OpenAPI contract honesty by construction.** The "v1" document now emits the `ApiKey` security
scheme plus per-operation `security`/`401` derived from the *same*
`ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime filter enforces, so declared auth
can never drift from enforced auth. Every operation also gets a synthesized stable `operationId` (the
framework only assigned one when `Name=` was set — ~90 were missing), and body/param-binding operations
get the documented `400 ValidationProblemDetails` they actually return. `DayOfWeek` is now a string enum
in the schema (added to `Startup.UseStringEnumSchemas`), removing the SPA's `WithDayNames` wart. Pinned by
in-process document generation in tests (`OpenApiContractHonestyTests`) rather than the committed `v1.json`.
**#288 — Wrap the last raw ViewModels; reverse the §7a "intentional `version` leak."** Minted
`MediaCollectionResponseModel`, `ProgramScheduleResponseModel`, and `ChannelDetailResponseModel` (all
`#nullable enable`) and routed `CollectionController` / `ScheduleController` / `SmartCollectionController` /
`ResolutionController.GetResolutionByName` / the channel detail GET+writes through ResponseModels, so no
`/api/*` action returns an Application VM. This reverses the earlier §7a judgment that a ResponseModel
"purely to hide one field was disproportionate": `Version` is now header-only (ETag) on every aggregate
body — confirmed safe by grepping `web/src` (the SPA reads `version` from the ETag header, never the
response body). `ChannelDetailResponseModel` is the *full editable* field set the channel editor needs
(distinct from the lean list `ChannelResponseModel`; drops only the derived `webEncodedName`). Also flipped
`#nullable enable` onto the remaining 24 lagging `ErsatzTV.Core/Api/` files for schema honesty, and added
`pageNum` paging to `GET /api/search`.
**Channel REST resources are keyed by database `Id`, never by `Number`.** `Channel.Number` is user-mutable
(editable on update, bulk-renumbered via `/api/channels/bulk/renumber`, transiently invalid mid-renumber),
so the immutable int PK is the canonical key for all `/api/channels/*` single-item routes, sub-resources
(including `playout/reset`, re-keyed from `{channelNumber}` to `{id:int}` in Bundle C), and `Location`
headers. `Number` remains the identity on broadcast surfaces only (IPTV/M3U/XMLTV), a separate contract. A
number-based lookup endpoint may be added additively later; `UniqueId` (Guid) stays out of the REST
contract absent a federation requirement.
+94 -99
View File
@@ -20,23 +20,23 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/blocks` | | Get all blocks |
| POST | `/api/blocks` | | Create a block |
| GET | `/api/blocks` | BlockGetAll | Get all blocks |
| POST | `/api/blocks` | BlockCreate | Create a block |
| GET | `/api/blocks/groups` | GetBlockGroups | Get all block groups |
| POST | `/api/blocks/groups` | | Create a block group |
| DELETE | `/api/blocks/groups/{id}` | | Delete a block group |
| DELETE | `/api/blocks/{id}` | | Delete a block |
| POST | `/api/blocks/groups` | BlockCreateGroup | Create a block group |
| DELETE | `/api/blocks/groups/{id}` | BlockDeleteGroup | Delete a block group |
| DELETE | `/api/blocks/{id}` | BlockDelete | Delete a block |
| GET | `/api/blocks/{id}` | GetBlockById | Get a block by id |
| PUT | `/api/blocks/{id}` | | Replace a block and its items |
| POST | `/api/blocks/{id}/copy` | | Copy a block |
| GET | `/api/blocks/{id}/items` | | Get block items |
| POST | `/api/blocks/{id}/preview` | | Preview a block playout |
| PUT | `/api/blocks/{id}` | BlockReplace | Replace a block and its items |
| POST | `/api/blocks/{id}/copy` | BlockCopy | Copy a block |
| GET | `/api/blocks/{id}/items` | BlockGetItems | Get block items |
| POST | `/api/blocks/{id}/preview` | BlockPreview | Preview a block playout |
## Channel
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/channels` | | |
| GET | `/api/channels` | ChannelGetAll | |
## Channel Templates
@@ -54,60 +54,60 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| POST | `/api/channels` | | Create a channel |
| POST | `/api/channels/bulk/delete` | | Delete channels |
| POST | `/api/channels/bulk/group` | | Move channels to a group |
| POST | `/api/channels/bulk/renumber` | | Renumber channels |
| POST | `/api/channels` | ChannelCreate | Create a channel |
| POST | `/api/channels/bulk/delete` | ChannelBulkDelete | Delete channels |
| POST | `/api/channels/bulk/group` | ChannelBulkMoveToGroup | Move channels to a group |
| POST | `/api/channels/bulk/renumber` | ChannelBulkRenumber | 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/state` | ChannelGetState | 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 |
| DELETE | `/api/channels/{id}` | ChannelDelete | Delete a channel |
| GET | `/api/channels/{id}` | GetChannelById | Get a channel by id |
| PUT | `/api/channels/{id}` | | Update a channel |
| GET | `/api/guide` | | Get the JSON channel guide (EPG) |
| PUT | `/api/channels/{id}` | ChannelUpdate | Update a channel |
| POST | `/api/channels/{id}/playout/reset` | ChannelResetPlayout | Reset channel playout |
| GET | `/api/guide` | ChannelGetGuide | Get the JSON channel guide (EPG) |
## Collections
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/collections` | | Get all collections |
| POST | `/api/collections` | | Create a collection |
| DELETE | `/api/collections/{id}` | | Delete a collection |
| GET | `/api/collections` | CollectionGetAll | Get all collections |
| POST | `/api/collections` | CollectionCreate | Create a collection |
| DELETE | `/api/collections/{id}` | CollectionDelete | Delete a collection |
| GET | `/api/collections/{id}` | GetCollectionById | Get a collection by id |
| PUT | `/api/collections/{id}` | | Update a collection |
| PUT | `/api/collections/{id}/custom-order` | | Set a collection's custom playback order |
| PUT | `/api/collections/{id}` | CollectionUpdate | Update a collection |
| PUT | `/api/collections/{id}/custom-order` | CollectionUpdateCustomOrder | Set a collection's custom playback order |
| GET | `/api/collections/{id}/items` | GetCollectionItems | Get the items in a manual collection |
| POST | `/api/collections/{id}/items` | | Add items to a collection |
| DELETE | `/api/collections/{id}/items/{mediaItemId}` | | Remove an item from a collection |
| POST | `/api/collections/{id}/items` | CollectionAddItems | Add items to a collection |
| DELETE | `/api/collections/{id}/items/{mediaItemId}` | CollectionRemoveItem | Remove an item from a collection |
## DecoTemplates
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/deco-templates` | | Get all deco templates |
| POST | `/api/deco-templates` | | Create a deco template |
| GET | `/api/deco-templates` | DecoTemplateGetAll | Get all deco templates |
| POST | `/api/deco-templates` | DecoTemplateCreate | Create a deco template |
| GET | `/api/deco-templates/groups` | GetDecoTemplateGroups | Get all deco template groups |
| POST | `/api/deco-templates/groups` | | Create a deco template group |
| DELETE | `/api/deco-templates/groups/{id}` | | Delete a deco template group |
| DELETE | `/api/deco-templates/{id}` | | Delete a deco template |
| POST | `/api/deco-templates/groups` | DecoTemplateCreateGroup | Create a deco template group |
| DELETE | `/api/deco-templates/groups/{id}` | DecoTemplateDeleteGroup | Delete a deco template group |
| DELETE | `/api/deco-templates/{id}` | DecoTemplateDelete | Delete a deco template |
| GET | `/api/deco-templates/{id}` | GetDecoTemplateById | Get a deco template by id |
| PUT | `/api/deco-templates/{id}` | | Replace a deco template and its items |
| GET | `/api/deco-templates/{id}/items` | | Get deco template items |
| PUT | `/api/deco-templates/{id}` | DecoTemplateReplace | Replace a deco template and its items |
| GET | `/api/deco-templates/{id}/items` | DecoTemplateGetItems | Get deco template items |
## Decos
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/decos` | | Get all decos |
| POST | `/api/decos` | | Create a deco |
| GET | `/api/decos` | DecoGetAll | Get all decos |
| POST | `/api/decos` | DecoCreate | Create a deco |
| GET | `/api/decos/groups` | GetDecoGroups | Get all deco groups |
| POST | `/api/decos/groups` | | Create a deco group |
| DELETE | `/api/decos/groups/{id}` | | Delete a deco group |
| DELETE | `/api/decos/{id}` | | Delete a deco |
| POST | `/api/decos/groups` | DecoCreateGroup | Create a deco group |
| DELETE | `/api/decos/groups/{id}` | DecoDeleteGroup | Delete a deco group |
| DELETE | `/api/decos/{id}` | DecoDelete | Delete a deco |
| GET | `/api/decos/{id}` | GetDecoById | Get a deco by id |
| PUT | `/api/decos/{id}` | | Replace a deco |
| PUT | `/api/decos/{id}` | DecoReplace | Replace a deco |
## Emby
@@ -190,15 +190,15 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/libraries/local` | GetLocalLibraries | Get all local libraries |
| POST | `/api/libraries/local` | | Create a local library |
| POST | `/api/libraries/local/path-exists` | | Check whether a filesystem path exists |
| POST | `/api/libraries/local/paths/{pathId}/move` | | Move a local library path to another local library |
| DELETE | `/api/libraries/local/{id}` | | Delete a local library |
| POST | `/api/libraries/local` | LocalLibrariesCreate | Create a local library |
| POST | `/api/libraries/local/path-exists` | LocalLibrariesCheckPathExists | Check whether a filesystem path exists |
| POST | `/api/libraries/local/paths/{pathId}/move` | LocalLibrariesMovePath | Move a local library path to another local library |
| DELETE | `/api/libraries/local/{id}` | LocalLibrariesDelete | Delete a local library |
| GET | `/api/libraries/local/{id}` | GetLocalLibrary | Get a local library by id |
| PUT | `/api/libraries/local/{id}` | | Update a local library |
| PUT | `/api/libraries/local/{id}` | LocalLibrariesUpdate | Update a local library |
| GET | `/api/libraries/scan-status` | GetLibraryScanStatus | Get active library scan status |
| POST | `/api/libraries/{id}/scan` | | Scan library |
| POST | `/api/libraries/{id}/scan-show` | | Scan show |
| POST | `/api/libraries/{id}/scan` | LibrariesScanLibrary | Scan library |
| POST | `/api/libraries/{id}/scan-show` | LibrariesScanShow | Scan show |
| GET | `/api/library/browse` | BrowseLibrary | Browse and search library items |
## Logs
@@ -211,9 +211,9 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| POST | `/api/maintenance/clean_artwork` | | Clean artwork cache |
| POST | `/api/maintenance/empty_trash` | | Empty trash |
| POST | `/api/maintenance/gc` | | Garbage collect |
| POST | `/api/maintenance/clean_artwork` | MaintenanceCleanArtwork | Clean artwork cache |
| POST | `/api/maintenance/empty_trash` | MaintenanceEmptyTrash | Empty trash |
| POST | `/api/maintenance/gc` | MaintenanceGarbageCollection | Garbage collect |
## Media Items
@@ -239,10 +239,10 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/multi-collections` | GetMultiCollections | Get all multi collections (paged) |
| POST | `/api/multi-collections` | | Create a multi collection |
| DELETE | `/api/multi-collections/{id}` | | Delete a multi collection |
| POST | `/api/multi-collections` | MultiCollectionCreate | Create a multi collection |
| DELETE | `/api/multi-collections/{id}` | MultiCollectionDelete | Delete a multi collection |
| GET | `/api/multi-collections/{id}` | GetMultiCollectionById | Get a multi collection by id |
| PUT | `/api/multi-collections/{id}` | | Update a multi collection |
| PUT | `/api/multi-collections/{id}` | MultiCollectionUpdate | Update a multi collection |
## Playlists
@@ -266,24 +266,24 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/playouts` | GetPlayouts | List playouts |
| POST | `/api/playouts` | | Create a playout |
| POST | `/api/playouts` | PlayoutCreate | Create a playout |
| GET | `/api/playouts/history/{id}` | GetPlayoutHistoryDetails | Decode a playout history row |
| GET | `/api/playouts/items/{id}/scheduling-context` | GetPlayoutItemSchedulingContext | Decode a playout item's scheduling context |
| POST | `/api/playouts/reset-all` | ResetAllPlayouts | Reset all playouts |
| GET | `/api/playouts/warnings/count` | GetPlayoutWarningsCount | Count playouts with a failed build |
| DELETE | `/api/playouts/{id}` | | Delete a playout |
| DELETE | `/api/playouts/{id}` | PlayoutDelete | Delete a playout |
| GET | `/api/playouts/{id}` | GetPlayoutById | Get a playout by id |
| PUT | `/api/playouts/{id}` | | Update playout scheduling details |
| PUT | `/api/playouts/{id}` | PlayoutUpdate | Update playout scheduling details |
| GET | `/api/playouts/{id}/alternate-schedules` | GetPlayoutAlternateSchedules | Get a classic playout's alternate schedules |
| PUT | `/api/playouts/{id}/alternate-schedules` | | Replace a classic playout's alternate schedules |
| PUT | `/api/playouts/{id}/alternate-schedules` | PlayoutReplaceAlternateSchedules | Replace a classic playout's alternate schedules |
| GET | `/api/playouts/{id}/blocks` | GetPlayoutBlocks | Get the blocks scheduled by a block playout |
| GET | `/api/playouts/{id}/blocks/{blockId}/history` | GetPlayoutBlockHistory | Get a block's playout history |
| PUT | `/api/playouts/{id}/deco` | | Set (or clear) a playout's default deco |
| PUT | `/api/playouts/{id}/deco` | PlayoutUpdateDefaultDeco | Set (or clear) a playout's default deco |
| POST | `/api/playouts/{id}/erase-items` | ErasePlayoutItems | Erase a playout's items |
| POST | `/api/playouts/{id}/erase-items-and-history` | ErasePlayoutItemsAndHistory | Erase a playout's items and history |
| GET | `/api/playouts/{id}/items` | GetPlayoutItems | Get upcoming items (and unscheduled gaps) for a playout |
| GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates |
| PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates |
| PUT | `/api/playouts/{id}/templates` | PlayoutReplaceTemplates | Replace a block playout's templates |
## Plex
@@ -304,30 +304,24 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/rerun-collections` | GetRerunCollections | Get all rerun collections (paged) |
| POST | `/api/rerun-collections` | | Create a rerun collection |
| DELETE | `/api/rerun-collections/{id}` | | Delete a rerun collection |
| POST | `/api/rerun-collections` | RerunCollectionCreate | Create a rerun collection |
| DELETE | `/api/rerun-collections/{id}` | RerunCollectionDelete | Delete a rerun collection |
| GET | `/api/rerun-collections/{id}` | GetRerunCollectionById | Get a rerun collection by id |
| PUT | `/api/rerun-collections/{id}` | | Update a rerun collection |
## Resolution
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/ffmpeg/resolution/by-name/{name}` | GetResolutionByName | |
| PUT | `/api/rerun-collections/{id}` | RerunCollectionUpdate | Update a rerun collection |
## Schedules
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/schedules` | | Get all schedules |
| POST | `/api/schedules` | | Create a schedule |
| DELETE | `/api/schedules/{id}` | | Delete a schedule |
| GET | `/api/schedules` | ScheduleGetAll | Get all schedules |
| POST | `/api/schedules` | ScheduleCreate | Create a schedule |
| DELETE | `/api/schedules/{id}` | ScheduleDelete | Delete a schedule |
| GET | `/api/schedules/{id}` | GetScheduleById | Get a schedule by id |
| PUT | `/api/schedules/{id}` | | Update a schedule |
| GET | `/api/schedules/{id}/items` | | Get schedule items |
| POST | `/api/schedules/{id}/items` | | Add a schedule item |
| PUT | `/api/schedules/{id}/items` | | Replace schedule items |
| DELETE | `/api/schedules/{id}/items/{itemId}` | | Delete a schedule item |
| PUT | `/api/schedules/{id}` | ScheduleUpdate | Update a schedule |
| GET | `/api/schedules/{id}/items` | ScheduleGetItems | Get schedule items |
| POST | `/api/schedules/{id}/items` | ScheduleAddItem | Add a schedule item |
| PUT | `/api/schedules/{id}/items` | ScheduleReplaceItems | Replace schedule items |
| DELETE | `/api/schedules/{id}/items/{itemId}` | ScheduleDeleteItem | Delete a schedule item |
## Search
@@ -346,13 +340,14 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| DELETE | `/api/session/{channelNumber}` | | Stop session |
| GET | `/api/sessions` | | Get sessions |
| DELETE | `/api/session/{channelNumber}` | SessionStopSession | Stop session |
| GET | `/api/sessions` | SessionGetSessions | Get sessions |
## Settings
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/ffmpeg/resolution/by-name/{name}` | GetResolutionByName | Get a resolution by name |
| GET | `/api/settings/ffmpeg` | GetFfmpegSettings | Get global FFmpeg settings |
| PUT | `/api/settings/ffmpeg` | UpdateFfmpegSettings | Update global FFmpeg settings |
| GET | `/api/settings/hdhr` | GetHdhrSettings | Get HDHomeRun emulation settings |
@@ -375,11 +370,11 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/smart-collections` | | Get all smart collections |
| POST | `/api/smart-collections` | | Create a smart collection |
| DELETE | `/api/smart-collections/{id}` | | Delete a smart collection |
| GET | `/api/smart-collections` | SmartCollectionGetAll | Get all smart collections |
| POST | `/api/smart-collections` | SmartCollectionCreate | Create a smart collection |
| DELETE | `/api/smart-collections/{id}` | SmartCollectionDelete | Delete a smart collection |
| GET | `/api/smart-collections/{id}` | GetSmartCollectionById | Get a smart collection by id |
| PUT | `/api/smart-collections/{id}` | | Update a smart collection |
| PUT | `/api/smart-collections/{id}` | SmartCollectionUpdate | Update a smart collection |
## Television
@@ -392,27 +387,27 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/templates` | | Get all templates |
| POST | `/api/templates` | | Create a template |
| GET | `/api/templates` | TemplateGetAll | Get all templates |
| POST | `/api/templates` | TemplateCreate | Create a template |
| GET | `/api/templates/groups` | GetTemplateGroups | Get all template groups |
| POST | `/api/templates/groups` | | Create a template group |
| DELETE | `/api/templates/groups/{id}` | | Delete a template group |
| DELETE | `/api/templates/{id}` | | Delete a template |
| POST | `/api/templates/groups` | TemplateCreateGroup | Create a template group |
| DELETE | `/api/templates/groups/{id}` | TemplateDeleteGroup | Delete a template group |
| DELETE | `/api/templates/{id}` | TemplateDelete | Delete a template |
| GET | `/api/templates/{id}` | GetTemplateById | Get a template by id |
| PUT | `/api/templates/{id}` | | Replace a template and its items |
| POST | `/api/templates/{id}/copy` | | Copy a template |
| GET | `/api/templates/{id}/items` | | Get template items |
| PUT | `/api/templates/{id}` | TemplateReplace | Replace a template and its items |
| POST | `/api/templates/{id}/copy` | TemplateCopy | Copy a template |
| GET | `/api/templates/{id}/items` | TemplateGetItems | Get template items |
## Trakt
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/trakt/lists` | GetTraktLists | Get paged Trakt lists |
| POST | `/api/trakt/lists` | | Add a Trakt list by URL |
| DELETE | `/api/trakt/lists/{id}` | | Delete a Trakt list |
| POST | `/api/trakt/lists` | TraktAdd | Add a Trakt list by URL |
| DELETE | `/api/trakt/lists/{id}` | TraktDelete | Delete a Trakt list |
| GET | `/api/trakt/lists/{id}` | GetTraktListById | Get a Trakt list by id |
| PUT | `/api/trakt/lists/{id}` | | Update a Trakt list's settings |
| POST | `/api/trakt/lists/{id}/match` | | Match a Trakt list's items |
| PUT | `/api/trakt/lists/{id}` | TraktUpdate | Update a Trakt list's settings |
| POST | `/api/trakt/lists/{id}/match` | TraktMatch | Match a Trakt list's items |
| GET | `/api/trakt/status` | GetTraktStatus | Get Trakt background operation status |
## Troubleshooting
@@ -420,12 +415,12 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/troubleshoot/info` | GetTroubleshootingInfo | Get troubleshooting diagnostic info |
| GET | `/api/troubleshoot/playback.m3u8` | | Start a troubleshooting playback session |
| HEAD | `/api/troubleshoot/playback.m3u8` | | Start a troubleshooting playback session |
| GET | `/api/troubleshoot/playback/archive` | | Download the last troubleshooting playback session archive |
| HEAD | `/api/troubleshoot/playback/archive` | | Download the last troubleshooting playback session archive |
| GET | `/api/troubleshoot/playback/sample/{mediaItemId}` | | Download a media sample archive for troubleshooting |
| HEAD | `/api/troubleshoot/playback/sample/{mediaItemId}` | | Download a media sample archive for troubleshooting |
| GET | `/api/troubleshoot/playback.m3u8` | TroubleshootTroubleshootPlaybackGet | Start a troubleshooting playback session |
| HEAD | `/api/troubleshoot/playback.m3u8` | TroubleshootTroubleshootPlaybackHead | Start a troubleshooting playback session |
| GET | `/api/troubleshoot/playback/archive` | TroubleshootTroubleshootPlaybackArchiveGet | Download the last troubleshooting playback session archive |
| HEAD | `/api/troubleshoot/playback/archive` | TroubleshootTroubleshootPlaybackArchiveHead | Download the last troubleshooting playback session archive |
| GET | `/api/troubleshoot/playback/sample/{mediaItemId}` | TroubleshootTroubleshootPlaybackSampleGet | Download a media sample archive for troubleshooting |
| HEAD | `/api/troubleshoot/playback/sample/{mediaItemId}` | TroubleshootTroubleshootPlaybackSampleHead | Download a media sample archive for troubleshooting |
| GET | `/api/troubleshoot/playback/status` | GetTroubleshootingPlaybackStatus | Get the status of the current or last troubleshooting playback session |
| GET | `/api/troubleshoot/playback/stream-selectors` | GetTroubleshootingStreamSelectors | List available channel stream selectors |
| GET | `/api/troubleshoot/playback/subtitles/{mediaItemId}` | GetTroubleshootingSubtitles | List selectable subtitle streams for a media item |
+4 -2
View File
@@ -73,9 +73,11 @@ DELETE /api/schedules/{id}/items/{itemId}
POST /api/playouts GET /api/playouts GET /api/playouts/{id}
DELETE /api/playouts/{id}
POST /api/channels/{number}/playout/reset # exists; keep
POST /api/channels/{id}/playout/reset # re-keyed {number} → {id} (#197 Bundle C)
```
`{id}` = numeric entity id. Channel reset keeps its existing `{number}` route (already public).
`{id}` = numeric entity id. Channel reset is keyed on `{id}` like every other single-item admin route
(re-keyed from `{number}` in #197 Bundle C — `Number` is user-mutable, so it's unusable as a stable
resource key; broadcast surfaces keep `{number}`. See `docs/decisions.md` 2026-07-12).
### 3.2 Status codes
| Outcome | Code |
+3 -1
View File
@@ -998,6 +998,7 @@ describe('ChicoryTV SPA scaffold', () => {
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
channelStates: [{ channelId: 7, channelNumber: '5.1', onAir: true, nowPlaying: null }],
playouts: { page: [listPlayout({ id: 20, channelNumber: '5.1' })], totalCount: 1 }
});
@@ -1009,8 +1010,9 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
await waitFor(() => {
// Keyed on the resolved channel id (7), not the playout id (20) or the channel number.
expect(window.fetch).toHaveBeenCalledWith(
'/api/channels/5.1/playout/reset',
'/api/channels/7/playout/reset',
expect.objectContaining({ method: 'POST' })
);
});
+10 -1
View File
@@ -1829,8 +1829,17 @@ function PlayoutsScreen() {
if (!selectedSummary) {
return;
}
// The reset endpoint keys on the immutable channel id. The playout summary only carries the
// channel number, so resolve the id from channel state (which covers every channel).
const channelId = channelStates.find(
(state) => state.channelNumber === selectedSummary.channelNumber
)?.channelId;
if (channelId == null) {
setMutationError('Unable to resolve the channel for this playout.');
return;
}
runMutation(`Reset the playout for ${selectedSummary.channelName}?`, () =>
resetChannelPlayout(selectedSummary.channelNumber)
resetChannelPlayout(channelId)
);
};
+1 -1
View File
@@ -4,7 +4,7 @@ import type { components } from './generated/v1';
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type Channel = components['schemas']['ChannelViewModel'];
export type Channel = components['schemas']['ChannelDetailResponseModel'];
export type UpdateChannelRequest = components['schemas']['UpdateChannelRequest'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
+2 -2
View File
@@ -2,8 +2,8 @@ import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './cli
import type { components } from './generated/v1';
import type { LibraryBrowseItem, PagedLibraryBrowseItems } from './libraryBrowse';
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type MediaCollection = components['schemas']['MediaCollectionResponseModel'];
export type SmartCollection = components['schemas']['SmartCollectionResponseModel'];
export type CreateCollectionRequest = components['schemas']['CreateCollectionRequest'];
export type UpdateCollectionRequest = components['schemas']['UpdateCollectionRequest'];
export type AddItemsToCollectionRequest = components['schemas']['AddItemsToCollectionRequest'];
+82 -87
View File
@@ -134,6 +134,37 @@ export interface components {
};
"BulkRenumberChannelsRequest": {
"channels": Array<components["schemas"]["BulkRenumberChannelRequest"]>;
};
"ChannelDetailResponseModel": {
"id": number;
"number": string;
"name": string;
"group": string;
"categories": string;
"ffmpegProfileId": number;
"slugSeconds": null | number;
"logo": components["schemas"]["ChannelLogoResponseModel"];
"streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"];
"streamSelector": string;
"preferredAudioLanguageCode": string;
"preferredAudioTitle": string;
"playoutSource": components["schemas"]["ChannelPlayoutSource"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"mirrorSourceChannelId": null | number;
"playoutOffset": null | string;
"streamingMode": components["schemas"]["StreamingMode"];
"watermarkId": null | number;
"fallbackFillerId": null | number;
"playoutCount": number;
"preferredSubtitleLanguageCode": string;
"subtitleMode": components["schemas"]["ChannelSubtitleMode"];
"musicVideoCreditsMode": components["schemas"]["ChannelMusicVideoCreditsMode"];
"musicVideoCreditsTemplate": string;
"songVideoMode": components["schemas"]["ChannelSongVideoMode"];
"transcodeMode": components["schemas"]["ChannelTranscodeMode"];
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"isEnabled": boolean;
"showInEpg": boolean;
};
"ChannelGuideChannelResponseModel": {
"number": string;
@@ -154,6 +185,13 @@ export interface components {
"channels": Array<components["schemas"]["ChannelGuideChannelResponseModel"]>;
};
"ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning";
"ChannelLogoResponseModel": {
"path": string;
"contentType": string;
"isExternalUrl"?: boolean;
"hasContentType"?: boolean;
"urlWithContentType"?: null | string;
};
"ChannelMusicVideoCreditsMode": "None" | "GenerateSubtitles";
"ChannelNowPlayingResponseModel": {
"title": string;
@@ -215,38 +253,6 @@ export interface components {
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
};
"ChannelTranscodeMode": "OnDemand";
"ChannelViewModel": {
"id": number;
"number": null | string;
"name": null | string;
"group": null | string;
"categories": null | string;
"ffmpegProfileId": number;
"slugSeconds": null | number;
"logo": components["schemas"]["ArtworkContentTypeModel"];
"streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"];
"streamSelector": null | string;
"preferredAudioLanguageCode": null | string;
"preferredAudioTitle": null | string;
"playoutSource": components["schemas"]["ChannelPlayoutSource"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"mirrorSourceChannelId": null | number;
"playoutOffset": null | string;
"streamingMode": components["schemas"]["StreamingMode"];
"watermarkId": null | number;
"fallbackFillerId": null | number;
"playoutCount": number;
"preferredSubtitleLanguageCode": null | string;
"subtitleMode": components["schemas"]["ChannelSubtitleMode"];
"musicVideoCreditsMode": components["schemas"]["ChannelMusicVideoCreditsMode"];
"musicVideoCreditsTemplate": null | string;
"songVideoMode": components["schemas"]["ChannelSongVideoMode"];
"transcodeMode": components["schemas"]["ChannelTranscodeMode"];
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"isEnabled": boolean;
"showInEpg": boolean;
"webEncodedName"?: null | string;
};
"ChannelWatermarkImageSource": "Custom" | "ChannelLogo" | "Resource";
"ChannelWatermarkMode": "None" | "Permanent" | "Intermittent" | "OpacityExpression";
"CollectionType": "Collection" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "MultiCollection" | "SmartCollection" | "Playlist" | "RerunFirstRun" | "RerunRerun" | "SearchQuery" | "Movie" | "Episode" | "MusicVideo" | "OtherVideo" | "Song" | "Image" | "RemoteStream" | "FakeCollection" | "FakePlaylistItem";
@@ -515,7 +521,7 @@ export interface components {
"zIndex": number;
"placeWithinSourceContent": boolean;
};
"DayOfWeek": number;
"DayOfWeek": "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday";
"DecoBreakContentRequest": {
"id": number;
"collectionType": components["schemas"]["CollectionType"];
@@ -689,7 +695,7 @@ export interface components {
};
"FillerPresetResponseModel": {
"id": number;
"name": null | string;
"name": string;
"fillerKind": components["schemas"]["FillerKind"];
};
"FillWithGroupMode": "None" | "FillWithOrderedGroups" | "FillWithShuffledGroups";
@@ -697,7 +703,7 @@ export interface components {
"FixedStartTimeBehavior": "Strict" | "Flexible";
"GraphicsElementResponseModel": {
"id": number;
"name": null | string;
"name": string;
};
"GuideMode": "Normal" | "Filler";
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
@@ -727,8 +733,8 @@ export interface components {
"durationSeconds": null | number;
};
"LanguageCodeResponseModel": {
"code": null | string;
"englishName": null | string;
"code": string;
"englishName": string;
};
"LibraryBrowseItemResponseModel": {
"id": number;
@@ -758,20 +764,20 @@ export interface components {
};
"LocalLibraryDetailResponseModel": {
"id": number;
"name": null | string;
"name": string;
"mediaKind": components["schemas"]["LibraryMediaKind"];
"isLocked": boolean;
"mediaItemCount": number;
"paths": null | Array<components["schemas"]["LocalLibraryPathResponseModel"]>;
"paths": Array<components["schemas"]["LocalLibraryPathResponseModel"]>;
};
"LocalLibraryPathResponseModel": {
"id": number;
"path": null | string;
"path": string;
"mediaItemCount": number;
};
"LocalLibraryResponseModel": {
"id": number;
"name": null | string;
"name": string;
"mediaKind": components["schemas"]["LibraryMediaKind"];
"isLocked": boolean;
};
@@ -796,19 +802,11 @@ export interface components {
"httpMinimumLogLevel": components["schemas"]["LogEventLevel"];
};
"MarathonGroupBy": "None" | "Show" | "Season" | "Artist" | "Album" | "Director";
"MediaCollectionViewModel": {
"collectionType": components["schemas"]["CollectionType"];
"MediaCollectionResponseModel": {
"id": number;
"name": null | string;
"name": string;
"collectionType": components["schemas"]["CollectionType"];
"useCustomPlaybackOrder": boolean;
"version"?: number;
"mediaItemId"?: number;
"title"?: null | string;
"subtitle"?: null | string;
"sortTitle"?: null | string;
"poster"?: null | string;
"state": components["schemas"]["MediaItemState"];
"hasMediaInfo"?: boolean;
};
"MediaItemInfoChapterResponseModel": {
"title": null | string;
@@ -915,7 +913,7 @@ export interface components {
"MultipleMode": "Count" | "CollectionSize" | "PlaylistItemSize" | "MultiEpisodeGroupSize";
"NamedIdResponseModel": {
"id": number;
"name": null | string;
"name": string;
};
"NormalizeLoudnessMode": "Off" | "LoudNorm";
"OutputFormatKind": "None" | "Mkv" | "MpegTs" | "Mp4" | "Hls" | "HlsMp4" | "Nut";
@@ -937,11 +935,11 @@ export interface components {
};
"PagedPlayoutItemsResponseModel": {
"totalCount": number;
"page": null | Array<components["schemas"]["PlayoutItemResponseModel"]>;
"page": Array<components["schemas"]["PlayoutItemResponseModel"]>;
};
"PagedPlayoutsResponseModel": {
"totalCount": number;
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
"page": Array<components["schemas"]["PlayoutListItemResponseModel"]>;
};
"PagedRerunCollectionsResponseModel": {
"totalCount": number;
@@ -1042,7 +1040,7 @@ export interface components {
"PlayoutBuildStatusResponseModel": {
"lastBuild": string;
"success": boolean;
"message": null | string;
"message": string;
};
"PlayoutHistoryDetailsResponseModel": {
"playbackOrder": components["schemas"]["PlaybackOrder"];
@@ -1138,7 +1136,7 @@ export interface components {
"endYear": null | number;
};
"PlexPinFlowResponseModel": {
"authUrl": null | string;
"authUrl": string;
};
"ProblemDetails": {
"type"?: null | string;
@@ -1147,18 +1145,17 @@ export interface components {
"detail"?: null | string;
"instance"?: null | string;
};
"ProgramScheduleViewModel": {
"ProgramScheduleResponseModel": {
"id": number;
"name": null | string;
"name": string;
"keepMultiPartEpisodesTogether": boolean;
"treatCollectionsAsShows": boolean;
"shuffleScheduleItems": boolean;
"randomStartPoint": boolean;
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
"version": number;
};
"RemoteConnectionResponseModel": {
"address": null | string;
"address": string;
"hasApiKey": boolean;
};
"RemoteLibraryPreferenceRequest": {
@@ -1179,7 +1176,7 @@ export interface components {
"RemoteMediaSourceStateResponseModel": {
"isAuthorized": boolean;
"isLocked": boolean;
"servers": null | Array<components["schemas"]["RemoteMediaSourceItemResponseModel"]>;
"servers": Array<components["schemas"]["RemoteMediaSourceItemResponseModel"]>;
};
"ReplaceBlockRequest": {
"name": null | string;
@@ -1258,13 +1255,6 @@ export interface components {
"width": number;
"height": number;
"isCustom": boolean;
};
"ResolutionViewModel": {
"id": number;
"name": null | string;
"width": number;
"height": number;
"isCustom": boolean;
};
"SaveRemoteConnectionRequest": {
"address": null | string;
@@ -1384,16 +1374,16 @@ export interface components {
"name": string;
};
"SearchResultAllItemsResponseModel": {
"movieIds": null | Array<number>;
"showIds": null | Array<number>;
"seasonIds": null | Array<number>;
"episodeIds": null | Array<number>;
"artistIds": null | Array<number>;
"musicVideoIds": null | Array<number>;
"otherVideoIds": null | Array<number>;
"songIds": null | Array<number>;
"imageIds": null | Array<number>;
"remoteStreamIds": null | Array<number>;
"movieIds": Array<number>;
"showIds": Array<number>;
"seasonIds": Array<number>;
"episodeIds": Array<number>;
"artistIds": Array<number>;
"musicVideoIds": Array<number>;
"otherVideoIds": Array<number>;
"songIds": Array<number>;
"imageIds": Array<number>;
"remoteStreamIds": Array<number>;
};
"SearchResultGroupResponseModel": {
"totalCount": number;
@@ -1439,13 +1429,8 @@ export interface components {
};
"SmartCollectionResponseModel": {
"id": number;
"name": null | string;
"query": null | string;
};
"SmartCollectionViewModel": {
"id": number;
"name": null | string;
"query": null | string;
"name": string;
"query": string;
};
"StartType": "Dynamic" | "Fixed";
"StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid";
@@ -1749,6 +1734,16 @@ export interface components {
"isValid": boolean;
"messages": Array<string>;
"json": string;
};
"ValidationProblemDetails": {
"type"?: null | string;
"title"?: null | string;
"status"?: null | number;
"detail"?: null | string;
"instance"?: null | string;
"errors"?: {
[key: string]: Array<string>;
};
};
"VideoScanKind": "Unknown" | "Progressive" | "Interlaced";
"WatermarkFullResponseModel": {
@@ -1773,7 +1768,7 @@ export interface components {
"WatermarkLocation": "BottomRight" | "BottomLeft" | "TopRight" | "TopLeft" | "TopMiddle" | "RightMiddle" | "BottomMiddle" | "LeftMiddle" | "MiddleCenter";
"WatermarkResponseModel": {
"id": number;
"name": null | string;
"name": string;
};
"WatermarkSize": "Scaled" | "ActualSize";
"XmltvBlockBehavior": "SplitTimeEvenly" | "UseActualTimes";
+3 -12
View File
@@ -115,25 +115,16 @@ describe('playouts api client', () => {
expect(init).toMatchObject({ method: 'DELETE' });
});
it('resetChannelPlayout POSTs to the channel playout reset route without a mode', async () => {
it('resetChannelPlayout POSTs to the id-keyed channel playout reset route without a mode', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('12.3');
await resetChannelPlayout(20);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/12.3/playout/reset');
expect(url).toBe('/api/channels/20/playout/reset');
expect(init).toMatchObject({ method: 'POST' });
});
it('resetChannelPlayout url-encodes the channel number', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('a b');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/a%20b/playout/reset');
});
it('erasePlayoutItems POSTs to the erase-items route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
+12 -14
View File
@@ -14,10 +14,11 @@ export type PlayoutItemSchedulingContext = components['schemas']['PlayoutItemSch
export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest'];
export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest'];
// The OpenAPI generator types DayOfWeek as `number` (the OpenAPI schema is derived from
// System.Text.Json metadata), but the MVC pipeline serializes with Newtonsoft + StringEnumConverter,
// so DayOfWeek is a day-name string on the wire ("Sunday" … "Saturday"). We override the generated
// `daysOfWeek` shape accordingly on both request and response DTOs.
// DayOfWeek is a day-name string on the wire ("Sunday" … "Saturday"). As of #287 the OpenAPI
// schema emits it as a string enum (added to Startup.UseStringEnumSchemas), so the generated
// `daysOfWeek` shape is already correct and no override is needed — the DTO aliases below point
// straight at the generated schemas. This local union is kept for the DAYS_OF_WEEK ordering
// constant and equals components['schemas']['DayOfWeek'].
export type DayOfWeek =
| 'Sunday'
| 'Monday'
@@ -38,14 +39,10 @@ export const DAYS_OF_WEEK: DayOfWeek[] = [
'Sunday'
];
type WithDayNames<T> = Omit<T, 'daysOfWeek'> & { daysOfWeek: DayOfWeek[] };
export type PlayoutAlternateSchedule = WithDayNames<components['schemas']['PlayoutAlternateScheduleResponseModel']>;
export type PlayoutAlternateScheduleItemRequest = WithDayNames<
components['schemas']['PlayoutAlternateScheduleItemRequest']
>;
export type PlayoutTemplate = WithDayNames<components['schemas']['PlayoutTemplateResponseModel']>;
export type PlayoutTemplateItemRequest = WithDayNames<components['schemas']['PlayoutTemplateItemRequest']>;
export type PlayoutAlternateSchedule = components['schemas']['PlayoutAlternateScheduleResponseModel'];
export type PlayoutAlternateScheduleItemRequest = components['schemas']['PlayoutAlternateScheduleItemRequest'];
export type PlayoutTemplate = components['schemas']['PlayoutTemplateResponseModel'];
export type PlayoutTemplateItemRequest = components['schemas']['PlayoutTemplateItemRequest'];
// Declared as `type` (not `interface`) so they satisfy the client's `Record<string, unknown>`
// RequestBody bound, matching the generated request DTOs.
@@ -123,8 +120,9 @@ export function deletePlayout(playoutId: number): Promise<void> {
// Resets the given channel's playout. The server picks the correct default mode per schedule kind
// (Classic → Refresh, others → Reset), matching the Blazor "Reset Playout" action — so no mode is sent.
export function resetChannelPlayout(channelNumber: string): Promise<void> {
return request<void>(`/api/channels/${encodeURIComponent(channelNumber)}/playout/reset`, { method: 'POST' });
// Keyed on the immutable channel id (not the user-mutable number), matching the /api/channels/{id} contract.
export function resetChannelPlayout(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}/playout/reset`, { method: 'POST' });
}
export function erasePlayoutItems(playoutId: number): Promise<void> {
+1 -1
View File
@@ -12,7 +12,7 @@ export type ScheduleItem = components['schemas']['ScheduleItemResponseModel'];
export type ScheduleItemsResponse = components['schemas']['ScheduleItemsResponseModel'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
export type ProgramSchedule = components['schemas']['ProgramScheduleResponseModel'];
export type CreateScheduleRequest = components['schemas']['CreateScheduleRequest'];
export type UpdateScheduleRequest = components['schemas']['UpdateScheduleRequest'];
export type NamedId = components['schemas']['NamedIdResponseModel'];
+9 -9
View File
@@ -88,16 +88,16 @@ describe('toAddItemsRequestFromSearch', () => {
it('fills every bucket, defaulting null arrays to []', () => {
expect(
toAddItemsRequestFromSearch({
artistIds: null,
episodeIds: null,
imageIds: null,
artistIds: [],
episodeIds: [],
imageIds: [],
movieIds: [1, 2],
musicVideoIds: null,
otherVideoIds: null,
remoteStreamIds: null,
seasonIds: null,
showIds: null,
songIds: null
musicVideoIds: [],
otherVideoIds: [],
remoteStreamIds: [],
seasonIds: [],
showIds: [],
songIds: []
})
).toEqual({
artistIds: [],