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/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..301e2ba6e 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); 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..5cc37b5de 100644 --- a/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs +++ b/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Api.MediaSources; diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs index 3e18cc656..ad211e7ba 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); 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.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/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/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); }