diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index bbe5a8c78..558f9f061 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -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, diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApi.cs b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApi.cs new file mode 100644 index 000000000..adf640e2b --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Channels; + +namespace ErsatzTV.Application.Channels; + +public record GetChannelByIdForApi(int Id) : IRequest>; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs new file mode 100644 index 000000000..8773964db --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs @@ -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> +{ + public Task> Handle( + GetChannelByIdForApi request, + CancellationToken cancellationToken) => + channelRepository.GetChannel(request.Id) + .MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0)); +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelId.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelId.cs new file mode 100644 index 000000000..a7f6c6aaa --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelId.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Playouts; + +public record GetPlayoutIdByChannelId(int ChannelId) : IRequest>; diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelIdHandler.cs new file mode 100644 index 000000000..ee44c1e66 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelIdHandler.cs @@ -0,0 +1,18 @@ +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Playouts; + +public class GetPlayoutIdByChannelIdHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> 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()); + } +} diff --git a/ErsatzTV.Application/Search/Queries/GetSearchResults.cs b/ErsatzTV.Application/Search/Queries/GetSearchResults.cs index 73af3f668..bfd47fc20 100644 --- a/ErsatzTV.Application/Search/Queries/GetSearchResults.cs +++ b/ErsatzTV.Application/Search/Queries/GetSearchResults.cs @@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Search; namespace ErsatzTV.Application.Search; -public record GetSearchResults(string Query, int PageSize) : IRequest; +public record GetSearchResults(string Query, int PageNum, int PageSize) : IRequest; diff --git a/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs b/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs index c9567f0b0..8ea6bb692 100644 --- a/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs +++ b/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs @@ -19,7 +19,7 @@ public class GetSearchResultsHandler(IMediator mediator) async Task 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); } diff --git a/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs new file mode 100644 index 000000000..850c9e14a --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs @@ -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; +} diff --git a/ErsatzTV.Core/Api/FFmpegProfiles/FFmpegProfileResponseModel.cs b/ErsatzTV.Core/Api/FFmpegProfiles/FFmpegProfileResponseModel.cs index 0884a2ed8..54afab848 100644 --- a/ErsatzTV.Core/Api/FFmpegProfiles/FFmpegProfileResponseModel.cs +++ b/ErsatzTV.Core/Api/FFmpegProfiles/FFmpegProfileResponseModel.cs @@ -1,4 +1,5 @@ -namespace ErsatzTV.Core.Api.FFmpegProfiles; +#nullable enable +namespace ErsatzTV.Core.Api.FFmpegProfiles; public record FFmpegProfileResponseModel( int Id, diff --git a/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs index 8ac01485a..3ceb17084 100644 --- a/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs +++ b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable using ErsatzTV.Core.Domain.Filler; namespace ErsatzTV.Core.Api.Filler; diff --git a/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs b/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs index fef16a496..c540a8c97 100644 --- a/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs +++ b/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.Graphics; public record GraphicsElementResponseModel(int Id, string Name); diff --git a/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs b/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs index fec106914..49519af84 100644 --- a/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs +++ b/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.Libraries; public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent); diff --git a/ErsatzTV.Core/Api/MediaCollections/MediaCollectionResponseModel.cs b/ErsatzTV.Core/Api/MediaCollections/MediaCollectionResponseModel.cs new file mode 100644 index 000000000..d1e9d8211 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaCollections/MediaCollectionResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs b/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs index d57b9c316..8a97fadbd 100644 --- a/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.MediaItems; public record LanguageCodeResponseModel(string Code, string EnglishName); diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs index 2f824207a..3b7cbc065 100644 --- a/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Api.MediaSources; diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs index 9fd9084c0..4753f4d35 100644 --- a/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.MediaSources; public record LocalLibraryPathResponseModel(int Id, string Path, int MediaItemCount); diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs index 7bb4bb8d2..f1977590e 100644 --- a/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Api.MediaSources; diff --git a/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs index d8e192c96..5d339c159 100644 --- a/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.MediaSources; public record LocalPathCheckResponseModel(bool Exists); diff --git a/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs index 40883f44a..b0ff54d26 100644 --- a/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs index 141db29f3..bda0098d1 100644 --- a/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.MediaSources; public record PlexPinFlowResponseModel(string AuthUrl); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs index c514a1af6..cfcfafdf4 100644 --- a/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs @@ -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) diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs index 307f0b853..76e5ce918 100644 --- a/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs index 3e18cc656..ef032337d 100644 --- a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs index 0654d7bf7..7fe81fe15 100644 --- a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.MediaSources; public record RemoteMediaSourceStateResponseModel( diff --git a/ErsatzTV.Core/Api/NamedIdResponseModel.cs b/ErsatzTV.Core/Api/NamedIdResponseModel.cs index db19e49c2..bb2578389 100644 --- a/ErsatzTV.Core/Api/NamedIdResponseModel.cs +++ b/ErsatzTV.Core/Api/NamedIdResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api; /// diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs index be6249ef1..fe084d43a 100644 --- a/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.Playouts; public record PagedPlayoutItemsResponseModel( diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs index 5473e67d2..81e1d1cc3 100644 --- a/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.Playouts; public record PagedPlayoutsResponseModel( diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs index 5410c9d23..1dfaea6cf 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.Playouts; public record PlayoutBuildStatusResponseModel( diff --git a/ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs new file mode 100644 index 000000000..157d2b4f5 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/Search/SearchResultAllItemsResponseModel.cs b/ErsatzTV.Core/Api/Search/SearchResultAllItemsResponseModel.cs index f94d0897d..aa7ca3e91 100644 --- a/ErsatzTV.Core/Api/Search/SearchResultAllItemsResponseModel.cs +++ b/ErsatzTV.Core/Api/Search/SearchResultAllItemsResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.Search; public record SearchResultAllItemsResponseModel( diff --git a/ErsatzTV.Core/Api/Settings/XmltvBlockBehavior.cs b/ErsatzTV.Core/Api/Settings/XmltvBlockBehavior.cs index ec4748333..db4ee83c3 100644 --- a/ErsatzTV.Core/Api/Settings/XmltvBlockBehavior.cs +++ b/ErsatzTV.Core/Api/Settings/XmltvBlockBehavior.cs @@ -1,3 +1,4 @@ +#nullable enable using System.Text.Json.Serialization; namespace ErsatzTV.Core.Api.Settings; diff --git a/ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs b/ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs index b26f88508..d1cfcd1a5 100644 --- a/ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs +++ b/ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs @@ -1,3 +1,4 @@ +#nullable enable using System.Text.Json.Serialization; namespace ErsatzTV.Core.Api.Settings; diff --git a/ErsatzTV.Core/Api/SmartCollections/SmartCollectionResponseModel.cs b/ErsatzTV.Core/Api/SmartCollections/SmartCollectionResponseModel.cs index faeb2117c..92dd2abe4 100644 --- a/ErsatzTV.Core/Api/SmartCollections/SmartCollectionResponseModel.cs +++ b/ErsatzTV.Core/Api/SmartCollections/SmartCollectionResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.SmartCollections; public record SmartCollectionResponseModel(int Id, string Name, string Query); diff --git a/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs b/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs index 656e5b550..9d0d6435c 100644 --- a/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs +++ b/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ErsatzTV.Core.Api.Watermarks; public record WatermarkResponseModel(int Id, string Name); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index 6d7d9c6a7..79ce5d889 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -11,6 +11,7 @@ public class ChannelRepository(IDbContextFactory 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) diff --git a/ErsatzTV.Tests/Application/Channels/GetChannelByIdForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelByIdForApiHandlerTests.cs new file mode 100644 index 000000000..3c651108c --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/GetChannelByIdForApiHandlerTests.cs @@ -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(); + repository.GetChannel(7) + .Returns(Option.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 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(); + repository.GetChannel(Arg.Any()).Returns(Option.None); + var handler = new GetChannelByIdForApiHandler(repository); + + Option result = + await handler.Handle(new GetChannelByIdForApi(99), CancellationToken.None); + + result.IsNone.ShouldBeTrue(); + } +} diff --git a/ErsatzTV.Tests/Application/Playouts/GetPlayoutIdByChannelIdHandlerTests.cs b/ErsatzTV.Tests/Application/Playouts/GetPlayoutIdByChannelIdHandlerTests.cs new file mode 100644 index 000000000..228d0f83e --- /dev/null +++ b/ErsatzTV.Tests/Application/Playouts/GetPlayoutIdByChannelIdHandlerTests.cs @@ -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 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 result = + await CreateHandler().Handle(new GetPlayoutIdByChannelId(channelId + 1000), CancellationToken.None); + + result.IsNone.ShouldBeTrue(); + } +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index f1f12b89f..97971c94e 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -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(), Arg.Any()) .Returns(Right(new CreateChannelResult(5))); - ChannelViewModel vm = MakeVm(5); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Option.Some(vm)); + ChannelDetailResponseModel model = MakeDetailModel(5); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(model)); IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None); var created = result.ShouldBeOfType(); 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(), Arg.Any()) .Returns(Right(new CreateChannelResult(5))); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Option.Some(MakeVm(5))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.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(), Arg.Any()) - .Returns(Right(vm)); + .Returns(Right(MakeVm(7))); + ChannelDetailResponseModel model = MakeDetailModel(7); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(model)); IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(vm); + result.ShouldBeOfType().Value.ShouldBe(model); await _mediator.Received(1).Send( Arg.Is(c => c.ChannelId == 7), Arg.Any()); + await _mediator.Received(1).Send( + Arg.Is(q => q.Id == 7), + Arg.Any()); } [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(), Arg.Any()) - .Returns(Option.Some(vm)); + ChannelDetailResponseModel model = MakeDetailModel(4); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(model)); IActionResult result = await _controller.GetById(4, CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(vm); + result.ShouldBeOfType().Value.ShouldBe(model); } [Test] public async Task GetById_Should_Return_404_For_None() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Option.None); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.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(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(), Arg.Any()) + _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.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(); var problemDetails = notFound.Value.ShouldBeOfType(); @@ -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(), Arg.Any()) + .Returns(Option.Some(9)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9, PlayoutScheduleKind.Classic))); + + IActionResult result = await _controller.ResetPlayout(42, mode: null, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(202); + await _mediator.Received(1).Send( + Arg.Is(q => q.ChannelId == 42), + Arg.Any()); + } + [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(), Arg.Any()) + _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(9)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.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().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(), Arg.Any()) + _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.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(); conflict.Value.ShouldBeOfType().Status.ShouldBe(409); @@ -431,10 +460,10 @@ public class ChannelControllerTests [Test] public async Task ResetPlayout_Should_Honor_Explicit_Mode() { - _mediator.Send(Arg.Any(), Arg.Any()) + _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(9)); - IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None); + IActionResult result = await _controller.ResetPlayout(5, PlayoutBuildMode.Continue, CancellationToken.None); result.ShouldBeOfType().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, diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs index 9914e4e2d..5a2c805b5 100644 --- a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs @@ -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(); 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().Value.ShouldBe(vm); + result.ShouldBeOfType().Value + .ShouldBe(new MediaCollectionResponseModel(7, "Updated", CollectionType.Collection, false)); await _mediator.Received(1).Send( Arg.Is(c => c.CollectionId == 7 && @@ -274,9 +276,13 @@ public class CollectionControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns(vms); - List result = await _controller.GetAll(CancellationToken.None); + List 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().Value.ShouldBe(vm); + result.ShouldBeOfType().Value + .ShouldBe(new MediaCollectionResponseModel(4, "Movies", CollectionType.Collection, false)); } [Test] diff --git a/ErsatzTV.Tests/Controllers/OpenApiContractHonestyTests.cs b/ErsatzTV.Tests/Controllers/OpenApiContractHonestyTests.cs new file mode 100644 index 000000000..da94713c7 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/OpenApiContractHonestyTests.cs @@ -0,0 +1,151 @@ +using ErsatzTV.Serialization; +using ErsatzTV.Tests.Support; +using Microsoft.OpenApi; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +/// +/// Pins the #287 "contract honesty by construction" transformers against the document the app would +/// actually serve (generated in-process; see ): 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. +/// +[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(); + 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 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 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()).ShouldContain("Sunday"); + } + + private static IEnumerable<(string Method, string Path, OpenApiOperation Operation)> EnumerateOperations() + { + foreach (KeyValuePair path in _document.Paths) + { + if (path.Value.Operations is null) + { + continue; + } + + foreach (KeyValuePair operation in path.Value.Operations) + { + yield return (operation.Key.Method, path.Key, operation.Value); + } + } + } +} diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 2e0c8638a..c4ea019bc 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -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")] diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index e990d1c89..1388a65ae 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -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 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", diff --git a/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs b/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs index 446844c8e..06c2a1225 100644 --- a/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs @@ -47,6 +47,31 @@ public class ResolutionControllerTests ]); } + [Test] + public async Task GetResolutionByName_Should_Return_ResponseModel_Not_ViewModel() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new ResolutionViewModel(1, "1920x1080", 1920, 1080, false))); + + ActionResult result = + await _controller.GetResolutionByName("1920x1080", CancellationToken.None); + + var ok = result.Result.ShouldBeOfType(); + ok.Value.ShouldBe(new ResolutionResponseModel(1, "1920x1080", 1920, 1080, false)); + } + + [Test] + public async Task GetResolutionByName_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + ActionResult result = + await _controller.GetResolutionByName("nope", CancellationToken.None); + + result.Result.ShouldBeOfType(); + } + [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { diff --git a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs index d4362da7c..0a864e3f2 100644 --- a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs @@ -79,7 +79,8 @@ public class ScheduleControllerTests var created = result.ShouldBeOfType(); 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().Value.ShouldBe(vm); + result.ShouldBeOfType().Value.ShouldBe( + new ProgramScheduleResponseModel(7, "Updated", true, true, false, false, FixedStartTimeBehavior.Flexible)); await _mediator.Received(1).Send( Arg.Is(c => c.ProgramScheduleId == 7 && c.Name == "Updated"), Arg.Any()); @@ -179,7 +181,8 @@ public class ScheduleControllerTests IActionResult result = await _controller.GetById(4, CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(vm); + result.ShouldBeOfType().Value.ShouldBe( + new ProgramScheduleResponseModel(4, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible)); } [Test] diff --git a/ErsatzTV.Tests/Controllers/SearchControllerTests.cs b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs index 184fb88ed..418b22b00 100644 --- a/ErsatzTV.Tests/Controllers/SearchControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs @@ -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(); unprocessable.StatusCode.ShouldBe(422); @@ -55,7 +55,7 @@ public class SearchControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns(EmptyResults()); - IActionResult result = await _controller.Search("star", 500, CancellationToken.None); + IActionResult result = await _controller.Search("star", 0, 500, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( @@ -63,13 +63,31 @@ public class SearchControllerTests Arg.Any()); } + [Test] + public async Task Search_Should_Thread_PageNum_And_Clamp_Negative() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .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(q => q.Query == "star" && q.PageNum == 3 && q.PageSize == 50), + Arg.Any()); + + await _controller.Search("star", -7, 50, CancellationToken.None); + await _mediator.Received(1).Send( + Arg.Is(q => q.Query == "star" && q.PageNum == 0 && q.PageSize == 50), + Arg.Any()); + } + [Test] public async Task Search_Should_Return_Grouped_Results() { SearchResultsResponseModel results = EmptyResults(); _mediator.Send(Arg.Any(), Arg.Any()).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(); ok.Value.ShouldBe(results); diff --git a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs index 860f4da88..45e7bd251 100644 --- a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs @@ -54,7 +54,7 @@ public class SmartCollectionControllerTests var created = result.ShouldBeOfType(); 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().Value.ShouldBe(vm); + result.ShouldBeOfType().Value + .ShouldBe(new SmartCollectionResponseModel(8, "Updated", "tag:updated")); await _mediator.Received(1).Send( Arg.Is(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().Value.ShouldBe(vm); + result.ShouldBeOfType().Value + .ShouldBe(new SmartCollectionResponseModel(4, "Kids", "tag:kids")); } [Test] diff --git a/ErsatzTV.Tests/Filters/ApiKeyEndpointRequiresKeyTests.cs b/ErsatzTV.Tests/Filters/ApiKeyEndpointRequiresKeyTests.cs new file mode 100644 index 000000000..5aaf8f39d --- /dev/null +++ b/ErsatzTV.Tests/Filters/ApiKeyEndpointRequiresKeyTests.cs @@ -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; + +/// +/// Proves the shared 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). +/// +[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(); + 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(), 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 Metadata(bool requiresApiKey, bool skip) + { + var metadata = new List(); + 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()); + } +} diff --git a/ErsatzTV.Tests/Support/GeneratedOpenApiDocument.cs b/ErsatzTV.Tests/Support/GeneratedOpenApiDocument.cs new file mode 100644 index 000000000..0a6496341 --- /dev/null +++ b/ErsatzTV.Tests/Support/GeneratedOpenApiDocument.cs @@ -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; + +/// +/// Builds the "v1" OpenAPI document in-process from the real +/// 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 +/// wwwroot/openapi/v1.json that is regenerated out-of-band. Mirrors the DI-only bootstrap that +/// ApiControllerSecurityTests uses to inspect MvcOptions. +/// +public static class GeneratedOpenApiDocument +{ + public static async Task BuildV1Async() + { + var settings = new Dictionary + { + ["provider"] = "sqlite", + ["ConnectionStrings:Data"] = "Data Source=:memory:" + }; + + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var environment = Substitute.For(); + 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(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() + .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("v1"); + return await documentProvider.GetOpenApiDocumentAsync(); + } +} diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index d2357e9e7..283ff9a59 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -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 GetById(int id, CancellationToken cancellationToken) { - Option result = await mediator.Send(new GetChannelById(id), cancellationToken); + Option 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 Create( @@ -92,10 +92,10 @@ public class ChannelController( Left: error => Task.FromResult(error.ToErrorResult()), Right: async created => { - Option channel = - await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken); + Option 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 Update( @@ -140,7 +140,18 @@ public class ChannelController( { Either 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 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 ResetPlayout( - string channelNumber, + int id, [FromQuery] PlayoutBuildMode? mode, CancellationToken cancellationToken) { Option 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. diff --git a/ErsatzTV/Controllers/Api/CollectionController.cs b/ErsatzTV/Controllers/Api/CollectionController.cs index 81031f2e7..ffbe1bf99 100644 --- a/ErsatzTV/Controllers/Api/CollectionController.cs +++ b/ErsatzTV/Controllers/Api/CollectionController.cs @@ -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), StatusCodes.Status200OK)] - public async Task> GetAll(CancellationToken cancellationToken) => - await mediator.Send(new GetAllCollections(), cancellationToken); + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) + { + List 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 GetById(int id, CancellationToken cancellationToken) { Option 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 Create( @@ -77,14 +81,14 @@ public class CollectionController(IMediator mediator) : ControllerBase { Either 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 Update( @@ -100,7 +104,7 @@ public class CollectionController(IMediator mediator) : ControllerBase Option 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); } diff --git a/ErsatzTV/Controllers/Api/ResolutionController.cs b/ErsatzTV/Controllers/Api/ResolutionController.cs index 0e148497b..b19cfc7b5 100644 --- a/ErsatzTV/Controllers/Api/ResolutionController.cs +++ b/ErsatzTV/Controllers/Api/ResolutionController.cs @@ -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> 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> GetResolutionByName( + string name, + CancellationToken cancellationToken) { Option result = await mediator.Send(new GetResolutionByName(name), cancellationToken); - return result.Match>(i => Ok(i), () => NotFound()); + return result.Match>( + i => Ok(ProjectToResponseModel(i)), + () => NotFound()); } [HttpGet("/api/settings/resolutions", Name = "GetResolutions")] diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs index c325f7824..e0ae6c773 100644 --- a/ErsatzTV/Controllers/Api/ScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -18,27 +18,30 @@ public class ScheduleController(IMediator mediator) : ControllerBase [Tags("Schedules")] [EndpointSummary("Get all schedules")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] - public async Task> GetAll(CancellationToken cancellationToken) => - await mediator.Send(new GetAllProgramSchedules(), cancellationToken); + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) + { + List 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 GetById(int id, CancellationToken cancellationToken) { Option 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 Create( @@ -54,7 +57,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase Option 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 Update( @@ -80,7 +85,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase Option 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); } diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs index b544522c7..5e6d17c0a 100644 --- a/ErsatzTV/Controllers/Api/SearchController.cs +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -24,6 +24,7 @@ public class SearchController(IMediator mediator) : ControllerBase [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task 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); } diff --git a/ErsatzTV/Controllers/Api/SmartCollectionController.cs b/ErsatzTV/Controllers/Api/SmartCollectionController.cs index 4837a4277..272bfa5fa 100644 --- a/ErsatzTV/Controllers/Api/SmartCollectionController.cs +++ b/ErsatzTV/Controllers/Api/SmartCollectionController.cs @@ -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 GetById(int id, CancellationToken cancellationToken) { Option 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 Create( @@ -47,14 +47,14 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase { Either 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 Update( @@ -71,7 +71,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase Option 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 result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken); return result.ToDeletedResult(); } + + private static SmartCollectionResponseModel ProjectToResponseModel(SmartCollectionViewModel vm) => + new(vm.Id, vm.Name, vm.Query); } diff --git a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs index b30771de6..419b2af27 100644 --- a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs +++ b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs @@ -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(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) + /// + /// The single decision shared between runtime enforcement (this filter) and the OpenAPI + /// document generation (ApiSecurityOperationTransformer), so the spec's declared + /// security/401 can never drift from what is actually enforced. Assumes the + /// endpoint is already known to be under /api (the caller's responsibility). Returns + /// when the endpoint requires a valid X-Api-Key: any mutating + /// verb (POST/PUT/PATCH/DELETE) is always fail-closed; reads are gated when + /// is set or the endpoint carries + /// . OPTIONS preflight and endpoints marked + /// are exempt. + /// + public static bool EndpointRequiresKey( + string httpMethod, + IEnumerable 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().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(context); + || requireKeyForReads + || endpointMetadata.OfType().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(AuthorizationFilterContext context) - where TAttribute : class => - context.Filters.OfType().Any() - || context.ActionDescriptor.EndpointMetadata.OfType().Any(); } diff --git a/ErsatzTV/Serialization/ApiSecurityOperationTransformer.cs b/ErsatzTV/Serialization/ApiSecurityOperationTransformer.cs new file mode 100644 index 000000000..8ee72f88b --- /dev/null +++ b/ErsatzTV/Serialization/ApiSecurityOperationTransformer.cs @@ -0,0 +1,60 @@ +#nullable enable +using ErsatzTV.Filters; +using ErsatzTV.Services; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace ErsatzTV.Serialization; + +/// +/// OpenAPI operation transformer that documents the API-key contract by construction: for +/// every operation that actually requires the X-Api-Key header it injects the +/// ApiKey security requirement and a documented 401 response. The "requires a key" +/// decision is the exact same predicate the runtime filter enforces +/// (), so the spec can never claim an +/// endpoint is open when it is gated (or vice-versa). The ApiKey scheme itself and the +/// ProblemDetails schema the 401 references are declared by +/// . See issues #286/#287. +/// +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 metadata = context.Description.ActionDescriptor.EndpointMetadata; + + if (!ApiKeyAuthorizationFilter.EndpointRequiresKey(method, metadata, apiKeyProvider.RequireKeyForReads)) + { + return Task.CompletedTask; + } + + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(SchemeName)] = new List() + }); + + operation.Responses ??= new OpenApiResponses(); + if (!operation.Responses.ContainsKey("401")) + { + operation.Responses["401"] = new OpenApiResponse + { + Description = "API key missing or invalid.", + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("ProblemDetails") + } + } + }; + } + + return Task.CompletedTask; + } +} diff --git a/ErsatzTV/Serialization/ApiSecuritySchemeDocumentTransformer.cs b/ErsatzTV/Serialization/ApiSecuritySchemeDocumentTransformer.cs new file mode 100644 index 000000000..6b00164f1 --- /dev/null +++ b/ErsatzTV/Serialization/ApiSecuritySchemeDocumentTransformer.cs @@ -0,0 +1,103 @@ +#nullable enable +using ErsatzTV.Filters; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace ErsatzTV.Serialization; + +/// +/// OpenAPI document transformer that declares the components the per-operation transformers reference: +/// the ApiKey security scheme (X-Api-Key request header) used by +/// , and the ValidationProblemDetails schema used +/// by for documented 400 responses. Runs on +/// the "v1" document only. See issues #286/#287. +/// +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(); + 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(); + 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 + { + ["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 + { + ["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 } + } + } + } + }; +} diff --git a/ErsatzTV/Serialization/OperationIdOpenApiTransformer.cs b/ErsatzTV/Serialization/OperationIdOpenApiTransformer.cs new file mode 100644 index 000000000..1d22ccd38 --- /dev/null +++ b/ErsatzTV/Serialization/OperationIdOpenApiTransformer.cs @@ -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; + +namespace ErsatzTV.Serialization; + +/// +/// OpenAPI operation transformer that guarantees every operation carries a stable, non-empty +/// operationId. Only actions with Name = "..." 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 operationId. Here we synthesize one from the +/// (ControllerName + ActionName) whenever the +/// id is null/empty, and never rename an id the developer set explicitly. +/// +/// Disambiguation is structural, not visitation-order-dependent: the colliding cases are the +/// HEAD/GET pairs that share a controller+action but differ only by verb. We precompute (from the +/// full , filtered to the same +/// general group the "v1" document includes) the set of synthesized base ids that 2+ operations +/// share, and suffix each such operation by its HTTP verb (<base>Get, +/// <base>Head). 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. +/// +/// +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(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(); + } +} diff --git a/ErsatzTV/Serialization/ValidationProblemOperationTransformer.cs b/ErsatzTV/Serialization/ValidationProblemOperationTransformer.cs new file mode 100644 index 000000000..a70e47ee3 --- /dev/null +++ b/ErsatzTV/Serialization/ValidationProblemOperationTransformer.cs @@ -0,0 +1,52 @@ +#nullable enable +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace ErsatzTV.Serialization; + +/// +/// OpenAPI operation transformer that documents the 400 a model-binding / FluentValidation +/// failure actually returns. ASP.NET Core's [ApiController] convention emits a +/// ValidationProblemDetails (an errors map) for any operation that binds a request body +/// or parameters, but that response was undocumented. This adds a 400 referencing the +/// ValidationProblemDetails schema (declared by +/// ) to every operation that binds something and +/// does not already document a 400. See issues #286/#287. +/// +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 + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference( + ApiSecuritySchemeDocumentTransformer.ValidationProblemDetailsSchemaId) + } + } + }; + + return Task.CompletedTask; + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index ab71a36ed..ab2c76e8d 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -139,6 +139,7 @@ public class Startup Dictionary 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(); + options.AddOperationTransformer(); + options.AddOperationTransformer(ValidationProblemOperationTransformer.TransformAsync); }); services.AddOpenApi( diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index a219dedd9..3838d6143 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -63,8 +63,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/artwork/uploads": { @@ -142,8 +165,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/blocks/groups": { @@ -182,14 +228,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Blocks" ], "summary": "Create a block group", + "operationId": "BlockCreateGroup", "requestBody": { "content": { "application/json-patch+json": { @@ -275,8 +335,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/blocks/groups/{id}": { @@ -286,6 +369,7 @@ ], "summary": "Delete a block group", "description": "Deletes the block group. The database cascade removes every block (and its items) in the group.", + "operationId": "BlockDeleteGroup", "parameters": [ { "name": "id", @@ -320,8 +404,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/blocks": { @@ -330,6 +437,7 @@ "Blocks" ], "summary": "Get all blocks", + "operationId": "BlockGetAll", "responses": { "200": { "description": "OK", @@ -359,8 +467,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -368,6 +489,7 @@ ], "summary": "Create a block", "description": "Creates an empty block in the given block group. The block defaults to 30 minutes.", + "operationId": "BlockCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -453,8 +575,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/blocks/{id}": { @@ -515,14 +660,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Blocks" ], "summary": "Delete a block", + "operationId": "BlockDelete", "parameters": [ { "name": "id", @@ -557,8 +726,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -566,6 +758,7 @@ ], "summary": "Replace a block and its items", "description": "Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.", + "operationId": "BlockReplace", "parameters": [ { "name": "id", @@ -702,8 +895,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/blocks/{id}/items": { @@ -713,6 +919,7 @@ ], "summary": "Get block items", "description": "Returns the block's items and a strong ETag of the block's version. Pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).", + "operationId": "BlockGetItems", "parameters": [ { "name": "id", @@ -773,8 +980,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/blocks/{id}/preview": { @@ -784,6 +1014,7 @@ ], "summary": "Preview a block playout", "description": "Builds a preview playout from the supplied block definition without persisting any changes to the block, its items, or any playout. Returns the resulting start/finish/title/duration list.", + "operationId": "BlockPreview", "parameters": [ { "name": "id", @@ -869,8 +1100,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/blocks/{id}/copy": { @@ -880,6 +1134,7 @@ ], "summary": "Copy a block", "description": "Copies the block and its items into another (or the same) block group under a new name.", + "operationId": "BlockCopy", "parameters": [ { "name": "id", @@ -976,8 +1231,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels": { @@ -985,6 +1263,7 @@ "tags": [ "Channel" ], + "operationId": "ChannelGetAll", "responses": { "200": { "description": "OK", @@ -1014,14 +1293,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Channels" ], "summary": "Create a channel", + "operationId": "ChannelCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -1053,17 +1346,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } } } @@ -1107,8 +1400,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels/state": { @@ -1117,6 +1433,7 @@ "Channels" ], "summary": "Get channel runtime state", + "operationId": "ChannelGetState", "responses": { "200": { "description": "OK", @@ -1146,8 +1463,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/guide": { @@ -1157,6 +1487,7 @@ ], "summary": "Get the JSON channel guide (EPG)", "description": "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, and end defaults to start plus the configured XmltvDaysToBuild window.", + "operationId": "ChannelGetGuide", "parameters": [ { "name": "start", @@ -1195,8 +1526,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels/music-video-credits-templates": { @@ -1235,8 +1589,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels/stream-selectors": { @@ -1275,8 +1642,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels/{id}": { @@ -1303,17 +1683,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } } } @@ -1337,14 +1717,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ "Channels" ], "summary": "Update a channel", + "operationId": "ChannelUpdate", "parameters": [ { "name": "id", @@ -1387,17 +1791,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ChannelViewModel" + "$ref": "#/components/schemas/ChannelDetailResponseModel" } } } @@ -1441,14 +1845,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Channels" ], "summary": "Delete a channel", + "operationId": "ChannelDelete", "parameters": [ { "name": "id", @@ -1503,8 +1931,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels/from-lineup": { @@ -1600,8 +2051,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels/bulk/renumber": { @@ -1610,6 +2084,7 @@ "Channels" ], "summary": "Renumber channels", + "operationId": "ChannelBulkRenumber", "requestBody": { "content": { "application/json-patch+json": { @@ -1678,8 +2153,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels/bulk/group": { @@ -1688,6 +2186,7 @@ "Channels" ], "summary": "Move channels to a group", + "operationId": "ChannelBulkMoveToGroup", "requestBody": { "content": { "application/json-patch+json": { @@ -1756,8 +2255,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channels/bulk/delete": { @@ -1766,6 +2288,7 @@ "Channels" ], "summary": "Delete channels", + "operationId": "ChannelBulkDelete", "requestBody": { "content": { "application/json-patch+json": { @@ -1834,24 +2357,49 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, - "/api/channels/{channelNumber}/playout/reset": { + "/api/channels/{id}/playout/reset": { "post": { "tags": [ "Channels" ], "summary": "Reset channel playout", "description": "When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. Pass mode to force a specific PlayoutBuildMode.", + "operationId": "ChannelResetPlayout", "parameters": [ { - "name": "channelNumber", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "integer", + "format": "int32" } }, { @@ -1905,8 +2453,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channel-templates": { @@ -1945,8 +2516,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -2039,8 +2623,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channel-templates/default": { @@ -2090,8 +2697,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channel-templates/default/{id}": { @@ -2172,8 +2792,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/channel-templates/{id}": { @@ -2234,8 +2877,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -2339,8 +3005,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ @@ -2402,8 +3091,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/collections": { @@ -2412,6 +3124,7 @@ "Collections" ], "summary": "Get all collections", + "operationId": "CollectionGetAll", "responses": { "200": { "description": "OK", @@ -2420,7 +3133,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } } }, @@ -2428,7 +3141,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } } }, @@ -2436,19 +3149,33 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Collections" ], "summary": "Create a collection", + "operationId": "CollectionCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -2480,17 +3207,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } } } @@ -2534,8 +3261,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/collections/{id}": { @@ -2562,17 +3312,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } } } @@ -2596,14 +3346,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ "Collections" ], "summary": "Update a collection", + "operationId": "CollectionUpdate", "parameters": [ { "name": "id", @@ -2646,17 +3420,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/MediaCollectionViewModel" + "$ref": "#/components/schemas/MediaCollectionResponseModel" } } } @@ -2700,14 +3474,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Collections" ], "summary": "Delete a collection", + "operationId": "CollectionDelete", "parameters": [ { "name": "id", @@ -2762,8 +3560,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/collections/{id}/items": { @@ -2843,14 +3664,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Collections" ], "summary": "Add items to a collection", + "operationId": "CollectionAddItems", "parameters": [ { "name": "id", @@ -2930,8 +3775,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/collections/{id}/custom-order": { @@ -2941,6 +3809,7 @@ ], "summary": "Set a collection's custom playback order", "description": "Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position (the request body has no index field); ids that are not members of the collection are ignored. Set UseCustomPlaybackOrder on the collection for this order to take effect.", + "operationId": "CollectionUpdateCustomOrder", "parameters": [ { "name": "id", @@ -3060,8 +3929,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/collections/{id}/items/{mediaItemId}": { @@ -3070,6 +3952,7 @@ "Collections" ], "summary": "Remove an item from a collection", + "operationId": "CollectionRemoveItem", "parameters": [ { "name": "id", @@ -3133,8 +4016,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/decos/groups": { @@ -3173,14 +4079,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Decos" ], "summary": "Create a deco group", + "operationId": "DecoCreateGroup", "requestBody": { "content": { "application/json-patch+json": { @@ -3266,8 +4186,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/decos/groups/{id}": { @@ -3277,6 +4220,7 @@ ], "summary": "Delete a deco group", "description": "Deletes the deco group. The database cascade removes every deco in the group (and each deco's break content and watermark/graphics-element assignments); any playout that used a removed deco has its default deco cleared.", + "operationId": "DecoDeleteGroup", "parameters": [ { "name": "id", @@ -3311,8 +4255,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/decos": { @@ -3322,6 +4289,7 @@ ], "summary": "Get all decos", "description": "Returns every deco as a flat list, ordered by group name then deco name.", + "operationId": "DecoGetAll", "responses": { "200": { "description": "OK", @@ -3351,8 +4319,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -3360,6 +4341,7 @@ ], "summary": "Create a deco", "description": "Creates a deco in the given deco group. The deco inherits everything by default.", + "operationId": "DecoCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -3445,8 +4427,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/decos/{id}": { @@ -3507,8 +4512,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ @@ -3516,6 +4544,7 @@ ], "summary": "Delete a deco", "description": "Deletes the deco. Any playout that used it as its default deco has that default cleared.", + "operationId": "DecoDelete", "parameters": [ { "name": "id", @@ -3550,8 +4579,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -3559,6 +4611,7 @@ ], "summary": "Replace a deco", "description": "Replaces the full state of the deco (name and all six sections). Send the entire desired state, including unchanged sections. Merge is only valid for the watermark and graphics-element modes. Break content allows at most one item per placement.", + "operationId": "DecoReplace", "parameters": [ { "name": "id", @@ -3655,8 +4708,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/deco-templates/groups": { @@ -3695,14 +4771,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "DecoTemplates" ], "summary": "Create a deco template group", + "operationId": "DecoTemplateCreateGroup", "requestBody": { "content": { "application/json-patch+json": { @@ -3788,8 +4878,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/deco-templates/groups/{id}": { @@ -3799,6 +4912,7 @@ ], "summary": "Delete a deco template group", "description": "Deletes the deco template group. The database cascade removes every deco template (and its items) in the group; any playout template that used a removed deco template has that reference cleared.", + "operationId": "DecoTemplateDeleteGroup", "parameters": [ { "name": "id", @@ -3833,8 +4947,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/deco-templates": { @@ -3844,6 +4981,7 @@ ], "summary": "Get all deco templates", "description": "Returns every deco template as a flat list, ordered by group name then deco template name.", + "operationId": "DecoTemplateGetAll", "responses": { "200": { "description": "OK", @@ -3873,8 +5011,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -3882,6 +5033,7 @@ ], "summary": "Create a deco template", "description": "Creates an empty deco template in the given deco template group.", + "operationId": "DecoTemplateCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -3967,8 +5119,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/deco-templates/{id}": { @@ -4029,14 +5204,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "DecoTemplates" ], "summary": "Delete a deco template", + "operationId": "DecoTemplateDelete", "parameters": [ { "name": "id", @@ -4071,8 +5270,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -4080,6 +5302,7 @@ ], "summary": "Replace a deco template and its items", "description": "Replaces the deco template's name and its full item list. Each item assigns a deco to a start/end time of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.", + "operationId": "DecoTemplateReplace", "parameters": [ { "name": "id", @@ -4216,8 +5439,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/deco-templates/{id}/items": { @@ -4227,6 +5463,7 @@ ], "summary": "Get deco template items", "description": "Returns the deco template's items and a strong ETag of the deco template's version. Pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).", + "operationId": "DecoTemplateGetItems", "parameters": [ { "name": "id", @@ -4287,8 +5524,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/emby": { @@ -4318,8 +5578,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/emby/connection": { @@ -4350,8 +5623,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -4445,8 +5731,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/emby/disconnect": { @@ -4480,8 +5789,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/emby/{id}/libraries": { @@ -4551,8 +5873,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -4666,8 +6011,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/emby/{id}/path-replacements": { @@ -4737,8 +6105,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -4851,8 +6242,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/emby/{id}/refresh-libraries": { @@ -4916,8 +6330,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/emby/{id}/scan-collections": { @@ -4990,8 +6427,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/ffmpeg/profiles": { @@ -5030,8 +6490,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -5144,8 +6617,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/ffmpeg/hardware-acceleration-kinds": { @@ -5185,8 +6671,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/ffmpeg/profiles/{id}": { @@ -5247,8 +6746,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -5372,8 +6894,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ @@ -5455,8 +6990,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/filler-presets": { @@ -5505,8 +7053,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -5619,8 +7190,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/filler-presets/{id}": { @@ -5681,8 +7265,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -5806,8 +7413,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ @@ -5889,8 +7509,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/graphics-elements": { @@ -5939,8 +7572,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/health": { @@ -5979,8 +7635,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/images/folders": { @@ -6031,8 +7700,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/images/folders/{id}/duration": { @@ -6139,8 +7831,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/jellyfin": { @@ -6170,8 +7875,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/jellyfin/connection": { @@ -6202,8 +7920,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -6297,8 +8028,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/jellyfin/disconnect": { @@ -6332,8 +8086,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/jellyfin/{id}/libraries": { @@ -6403,8 +8170,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -6518,8 +8308,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/jellyfin/{id}/path-replacements": { @@ -6589,8 +8402,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -6703,8 +8539,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/jellyfin/{id}/refresh-libraries": { @@ -6768,8 +8627,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/jellyfin/{id}/scan-collections": { @@ -6842,8 +8724,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/languages": { @@ -6882,8 +8787,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/libraries/scan-status": { @@ -6922,8 +8840,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/libraries/{id}/scan": { @@ -6933,6 +8864,7 @@ ], "summary": "Scan library", "description": "Queues a scan of the whole library. Pass ?deep=true for a deep (metadata-refresh) scan.", + "operationId": "LibrariesScanLibrary", "parameters": [ { "name": "id", @@ -7015,8 +8947,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/libraries/{id}/scan-show": { @@ -7025,6 +8980,7 @@ "Libraries" ], "summary": "Scan show", + "operationId": "LibrariesScanShow", "parameters": [ { "name": "id", @@ -7124,8 +9080,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/library/browse": { @@ -7207,8 +9186,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/libraries/local": { @@ -7247,14 +9249,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Libraries" ], "summary": "Create a local library", + "operationId": "LocalLibrariesCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -7320,8 +9336,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/libraries/local/{id}": { @@ -7382,8 +9421,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -7391,6 +9453,7 @@ ], "summary": "Update a local library", "description": "Replaces the library's name and full path list. MediaKind is immutable after create and is not part of this request. Paths are identified by normalized path string, not id — a renamed path is a delete-old + add-new (its id changes).", + "operationId": "LocalLibrariesUpdate", "parameters": [ { "name": "id", @@ -7507,14 +9570,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Libraries" ], "summary": "Delete a local library", + "operationId": "LocalLibrariesDelete", "parameters": [ { "name": "id", @@ -7569,8 +9656,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/libraries/local/paths/{pathId}/move": { @@ -7580,6 +9690,7 @@ ], "summary": "Move a local library path to another local library", "description": "Moves a path (and its scanned media) from its current library to a different local library of the same media kind. Blazor's move dialog enforces same-kind/different-library only client-side; this endpoint enforces both server-side so API/MCP clients cannot bypass them.", + "operationId": "LocalLibrariesMovePath", "parameters": [ { "name": "pathId", @@ -7679,8 +9790,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/libraries/local/path-exists": { @@ -7690,6 +9824,7 @@ ], "summary": "Check whether a filesystem path exists", "description": "Server-side existence check for the SPA's add-path UX (a browser cannot call Directory.Exists). This is a check-then-act convenience only — Create/Update still validate new paths at save time (design #202 §C2).", + "operationId": "LocalLibrariesCheckPathExists", "requestBody": { "content": { "application/json-patch+json": { @@ -7735,8 +9870,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/logs": { @@ -7811,8 +9969,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/maintenance/gc": { @@ -7821,6 +10002,7 @@ "Maintenance" ], "summary": "Garbage collect", + "operationId": "MaintenanceGarbageCollection", "parameters": [ { "name": "force", @@ -7834,8 +10016,31 @@ "responses": { "200": { "description": "OK" + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/maintenance/empty_trash": { @@ -7844,6 +10049,7 @@ "Maintenance" ], "summary": "Empty trash", + "operationId": "MaintenanceEmptyTrash", "responses": { "200": { "description": "OK" @@ -7867,8 +10073,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/maintenance/clean_artwork": { @@ -7877,11 +10096,25 @@ "Maintenance" ], "summary": "Clean artwork cache", + "operationId": "MaintenanceCleanArtwork", "responses": { "202": { "description": "Accepted" + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-items": { @@ -7939,8 +10172,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-items/{id}/info": { @@ -8021,8 +10277,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources": { @@ -8061,8 +10340,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/movies/{id}": { @@ -8123,8 +10415,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/multi-collections": { @@ -8182,14 +10497,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Multi Collections" ], "summary": "Create a multi collection", + "operationId": "MultiCollectionCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -8275,8 +10614,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/multi-collections/{id}": { @@ -8337,14 +10699,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ "Multi Collections" ], "summary": "Update a multi collection", + "operationId": "MultiCollectionUpdate", "parameters": [ { "name": "id", @@ -8481,14 +10867,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Multi Collections" ], "summary": "Delete a multi collection", + "operationId": "MultiCollectionDelete", "parameters": [ { "name": "id", @@ -8543,8 +10943,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playlists/groups": { @@ -8583,8 +11006,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -8657,8 +11093,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playlists/groups/{id}": { @@ -8764,8 +11223,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ @@ -8827,8 +11309,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playlists": { @@ -8878,8 +11383,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -8952,8 +11480,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playlists/{id}": { @@ -9014,8 +11565,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -9169,8 +11743,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ @@ -9232,8 +11819,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playlists/{id}/items": { @@ -9304,8 +11914,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -9392,8 +12025,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playlists/preview": { @@ -9478,8 +12134,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts": { @@ -9537,8 +12216,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -9546,6 +12248,7 @@ ], "summary": "Create a playout", "description": "Creates a playout of any kind (Classic, Block, Sequential, Scripted, or ExternalJson) for a channel. Classic requires ProgramScheduleId; Sequential/Scripted/ExternalJson require ScheduleFile; Block requires neither. A channel may only have one playout.", + "operationId": "PlayoutCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -9631,8 +12334,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/warnings/count": { @@ -9665,8 +12391,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}": { @@ -9727,8 +12466,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -9736,6 +12498,7 @@ ], "summary": "Update playout scheduling details", "description": "DailyRebuildTime is always applied; omit it (null) to clear the daily reset. ScheduleFile is only valid for Sequential, Scripted, and ExternalJson playouts; omit it (null) to leave it unchanged.", + "operationId": "PlayoutUpdate", "parameters": [ { "name": "id", @@ -9852,14 +12615,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Playouts" ], "summary": "Delete a playout", + "operationId": "PlayoutDelete", "parameters": [ { "name": "id", @@ -9934,8 +12721,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}/items": { @@ -10022,8 +12832,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}/deco": { @@ -10033,6 +12866,7 @@ ], "summary": "Set (or clear) a playout's default deco", "description": "Assigns the default deco for a block playout. Send a null decoId to clear it.", + "operationId": "PlayoutUpdateDefaultDeco", "parameters": [ { "name": "id", @@ -10149,8 +12983,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}/alternate-schedules": { @@ -10241,8 +13098,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -10250,6 +13130,7 @@ ], "summary": "Replace a classic playout's alternate schedules", "description": "Replaces a Classic playout's alternate schedules. Items are ordered by priority: the first item is the highest priority and the last item is the catch-all default whose schedule becomes the playout's default schedule. Index is assigned from array order (the request body has no Index field). The list must contain at least one item, and every ProgramScheduleId must exist. Only valid for Classic playouts.", + "operationId": "PlayoutReplaceAlternateSchedules", "parameters": [ { "name": "id", @@ -10415,8 +13296,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}/templates": { @@ -10507,8 +13401,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -10516,6 +13433,7 @@ ], "summary": "Replace a block playout's templates", "description": "Replaces a Block playout's templates. Items are ordered by priority (first = highest priority); Index is assigned from array order (the request body has no Index field). Every TemplateId must exist, and any supplied DecoTemplateId must exist. An empty list clears all templates. Only valid for Block playouts.", + "operationId": "PlayoutReplaceTemplates", "parameters": [ { "name": "id", @@ -10681,8 +13599,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}/blocks": { @@ -10753,8 +13684,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}/blocks/{blockId}/history": { @@ -10843,8 +13797,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/history/{id}": { @@ -10926,8 +13903,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/reset-all": { @@ -10957,8 +13957,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}/erase-items": { @@ -11043,8 +14056,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/{id}/erase-items-and-history": { @@ -11129,8 +14165,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/playouts/items/{id}/scheduling-context": { @@ -11192,8 +14251,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/plex": { @@ -11224,8 +14306,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/plex/pin-flow": { @@ -11296,8 +14391,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/plex/sign-out": { @@ -11331,8 +14439,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/plex/{id}/libraries": { @@ -11402,8 +14523,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -11517,8 +14661,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/plex/{id}/path-replacements": { @@ -11588,8 +14755,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -11703,8 +14893,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/plex/{id}/refresh-libraries": { @@ -11769,8 +14982,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/media-sources/plex/{id}/scan-collections": { @@ -11843,8 +15079,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/rerun-collections": { @@ -11902,14 +15161,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Rerun Collections" ], "summary": "Create a rerun collection", + "operationId": "RerunCollectionCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -11995,8 +15278,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/rerun-collections/{id}": { @@ -12057,14 +15363,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ "Rerun Collections" ], "summary": "Update a rerun collection", + "operationId": "RerunCollectionUpdate", "parameters": [ { "name": "id", @@ -12201,14 +15531,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Rerun Collections" ], "summary": "Delete a rerun collection", + "operationId": "RerunCollectionDelete", "parameters": [ { "name": "id", @@ -12263,15 +15607,39 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/ffmpeg/resolution/by-name/{name}": { "get": { "tags": [ - "Resolution" + "Settings" ], + "summary": "Get a resolution by name", "operationId": "GetResolutionByName", "parameters": [ { @@ -12289,22 +15657,65 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ResolutionViewModel" + "$ref": "#/components/schemas/ResolutionResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ResolutionViewModel" + "$ref": "#/components/schemas/ResolutionResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ResolutionViewModel" + "$ref": "#/components/schemas/ResolutionResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" } } } } - } + }, + "security": [ + { } + ] } }, "/api/settings/resolutions": { @@ -12343,8 +15754,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -12437,8 +15861,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/settings/resolutions/{id}": { @@ -12522,8 +15959,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/schedules": { @@ -12532,6 +15982,7 @@ "Schedules" ], "summary": "Get all schedules", + "operationId": "ScheduleGetAll", "responses": { "200": { "description": "OK", @@ -12540,7 +15991,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } } }, @@ -12548,7 +15999,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } } }, @@ -12556,19 +16007,33 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Schedules" ], "summary": "Create a schedule", + "operationId": "ScheduleCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -12600,17 +16065,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } } } @@ -12654,8 +16119,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/schedules/{id}": { @@ -12682,17 +16170,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } } } @@ -12716,14 +16204,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ "Schedules" ], "summary": "Update a schedule", + "operationId": "ScheduleUpdate", "parameters": [ { "name": "id", @@ -12766,17 +16278,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleViewModel" + "$ref": "#/components/schemas/ProgramScheduleResponseModel" } } } @@ -12820,14 +16332,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Schedules" ], "summary": "Delete a schedule", + "operationId": "ScheduleDelete", "parameters": [ { "name": "id", @@ -12882,8 +16418,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/schedules/{id}/items": { @@ -12893,6 +16452,7 @@ ], "summary": "Get schedule items", "description": "Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are derived from referenced collection/media runtimes and are null when unbounded or unknown. The response also carries a strong ETag of the schedule's version; pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).", + "operationId": "ScheduleGetItems", "parameters": [ { "name": "id", @@ -12944,14 +16504,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Schedules" ], "summary": "Add a schedule item", + "operationId": "ScheduleAddItem", "parameters": [ { "name": "id", @@ -13048,8 +16632,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -13057,6 +16664,7 @@ ], "summary": "Replace schedule items", "description": "Replaces the schedule's full item list; item indexes are assigned from the array order. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.", + "operationId": "ScheduleReplaceItems", "parameters": [ { "name": "id", @@ -13202,8 +16810,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/schedules/{id}/items/{itemId}": { @@ -13212,6 +16833,7 @@ "Schedules" ], "summary": "Delete a schedule item", + "operationId": "ScheduleDeleteItem", "parameters": [ { "name": "id", @@ -13275,8 +16897,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/search": { @@ -13295,6 +16940,15 @@ "default": "" } }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, { "name": "pageSize", "in": "query", @@ -13345,8 +16999,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/search/all-items": { @@ -13407,8 +17084,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/search/collections": { @@ -13458,8 +17158,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/search/television-shows": { @@ -13509,8 +17232,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/search/television-seasons": { @@ -13560,8 +17306,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/search/smart-collections": { @@ -13611,8 +17380,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/search/artists": { @@ -13662,8 +17454,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/search/multi-collections": { @@ -13713,8 +17528,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/seasons/{id}": { @@ -13775,8 +17613,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/sessions": { @@ -13785,6 +17646,7 @@ "Sessions" ], "summary": "Get sessions", + "operationId": "SessionGetSessions", "responses": { "200": { "description": "OK", @@ -13814,8 +17676,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/session/{channelNumber}": { @@ -13824,6 +17699,7 @@ "Sessions" ], "summary": "Stop session", + "operationId": "SessionStopSession", "parameters": [ { "name": "channelNumber", @@ -13837,8 +17713,31 @@ "responses": { "200": { "description": "OK" + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/settings/ffmpeg": { @@ -13868,8 +17767,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -13962,8 +17874,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/settings/playout": { @@ -13993,8 +17918,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -14087,8 +18025,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/settings/xmltv": { @@ -14118,8 +18069,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -14212,8 +18176,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/settings/scanner": { @@ -14243,8 +18220,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -14337,8 +18327,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/settings/logging": { @@ -14368,8 +18371,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -14462,8 +18478,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/settings/ui": { @@ -14493,8 +18522,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -14587,8 +18629,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/settings/hdhr": { @@ -14618,8 +18673,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -14712,8 +18780,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/shows/{id}": { @@ -14774,8 +18855,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/smart-collections": { @@ -14784,6 +18888,7 @@ "Smart Collections" ], "summary": "Get all smart collections", + "operationId": "SmartCollectionGetAll", "responses": { "200": { "description": "OK", @@ -14813,14 +18918,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Smart Collections" ], "summary": "Create a smart collection", + "operationId": "SmartCollectionCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -14852,17 +18971,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } } } @@ -14906,8 +19025,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/smart-collections/{id}": { @@ -14934,17 +19076,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } } } @@ -14968,14 +19110,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ "Smart Collections" ], "summary": "Update a smart collection", + "operationId": "SmartCollectionUpdate", "parameters": [ { "name": "id", @@ -15018,17 +19184,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/SmartCollectionViewModel" + "$ref": "#/components/schemas/SmartCollectionResponseModel" } } } @@ -15072,14 +19238,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Smart Collections" ], "summary": "Delete a smart collection", + "operationId": "SmartCollectionDelete", "parameters": [ { "name": "id", @@ -15134,8 +19324,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/templates/groups": { @@ -15174,14 +19387,28 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ "Templates" ], "summary": "Create a template group", + "operationId": "TemplateCreateGroup", "requestBody": { "content": { "application/json-patch+json": { @@ -15267,8 +19494,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/templates/groups/{id}": { @@ -15278,6 +19528,7 @@ ], "summary": "Delete a template group", "description": "Deletes the template group. The database cascade removes every template (and its items) in the group.", + "operationId": "TemplateDeleteGroup", "parameters": [ { "name": "id", @@ -15312,8 +19563,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/templates": { @@ -15323,6 +19597,7 @@ ], "summary": "Get all templates", "description": "Returns every template. Pass templateGroupId to filter to the templates in a single template group.", + "operationId": "TemplateGetAll", "parameters": [ { "name": "templateGroupId", @@ -15362,8 +19637,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -15371,6 +19669,7 @@ ], "summary": "Create a template", "description": "Creates an empty template in the given template group.", + "operationId": "TemplateCreate", "requestBody": { "content": { "application/json-patch+json": { @@ -15456,8 +19755,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/templates/{id}": { @@ -15518,14 +19840,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Templates" ], "summary": "Delete a template", + "operationId": "TemplateDelete", "parameters": [ { "name": "id", @@ -15560,8 +19906,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -15569,6 +19938,7 @@ ], "summary": "Replace a template and its items", "description": "Replaces the template's name and its full item list. Each item assigns a block to a start time of day; items must not overlap (an item's end time is its start time plus the assigned block's duration). Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.", + "operationId": "TemplateReplace", "parameters": [ { "name": "id", @@ -15705,8 +20075,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/templates/{id}/items": { @@ -15716,6 +20099,7 @@ ], "summary": "Get template items", "description": "Returns the template's items and a strong ETag of the template's version. Pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).", + "operationId": "TemplateGetItems", "parameters": [ { "name": "id", @@ -15776,8 +20160,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/templates/{id}/copy": { @@ -15787,6 +20194,7 @@ ], "summary": "Copy a template", "description": "Copies the template and its items into another (or the same) template group under a new name.", + "operationId": "TemplateCopy", "parameters": [ { "name": "id", @@ -15883,8 +20291,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/trakt/lists": { @@ -15934,8 +20365,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -15943,6 +20397,7 @@ ], "summary": "Add a Trakt list by URL", "description": "Dispatches to the same background worker channel used by the classic UI's \"Add Trakt List\" dialog; the list is fetched, saved, and matched asynchronously. Poll GET /api/trakt/status while busy.", + "operationId": "TraktAdd", "requestBody": { "content": { "application/json-patch+json": { @@ -16011,8 +20466,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/trakt/lists/{id}": { @@ -16073,14 +20551,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ "Trakt" ], "summary": "Delete a Trakt list", + "operationId": "TraktDelete", "parameters": [ { "name": "id", @@ -16135,14 +20637,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ "Trakt" ], "summary": "Update a Trakt list's settings", + "operationId": "TraktUpdate", "parameters": [ { "name": "id", @@ -16239,8 +20765,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/trakt/lists/{id}/match": { @@ -16249,6 +20798,7 @@ "Trakt" ], "summary": "Match a Trakt list's items", + "operationId": "TraktMatch", "parameters": [ { "name": "id", @@ -16303,8 +20853,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/trakt/status": { @@ -16335,8 +20908,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/troubleshoot/info": { @@ -16366,8 +20952,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/troubleshoot/validate-schedule": { @@ -16443,8 +21042,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/troubleshoot/playback.m3u8": { @@ -16453,6 +21065,7 @@ "Troubleshooting" ], "summary": "Start a troubleshooting playback session", + "operationId": "TroubleshootTroubleshootPlaybackHead", "parameters": [ { "name": "mediaItem", @@ -16599,14 +21212,38 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "get": { "tags": [ "Troubleshooting" ], "summary": "Start a troubleshooting playback session", + "operationId": "TroubleshootTroubleshootPlaybackGet", "parameters": [ { "name": "mediaItem", @@ -16753,8 +21390,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/troubleshoot/playback/archive": { @@ -16763,22 +21423,50 @@ "Troubleshooting" ], "summary": "Download the last troubleshooting playback session archive", + "operationId": "TroubleshootTroubleshootPlaybackArchiveHead", "responses": { "200": { "description": "OK" + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "get": { "tags": [ "Troubleshooting" ], "summary": "Download the last troubleshooting playback session archive", + "operationId": "TroubleshootTroubleshootPlaybackArchiveGet", "responses": { "200": { "description": "OK" + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/troubleshoot/playback/sample/{mediaItemId}": { @@ -16787,6 +21475,7 @@ "Troubleshooting" ], "summary": "Download a media sample archive for troubleshooting", + "operationId": "TroubleshootTroubleshootPlaybackSampleHead", "parameters": [ { "name": "mediaItemId", @@ -16801,14 +21490,38 @@ "responses": { "200": { "description": "OK" + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "get": { "tags": [ "Troubleshooting" ], "summary": "Download a media sample archive for troubleshooting", + "operationId": "TroubleshootTroubleshootPlaybackSampleGet", "parameters": [ { "name": "mediaItemId", @@ -16823,8 +21536,31 @@ "responses": { "200": { "description": "OK" + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/troubleshoot/playback/stream-selectors": { @@ -16863,8 +21599,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/troubleshoot/playback/subtitles/{mediaItemId}": { @@ -16935,8 +21684,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/troubleshoot/playback/status": { @@ -16967,8 +21739,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/version": { @@ -16998,8 +21783,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/watermarks": { @@ -17038,8 +21836,21 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "post": { "tags": [ @@ -17152,8 +21963,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } }, "/api/watermarks/{id}": { @@ -17214,8 +22038,31 @@ } } } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "put": { "tags": [ @@ -17339,8 +22186,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] }, "delete": { "tags": [ @@ -17422,8 +22282,21 @@ } } } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } } - } + }, + "security": [ + { } + ] } } }, @@ -18213,6 +23086,152 @@ } } }, + "ChannelDetailResponseModel": { + "required": [ + "id", + "number", + "name", + "group", + "categories", + "ffmpegProfileId", + "slugSeconds", + "logo", + "streamSelectorMode", + "streamSelector", + "preferredAudioLanguageCode", + "preferredAudioTitle", + "playoutSource", + "playoutMode", + "mirrorSourceChannelId", + "playoutOffset", + "streamingMode", + "watermarkId", + "fallbackFillerId", + "playoutCount", + "preferredSubtitleLanguageCode", + "subtitleMode", + "musicVideoCreditsMode", + "musicVideoCreditsTemplate", + "songVideoMode", + "transcodeMode", + "idleBehavior", + "isEnabled", + "showInEpg" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "number": { + "type": "string" + }, + "name": { + "type": "string" + }, + "group": { + "type": "string" + }, + "categories": { + "type": "string" + }, + "ffmpegProfileId": { + "type": "integer", + "format": "int32" + }, + "slugSeconds": { + "type": [ + "null", + "number" + ], + "format": "double" + }, + "logo": { + "$ref": "#/components/schemas/ChannelLogoResponseModel" + }, + "streamSelectorMode": { + "$ref": "#/components/schemas/ChannelStreamSelectorMode" + }, + "streamSelector": { + "type": "string" + }, + "preferredAudioLanguageCode": { + "type": "string" + }, + "preferredAudioTitle": { + "type": "string" + }, + "playoutSource": { + "$ref": "#/components/schemas/ChannelPlayoutSource" + }, + "playoutMode": { + "$ref": "#/components/schemas/ChannelPlayoutMode" + }, + "mirrorSourceChannelId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playoutOffset": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "streamingMode": { + "$ref": "#/components/schemas/StreamingMode" + }, + "watermarkId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "fallbackFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playoutCount": { + "type": "integer", + "format": "int32" + }, + "preferredSubtitleLanguageCode": { + "type": "string" + }, + "subtitleMode": { + "$ref": "#/components/schemas/ChannelSubtitleMode" + }, + "musicVideoCreditsMode": { + "$ref": "#/components/schemas/ChannelMusicVideoCreditsMode" + }, + "musicVideoCreditsTemplate": { + "type": "string" + }, + "songVideoMode": { + "$ref": "#/components/schemas/ChannelSongVideoMode" + }, + "transcodeMode": { + "$ref": "#/components/schemas/ChannelTranscodeMode" + }, + "idleBehavior": { + "$ref": "#/components/schemas/ChannelIdleBehavior" + }, + "isEnabled": { + "type": "boolean" + }, + "showInEpg": { + "type": "boolean" + } + } + }, "ChannelGuideChannelResponseModel": { "required": [ "number", @@ -18305,6 +23324,33 @@ ], "type": "string" }, + "ChannelLogoResponseModel": { + "required": [ + "path", + "contentType" + ], + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "isExternalUrl": { + "type": "boolean" + }, + "hasContentType": { + "type": "boolean" + }, + "urlWithContentType": { + "type": [ + "null", + "string" + ] + } + } + }, "ChannelMusicVideoCreditsMode": { "enum": [ "None", @@ -18617,185 +23663,6 @@ ], "type": "string" }, - "ChannelViewModel": { - "required": [ - "id", - "number", - "name", - "group", - "categories", - "ffmpegProfileId", - "slugSeconds", - "logo", - "streamSelectorMode", - "streamSelector", - "preferredAudioLanguageCode", - "preferredAudioTitle", - "playoutSource", - "playoutMode", - "mirrorSourceChannelId", - "playoutOffset", - "streamingMode", - "watermarkId", - "fallbackFillerId", - "playoutCount", - "preferredSubtitleLanguageCode", - "subtitleMode", - "musicVideoCreditsMode", - "musicVideoCreditsTemplate", - "songVideoMode", - "transcodeMode", - "idleBehavior", - "isEnabled", - "showInEpg" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "number": { - "type": [ - "null", - "string" - ] - }, - "name": { - "type": [ - "null", - "string" - ] - }, - "group": { - "type": [ - "null", - "string" - ] - }, - "categories": { - "type": [ - "null", - "string" - ] - }, - "ffmpegProfileId": { - "type": "integer", - "format": "int32" - }, - "slugSeconds": { - "type": [ - "null", - "number" - ], - "format": "double" - }, - "logo": { - "$ref": "#/components/schemas/ArtworkContentTypeModel" - }, - "streamSelectorMode": { - "$ref": "#/components/schemas/ChannelStreamSelectorMode" - }, - "streamSelector": { - "type": [ - "null", - "string" - ] - }, - "preferredAudioLanguageCode": { - "type": [ - "null", - "string" - ] - }, - "preferredAudioTitle": { - "type": [ - "null", - "string" - ] - }, - "playoutSource": { - "$ref": "#/components/schemas/ChannelPlayoutSource" - }, - "playoutMode": { - "$ref": "#/components/schemas/ChannelPlayoutMode" - }, - "mirrorSourceChannelId": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "playoutOffset": { - "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", - "type": [ - "null", - "string" - ] - }, - "streamingMode": { - "$ref": "#/components/schemas/StreamingMode" - }, - "watermarkId": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "fallbackFillerId": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "playoutCount": { - "type": "integer", - "format": "int32" - }, - "preferredSubtitleLanguageCode": { - "type": [ - "null", - "string" - ] - }, - "subtitleMode": { - "$ref": "#/components/schemas/ChannelSubtitleMode" - }, - "musicVideoCreditsMode": { - "$ref": "#/components/schemas/ChannelMusicVideoCreditsMode" - }, - "musicVideoCreditsTemplate": { - "type": [ - "null", - "string" - ] - }, - "songVideoMode": { - "$ref": "#/components/schemas/ChannelSongVideoMode" - }, - "transcodeMode": { - "$ref": "#/components/schemas/ChannelTranscodeMode" - }, - "idleBehavior": { - "$ref": "#/components/schemas/ChannelIdleBehavior" - }, - "isEnabled": { - "type": "boolean" - }, - "showInEpg": { - "type": "boolean" - }, - "webEncodedName": { - "type": [ - "null", - "string" - ] - } - } - }, "ChannelWatermarkImageSource": { "enum": [ "Custom", @@ -20263,7 +25130,16 @@ } }, "DayOfWeek": { - "type": "integer" + "enum": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "type": "string" }, "DecoBreakContentRequest": { "required": [ @@ -21187,10 +26063,7 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" }, "fillerKind": { "$ref": "#/components/schemas/FillerKind" @@ -21231,10 +26104,7 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" } } }, @@ -21382,16 +26252,10 @@ "type": "object", "properties": { "code": { - "type": [ - "null", - "string" - ] + "type": "string" }, "englishName": { - "type": [ - "null", - "string" - ] + "type": "string" } } }, @@ -21587,10 +26451,7 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" }, "mediaKind": { "$ref": "#/components/schemas/LibraryMediaKind" @@ -21603,10 +26464,7 @@ "format": "int32" }, "paths": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "$ref": "#/components/schemas/LocalLibraryPathResponseModel" } @@ -21626,10 +26484,7 @@ "format": "int32" }, "path": { - "type": [ - "null", - "string" - ] + "type": "string" }, "mediaItemCount": { "type": "integer", @@ -21651,10 +26506,7 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" }, "mediaKind": { "$ref": "#/components/schemas/LibraryMediaKind" @@ -21762,70 +26614,27 @@ ], "type": "string" }, - "MediaCollectionViewModel": { + "MediaCollectionResponseModel": { "required": [ - "collectionType", "id", "name", - "useCustomPlaybackOrder", - "state" + "collectionType", + "useCustomPlaybackOrder" ], "type": "object", "properties": { - "collectionType": { - "$ref": "#/components/schemas/CollectionType" - }, "id": { "type": "integer", "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" }, "useCustomPlaybackOrder": { "type": "boolean" - }, - "version": { - "type": "integer", - "format": "int32", - "default": 0 - }, - "mediaItemId": { - "type": "integer", - "format": "int32" - }, - "title": { - "type": [ - "null", - "string" - ] - }, - "subtitle": { - "type": [ - "null", - "string" - ] - }, - "sortTitle": { - "type": [ - "null", - "string" - ] - }, - "poster": { - "type": [ - "null", - "string" - ] - }, - "state": { - "$ref": "#/components/schemas/MediaItemState" - }, - "hasMediaInfo": { - "type": "boolean" } } }, @@ -22423,10 +27232,7 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" } } }, @@ -22537,10 +27343,7 @@ "format": "int32" }, "page": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "$ref": "#/components/schemas/PlayoutItemResponseModel" } @@ -22559,10 +27362,7 @@ "format": "int32" }, "page": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "$ref": "#/components/schemas/PlayoutListItemResponseModel" } @@ -23113,10 +27913,7 @@ "type": "boolean" }, "message": { - "type": [ - "null", - "string" - ] + "type": "string" } } }, @@ -23628,10 +28425,7 @@ "type": "object", "properties": { "authUrl": { - "type": [ - "null", - "string" - ] + "type": "string" } } }, @@ -23671,7 +28465,7 @@ } } }, - "ProgramScheduleViewModel": { + "ProgramScheduleResponseModel": { "required": [ "id", "name", @@ -23679,8 +28473,7 @@ "treatCollectionsAsShows", "shuffleScheduleItems", "randomStartPoint", - "fixedStartTimeBehavior", - "version" + "fixedStartTimeBehavior" ], "type": "object", "properties": { @@ -23689,10 +28482,7 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" }, "keepMultiPartEpisodesTogether": { "type": "boolean" @@ -23708,10 +28498,6 @@ }, "fixedStartTimeBehavior": { "$ref": "#/components/schemas/FixedStartTimeBehavior" - }, - "version": { - "type": "integer", - "format": "int32" } } }, @@ -23723,10 +28509,7 @@ "type": "object", "properties": { "address": { - "type": [ - "null", - "string" - ] + "type": "string" }, "hasApiKey": { "type": "boolean" @@ -23817,10 +28600,7 @@ "type": "boolean" }, "servers": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "$ref": "#/components/schemas/RemoteMediaSourceItemResponseModel" } @@ -24271,39 +29051,6 @@ } } }, - "ResolutionViewModel": { - "required": [ - "id", - "name", - "width", - "height", - "isCustom" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": [ - "null", - "string" - ] - }, - "width": { - "type": "integer", - "format": "int32" - }, - "height": { - "type": "integer", - "format": "int32" - }, - "isCustom": { - "type": "boolean" - } - } - }, "SaveRemoteConnectionRequest": { "required": [ "address", @@ -25083,100 +29830,70 @@ "type": "object", "properties": { "movieIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "showIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "seasonIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "episodeIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "artistIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "musicVideoIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "otherVideoIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "songIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "imageIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" } }, "remoteStreamIds": { - "type": [ - "null", - "array" - ], + "type": "array", "items": { "type": "integer", "format": "int32" @@ -25399,42 +30116,10 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" }, "query": { - "type": [ - "null", - "string" - ] - } - } - }, - "SmartCollectionViewModel": { - "required": [ - "id", - "name", - "query" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": [ - "null", - "string" - ] - }, - "query": { - "type": [ - "null", - "string" - ] + "type": "string" } } }, @@ -26955,6 +31640,51 @@ } } }, + "ValidationProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "status": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "instance": { + "type": [ + "null", + "string" + ] + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, "VideoScanKind": { "enum": [ "Unknown", @@ -27081,10 +31811,7 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" } } }, @@ -27129,6 +31856,14 @@ ], "type": "string" } + }, + "securitySchemes": { + "ApiKey": { + "type": "apiKey", + "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.", + "name": "X-Api-Key", + "in": "header" + } } }, "tags": [ @@ -27216,9 +31951,6 @@ { "name": "Rerun Collections" }, - { - "name": "Resolution" - }, { "name": "Settings" }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 705964b6f..7ee695f02 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -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 `disable` — 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 `disable` + (`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 = Omit & { 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` 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. diff --git a/docs/decisions.md b/docs/decisions.md index 8b5d72584..d79dce14a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index dd7a82884..a21d04d56 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -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 | diff --git a/docs/rest-api.md b/docs/rest-api.md index 972340ff8..74f53d23a 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -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 | diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index c5d52b106..a727628d1 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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' }) ); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index c814eb19d..e3302c1d8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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) ); }; diff --git a/web/src/api/channels.ts b/web/src/api/channels.ts index 6ecb4cfb4..3eb445cc1 100644 --- a/web/src/api/channels.ts +++ b/web/src/api/channels.ts @@ -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']; diff --git a/web/src/api/collections.ts b/web/src/api/collections.ts index ca3b0ee71..56603523f 100644 --- a/web/src/api/collections.ts +++ b/web/src/api/collections.ts @@ -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']; diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index d51908279..e43b190d1 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -134,6 +134,37 @@ export interface components { }; "BulkRenumberChannelsRequest": { "channels": Array; + }; + "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; }; "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; + "paths": Array; }; "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; + "page": Array; }; "PagedPlayoutsResponseModel": { "totalCount": number; - "page": null | Array; + "page": Array; }; "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; + "servers": Array; }; "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; - "showIds": null | Array; - "seasonIds": null | Array; - "episodeIds": null | Array; - "artistIds": null | Array; - "musicVideoIds": null | Array; - "otherVideoIds": null | Array; - "songIds": null | Array; - "imageIds": null | Array; - "remoteStreamIds": null | Array; + "movieIds": Array; + "showIds": Array; + "seasonIds": Array; + "episodeIds": Array; + "artistIds": Array; + "musicVideoIds": Array; + "otherVideoIds": Array; + "songIds": Array; + "imageIds": Array; + "remoteStreamIds": Array; }; "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; "json": string; + }; + "ValidationProblemDetails": { + "type"?: null | string; + "title"?: null | string; + "status"?: null | number; + "detail"?: null | string; + "instance"?: null | string; + "errors"?: { + [key: string]: Array; + }; }; "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"; diff --git a/web/src/api/playouts.test.ts b/web/src/api/playouts.test.ts index 164b8c0fb..da608608f 100644 --- a/web/src/api/playouts.test.ts +++ b/web/src/api/playouts.test.ts @@ -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()); diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts index 5d5e08aab..1c23f8373 100644 --- a/web/src/api/playouts.ts +++ b/web/src/api/playouts.ts @@ -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 = Omit & { daysOfWeek: DayOfWeek[] }; - -export type PlayoutAlternateSchedule = WithDayNames; -export type PlayoutAlternateScheduleItemRequest = WithDayNames< - components['schemas']['PlayoutAlternateScheduleItemRequest'] ->; -export type PlayoutTemplate = WithDayNames; -export type PlayoutTemplateItemRequest = WithDayNames; +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` // RequestBody bound, matching the generated request DTOs. @@ -123,8 +120,9 @@ export function deletePlayout(playoutId: number): Promise { // 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 { - return request(`/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 { + return request(`/api/channels/${channelId}/playout/reset`, { method: 'POST' }); } export function erasePlayoutItems(playoutId: number): Promise { diff --git a/web/src/api/schedules.ts b/web/src/api/schedules.ts index aa85d14ce..7736f3370 100644 --- a/web/src/api/schedules.ts +++ b/web/src/api/schedules.ts @@ -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']; diff --git a/web/src/api/search.test.ts b/web/src/api/search.test.ts index ceed1af3d..65362a781 100644 --- a/web/src/api/search.test.ts +++ b/web/src/api/search.test.ts @@ -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: [],