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:
@@ -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")]
|
||||
|
||||
@@ -18,27 +18,30 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Get all schedules")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<ProgramScheduleViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<ProgramScheduleViewModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
|
||||
[ProducesResponseType(typeof(List<ProgramScheduleResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<ProgramScheduleResponseModel>> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
List<ProgramScheduleViewModel> schedules = await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
|
||||
return schedules.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/schedules/{id:int}", Name = "GetScheduleById")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Get a schedule by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ProgramScheduleViewModel> result = await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/schedules")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Create a schedule")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
@@ -54,7 +57,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
Option<ProgramScheduleViewModel> schedule =
|
||||
await mediator.Send(new GetProgramScheduleById(created.ProgramScheduleId), cancellationToken);
|
||||
return schedule.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/schedules/{vm.Id}", vm),
|
||||
Some: vm => (IActionResult)new CreatedResult(
|
||||
$"/api/schedules/{vm.Id}",
|
||||
ProjectToResponseModel(vm)),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
@@ -63,7 +68,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Update a schedule")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
@@ -80,7 +85,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
Option<ProgramScheduleViewModel> schedule =
|
||||
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
|
||||
return schedule.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(vm),
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
@@ -214,4 +219,14 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new DeleteProgramScheduleItem(id, itemId), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
private static ProgramScheduleResponseModel ProjectToResponseModel(ProgramScheduleViewModel vm) =>
|
||||
new(
|
||||
vm.Id,
|
||||
vm.Name,
|
||||
vm.KeepMultiPartEpisodesTogether,
|
||||
vm.TreatCollectionsAsShows,
|
||||
vm.ShuffleScheduleItems,
|
||||
vm.RandomStartPoint,
|
||||
vm.FixedStartTimeBehavior);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -32,9 +33,10 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
return BaseError.New("A non-empty query is required").ToErrorResult();
|
||||
}
|
||||
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||
SearchResultsResponseModel result = await mediator.Send(
|
||||
new GetSearchResults(query, clampedPageSize),
|
||||
new GetSearchResults(query, clampedPageNum, clampedPageSize),
|
||||
cancellationToken);
|
||||
return new OkObjectResult(result);
|
||||
}
|
||||
|
||||
@@ -25,20 +25,20 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Get a smart collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<SmartCollectionViewModel> result =
|
||||
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/smart-collections")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Create a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
@@ -47,14 +47,14 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
Either<BaseError, SmartCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", vm => vm);
|
||||
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/smart-collections/{id:int}")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Update a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(SmartCollectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
@@ -71,7 +71,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
Option<SmartCollectionViewModel> smartCollection =
|
||||
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
|
||||
return smartCollection.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(vm),
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
@@ -88,4 +88,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
private static SmartCollectionResponseModel ProjectToResponseModel(SmartCollectionViewModel vm) =>
|
||||
new(vm.Id, vm.Name, vm.Query);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user