@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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><base>Get</c>,
|
||||
/// <c><base>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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+5439
-707
File diff suppressed because it is too large
Load Diff
+54
-21
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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'];
|
||||
|
||||
Vendored
+82
-87
@@ -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";
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user