feat(api): add schedule REST endpoints
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Refs #36
This commit is contained in:
2026-06-29 08:13:14 +02:00
parent 0d101e79b1
commit b552905799
19 changed files with 3270 additions and 50 deletions
@@ -0,0 +1,22 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateScheduleRequest(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
public CreateProgramSchedule ToCreateCommand() =>
new(
Name,
KeepMultiPartEpisodesTogether,
TreatCollectionsAsShows,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
@@ -0,0 +1,11 @@
using ErsatzTV.Application.ProgramSchedules;
namespace ErsatzTV.Controllers.Api.Requests;
public record ReplaceScheduleItemsRequest(List<ScheduleItemRequest> Items)
{
public ReplaceProgramScheduleItems ToCommand(int scheduleId) =>
new(
scheduleId,
(Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList());
}
@@ -0,0 +1,127 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record ScheduleItemRequest(
StartType StartType,
TimeSpan? StartTime,
FixedStartTimeBehavior? FixedStartTimeBehavior,
PlayoutMode PlayoutMode,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId,
string SearchTitle,
string SearchQuery,
PlaybackOrder PlaybackOrder,
MarathonGroupBy MarathonGroupBy,
bool MarathonShuffleGroups,
bool MarathonShuffleItems,
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode MultipleMode,
string MultipleCount,
TimeSpan? PlayoutDuration,
TailMode TailMode,
int? DiscardToFillAttempts,
string CustomTitle,
GuideMode GuideMode,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
int? TailFillerId,
int? FallbackFillerId,
List<int> WatermarkIds,
List<int> GraphicsElementIds,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode)
{
public AddProgramScheduleItem ToAddCommand(int scheduleId) =>
new(
scheduleId,
StartType,
StartTime,
FixedStartTimeBehavior,
PlayoutMode,
CollectionType,
CollectionId,
MultiCollectionId,
SmartCollectionId,
RerunCollectionId,
MediaItemId,
PlaylistId,
SearchTitle,
SearchQuery,
PlaybackOrder,
MarathonGroupBy,
MarathonShuffleGroups,
MarathonShuffleItems,
MarathonBatchSize,
FillWithGroupMode,
MultipleMode,
MultipleCount,
PlayoutDuration,
TailMode,
DiscardToFillAttempts,
CustomTitle,
GuideMode,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
TailFillerId,
FallbackFillerId,
WatermarkIds ?? [],
GraphicsElementIds ?? [],
PreferredAudioLanguageCode,
PreferredAudioTitle,
PreferredSubtitleLanguageCode,
SubtitleMode);
public ReplaceProgramScheduleItem ToReplaceCommand(int index) =>
new(
index,
StartType,
StartTime,
FixedStartTimeBehavior,
PlayoutMode,
CollectionType,
CollectionId,
MultiCollectionId,
SmartCollectionId,
RerunCollectionId,
MediaItemId,
PlaylistId,
SearchTitle,
SearchQuery,
PlaybackOrder,
MarathonGroupBy,
MarathonShuffleGroups,
MarathonShuffleItems,
MarathonBatchSize,
FillWithGroupMode,
MultipleMode,
MultipleCount,
PlayoutDuration,
TailMode,
DiscardToFillAttempts,
CustomTitle,
GuideMode,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
TailFillerId,
FallbackFillerId,
WatermarkIds ?? [],
GraphicsElementIds ?? [],
PreferredAudioLanguageCode,
PreferredAudioTitle,
PreferredSubtitleLanguageCode,
SubtitleMode);
}
@@ -0,0 +1,23 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateScheduleRequest(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
public UpdateProgramSchedule ToCommand(int id) =>
new(
id,
Name,
KeepMultiPartEpisodesTogether,
TreatCollectionsAsShows,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
@@ -0,0 +1,167 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class ScheduleController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/schedules")]
[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);
[HttpGet("/api/schedules/{id:int}", Name = "GetScheduleById")]
[Tags("Schedules")]
[EndpointSummary("Get a schedule by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), 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();
}
[HttpPost("/api/schedules")]
[Tags("Schedules")]
[EndpointSummary("Create a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateScheduleRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateProgramScheduleResult> result =
await mediator.Send(request.ToCreateCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<ProgramScheduleViewModel> schedule =
await mediator.Send(new GetProgramScheduleById(created.ProgramScheduleId), cancellationToken);
return schedule.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/schedules/{vm.Id}", vm),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpPut("/api/schedules/{id:int}")]
[Tags("Schedules")]
[EndpointSummary("Update a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateScheduleRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, UpdateProgramScheduleResult> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
Option<ProgramScheduleViewModel> schedule =
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
return schedule.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/schedules/{id:int}")]
[Tags("Schedules")]
[EndpointSummary("Delete a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteProgramSchedule(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpGet("/api/schedules/{id:int}/items")]
[Tags("Schedules")]
[EndpointSummary("Get schedule items")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
{
Option<ProgramScheduleViewModel> schedule = await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
if (schedule.IsNone)
{
return ApiResults.NotFoundProblem();
}
List<ProgramScheduleItemViewModel> items =
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
return new OkObjectResult(items);
}
[HttpPost("/api/schedules/{id:int}/items")]
[Tags("Schedules")]
[EndpointSummary("Add a schedule item")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleItemViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> AddItem(
int id,
[Required] [FromBody] ScheduleItemRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ProgramScheduleItemViewModel> result =
await mediator.Send(request.ToAddCommand(id), cancellationToken);
return result.ToCreatedResult(item => $"/api/schedules/{id}/items/{item.Id}", item => item);
}
[HttpPut("/api/schedules/{id:int}/items")]
[Tags("Schedules")]
[EndpointSummary("Replace schedule items")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(IEnumerable<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceItems(
int id,
[Required] [FromBody] ReplaceScheduleItemsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
}
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]
[Tags("Schedules")]
[EndpointSummary("Delete a schedule item")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> DeleteItem(int id, int itemId, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result =
await mediator.Send(new DeleteProgramScheduleItem(id, itemId), cancellationToken);
return result.ToDeletedResult();
}
}