feat(api): #288 wrap raw VMs, nullable-honest DTOs, search pageNum

24 #nullable enable flips across ErsatzTV.Core/Api; Collection/Schedule/
SmartCollection/Resolution VMs wrapped in ResponseModels (Version now
header-only, SPA-verified); pageNum threaded into GET /api/search.

Refs #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 02:08:12 +02:00
co-authored by Claude Opus 4.8
parent 9e2d160884
commit fecf16d3d2
38 changed files with 189 additions and 45 deletions
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search;
public record GetSearchResults(string Query, int PageSize) : IRequest<SearchResultsResponseModel>;
public record GetSearchResults(string Query, int PageNum, int PageSize) : IRequest<SearchResultsResponseModel>;
@@ -19,7 +19,7 @@ public class GetSearchResultsHandler(IMediator mediator)
async Task<SearchResultGroupResponseModel> ForKind(LibraryBrowseMediaType kind)
{
PagedLibraryBrowseItemsResponseModel paged = await mediator.Send(
new GetLibraryBrowseItems(request.Query, null, kind, 0, request.PageSize),
new GetLibraryBrowseItems(request.Query, null, kind, request.PageNum, request.PageSize),
cancellationToken);
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
}
@@ -1,4 +1,5 @@
namespace ErsatzTV.Core.Api.FFmpegProfiles;
#nullable enable
namespace ErsatzTV.Core.Api.FFmpegProfiles;
public record FFmpegProfileResponseModel(
int Id,
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Filler;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Graphics;
public record GraphicsElementResponseModel(int Id, string Name);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Libraries;
public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
@@ -0,0 +1,14 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaCollections;
// API response DTO for a manual collection. Exposes only the collection's own consumer-facing
// fields; deliberately drops the Application VM's MediaCardViewModel scaffolding (Title/Subtitle/
// SortTitle/Poster/HasMediaInfo) and the header-only optimistic-concurrency token (issue #253,
// #288). The ETag carries Version; the SPA reads it from the response header, never the body.
public record MediaCollectionResponseModel(
int Id,
string Name,
CollectionType CollectionType,
bool UseCustomPlaybackOrder);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaItems;
public record LanguageCodeResponseModel(string Code, string EnglishName);
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record LocalLibraryPathResponseModel(int Id, string Path, int MediaItemCount);
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record LocalPathCheckResponseModel(bool Exists);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record PathReplacementResponseModel(int Id, string RemotePath, string LocalPath);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record PlexPinFlowResponseModel(string AuthUrl);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
// NEVER include the raw API key in this DTO — the secure connection contract (design #202 finding 1)
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record RemoteMediaSourceItemResponseModel(int Id, string Name, string Address);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record RemoteMediaSourceStateResponseModel(
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api;
/// <summary>
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutItemsResponseModel(
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutsResponseModel(
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutBuildStatusResponseModel(
@@ -0,0 +1,16 @@
#nullable enable
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
// API response DTO for a program schedule. Mirrors the Application ProgramScheduleViewModel's
// config fields but omits the header-only optimistic-concurrency token (issue #253, #288): the
// schedule-items GET/PUT carry Version as an ETag header, so the SPA never reads it from the body.
public record ProgramScheduleResponseModel(
int Id,
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
public record SearchResultAllItemsResponseModel(
@@ -1,3 +1,4 @@
#nullable enable
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
@@ -1,3 +1,4 @@
#nullable enable
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.SmartCollections;
public record SmartCollectionResponseModel(int Id, string Name, string Query);
@@ -1,3 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkResponseModel(int Id, string Name);
@@ -4,6 +4,7 @@ using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
@@ -124,7 +125,7 @@ public class CollectionControllerTests
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/collections/5");
created.Value.ShouldBe(vm);
created.Value.ShouldBe(new MediaCollectionResponseModel(5, "Movies", CollectionType.Collection, false));
}
[Test]
@@ -165,7 +166,8 @@ public class CollectionControllerTests
new UpdateCollectionRequest("Updated", true),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBe(new MediaCollectionResponseModel(7, "Updated", CollectionType.Collection, false));
await _mediator.Received(1).Send(
Arg.Is<UpdateCollection>(c =>
c.CollectionId == 7 &&
@@ -274,9 +276,13 @@ public class CollectionControllerTests
_mediator.Send(Arg.Any<GetAllCollections>(), Arg.Any<CancellationToken>())
.Returns(vms);
List<MediaCollectionViewModel> result = await _controller.GetAll(CancellationToken.None);
List<MediaCollectionResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(vms);
result.ShouldBe(
[
new MediaCollectionResponseModel(1, "Movies", CollectionType.Collection, false),
new MediaCollectionResponseModel(2, "Shows", CollectionType.Collection, false)
]);
}
[Test]
@@ -288,7 +294,8 @@ public class CollectionControllerTests
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBe(new MediaCollectionResponseModel(4, "Movies", CollectionType.Collection, false));
}
[Test]
@@ -47,6 +47,31 @@ public class ResolutionControllerTests
]);
}
[Test]
public async Task GetResolutionByName_Should_Return_ResponseModel_Not_ViewModel()
{
_mediator.Send(Arg.Any<GetResolutionByName>(), Arg.Any<CancellationToken>())
.Returns(Option<ResolutionViewModel>.Some(new ResolutionViewModel(1, "1920x1080", 1920, 1080, false)));
ActionResult<ResolutionResponseModel> result =
await _controller.GetResolutionByName("1920x1080", CancellationToken.None);
var ok = result.Result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBe(new ResolutionResponseModel(1, "1920x1080", 1920, 1080, false));
}
[Test]
public async Task GetResolutionByName_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetResolutionByName>(), Arg.Any<CancellationToken>())
.Returns(Option<ResolutionViewModel>.None);
ActionResult<ResolutionResponseModel> result =
await _controller.GetResolutionByName("nope", CancellationToken.None);
result.Result.ShouldBeOfType<NotFoundResult>();
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
@@ -79,7 +79,8 @@ public class ScheduleControllerTests
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/schedules/5");
created.Value.ShouldBe(vm);
created.Value.ShouldBe(
new ProgramScheduleResponseModel(5, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible));
}
[Test]
@@ -128,7 +129,8 @@ public class ScheduleControllerTests
IActionResult result = await _controller.Update(7, MakeUpdateScheduleRequest("Updated"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
new ProgramScheduleResponseModel(7, "Updated", true, true, false, false, FixedStartTimeBehavior.Flexible));
await _mediator.Received(1).Send(
Arg.Is<UpdateProgramSchedule>(c => c.ProgramScheduleId == 7 && c.Name == "Updated"),
Arg.Any<CancellationToken>());
@@ -179,7 +181,8 @@ public class ScheduleControllerTests
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
new ProgramScheduleResponseModel(4, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible));
}
[Test]
@@ -42,7 +42,7 @@ public class SearchControllerTests
[Test]
public async Task Search_Should_Return_422_For_Empty_Query()
{
IActionResult result = await _controller.Search(" ", 50, CancellationToken.None);
IActionResult result = await _controller.Search(" ", 0, 50, CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.StatusCode.ShouldBe(422);
@@ -55,7 +55,7 @@ public class SearchControllerTests
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>())
.Returns(EmptyResults());
IActionResult result = await _controller.Search("star", 500, CancellationToken.None);
IActionResult result = await _controller.Search("star", 0, 500, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
@@ -63,13 +63,31 @@ public class SearchControllerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task Search_Should_Thread_PageNum_And_Clamp_Negative()
{
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>())
.Returns(EmptyResults());
// A valid page number is threaded through; a negative one clamps to 0.
await _controller.Search("star", 3, 50, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageNum == 3 && q.PageSize == 50),
Arg.Any<CancellationToken>());
await _controller.Search("star", -7, 50, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageNum == 0 && q.PageSize == 50),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Search_Should_Return_Grouped_Results()
{
SearchResultsResponseModel results = EmptyResults();
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>()).Returns(results);
IActionResult result = await _controller.Search("star", 50, CancellationToken.None);
IActionResult result = await _controller.Search("star", 0, 50, CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBe(results);
@@ -54,7 +54,7 @@ public class SmartCollectionControllerTests
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/smart-collections/7");
created.Value.ShouldBe(vm);
created.Value.ShouldBe(new SmartCollectionResponseModel(7, "Kids", "tag:family"));
}
[Test]
@@ -98,7 +98,8 @@ public class SmartCollectionControllerTests
new UpdateSmartCollectionRequest("Updated", "tag:updated"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBe(new SmartCollectionResponseModel(8, "Updated", "tag:updated"));
await _mediator.Received(1).Send(
Arg.Is<UpdateSmartCollection>(c =>
c.Id == 8 && c.Name == "Updated" && c.Query == "tag:updated"),
@@ -166,7 +167,8 @@ public class SmartCollectionControllerTests
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBe(new SmartCollectionResponseModel(4, "Kids", "tag:kids"));
}
[Test]
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -17,20 +18,23 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Get all collections")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<MediaCollectionViewModel>), StatusCodes.Status200OK)]
public async Task<List<MediaCollectionViewModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllCollections(), cancellationToken);
[ProducesResponseType(typeof(List<MediaCollectionResponseModel>), StatusCodes.Status200OK)]
public async Task<List<MediaCollectionResponseModel>> GetAll(CancellationToken cancellationToken)
{
List<MediaCollectionViewModel> collections = await mediator.Send(new GetAllCollections(), cancellationToken);
return collections.Map(ProjectToResponseModel).ToList();
}
[HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")]
[Tags("Collections")]
[EndpointSummary("Get a collection by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MediaCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> result = await mediator.Send(new GetCollectionById(id), cancellationToken);
return result.ToGetResult();
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")]
@@ -68,7 +72,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
[Tags("Collections")]
[EndpointSummary("Create a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(MediaCollectionResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
@@ -77,14 +81,14 @@ public class CollectionController(IMediator mediator) : ControllerBase
{
Either<BaseError, MediaCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", vm => vm);
return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", ProjectToResponseModel);
}
[HttpPut("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Update a collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MediaCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
@@ -100,7 +104,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
Option<MediaCollectionViewModel> collection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
return collection.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -196,4 +200,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
cancellationToken);
return result.ToDeletedResult();
}
private static MediaCollectionResponseModel ProjectToResponseModel(MediaCollectionViewModel vm) =>
new(vm.Id, vm.Name, vm.CollectionType, vm.UseCustomPlaybackOrder);
}
@@ -14,11 +14,19 @@ namespace ErsatzTV.Controllers.Api;
[EndpointGroupName("general")]
public class ResolutionController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/ffmpeg/resolution/by-name/{name}", Name="GetResolutionByName")]
public async Task<ActionResult<ResolutionViewModel>> GetResolutionByName(string name, CancellationToken cancellationToken)
[HttpGet("/api/ffmpeg/resolution/by-name/{name}", Name = "GetResolutionByName")]
[Tags("Settings")]
[EndpointSummary("Get a resolution by name")]
[ProducesResponseType(typeof(ResolutionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResolutionResponseModel>> GetResolutionByName(
string name,
CancellationToken cancellationToken)
{
Option<ResolutionViewModel> result = await mediator.Send(new GetResolutionByName(name), cancellationToken);
return result.Match<ActionResult<ResolutionViewModel>>(i => Ok(i), () => NotFound());
return result.Match<ActionResult<ResolutionResponseModel>>(
i => Ok(ProjectToResponseModel(i)),
() => NotFound());
}
[HttpGet("/api/settings/resolutions", Name = "GetResolutions")]
+24 -9
View File
@@ -18,27 +18,30 @@ public class ScheduleController(IMediator mediator) : ControllerBase
[Tags("Schedules")]
[EndpointSummary("Get all schedules")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ProgramScheduleViewModel>), StatusCodes.Status200OK)]
public async Task<List<ProgramScheduleViewModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
[ProducesResponseType(typeof(List<ProgramScheduleResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ProgramScheduleResponseModel>> GetAll(CancellationToken cancellationToken)
{
List<ProgramScheduleViewModel> schedules = await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
return schedules.Map(ProjectToResponseModel).ToList();
}
[HttpGet("/api/schedules/{id:int}", Name = "GetScheduleById")]
[Tags("Schedules")]
[EndpointSummary("Get a schedule by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ProgramScheduleViewModel> result = await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
return result.ToGetResult();
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpPost("/api/schedules")]
[Tags("Schedules")]
[EndpointSummary("Create a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
@@ -54,7 +57,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
Option<ProgramScheduleViewModel> schedule =
await mediator.Send(new GetProgramScheduleById(created.ProgramScheduleId), cancellationToken);
return schedule.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/schedules/{vm.Id}", vm),
Some: vm => (IActionResult)new CreatedResult(
$"/api/schedules/{vm.Id}",
ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -63,7 +68,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
[Tags("Schedules")]
[EndpointSummary("Update a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
@@ -80,7 +85,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
Option<ProgramScheduleViewModel> schedule =
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
return schedule.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -214,4 +219,14 @@ public class ScheduleController(IMediator mediator) : ControllerBase
await mediator.Send(new DeleteProgramScheduleItem(id, itemId), cancellationToken);
return result.ToDeletedResult();
}
private static ProgramScheduleResponseModel ProjectToResponseModel(ProgramScheduleViewModel vm) =>
new(
vm.Id,
vm.Name,
vm.KeepMultiPartEpisodesTogether,
vm.TreatCollectionsAsShows,
vm.ShuffleScheduleItems,
vm.RandomStartPoint,
vm.FixedStartTimeBehavior);
}
+3 -1
View File
@@ -24,6 +24,7 @@ public class SearchController(IMediator mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Search(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default)
{
@@ -32,9 +33,10 @@ public class SearchController(IMediator mediator) : ControllerBase
return BaseError.New("A non-empty query is required").ToErrorResult();
}
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
SearchResultsResponseModel result = await mediator.Send(
new GetSearchResults(query, clampedPageSize),
new GetSearchResults(query, clampedPageNum, clampedPageSize),
cancellationToken);
return new OkObjectResult(result);
}
@@ -25,20 +25,20 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
[Tags("Smart Collections")]
[EndpointSummary("Get a smart collection by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<SmartCollectionViewModel> result =
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
return result.ToGetResult();
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpPost("/api/smart-collections")]
[Tags("Smart Collections")]
[EndpointSummary("Create a smart collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
@@ -47,14 +47,14 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
{
Either<BaseError, SmartCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", vm => vm);
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", ProjectToResponseModel);
}
[HttpPut("/api/smart-collections/{id:int}")]
[Tags("Smart Collections")]
[EndpointSummary("Update a smart collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
@@ -71,7 +71,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
Option<SmartCollectionViewModel> smartCollection =
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
return smartCollection.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -88,4 +88,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
return result.ToDeletedResult();
}
private static SmartCollectionResponseModel ProjectToResponseModel(SmartCollectionViewModel vm) =>
new(vm.Id, vm.Name, vm.Query);
}