Merge main into feat/59-spa-foundation (post-#119 backend API sync)
# Conflicts: # ErsatzTV.Tests/Controllers/ChannelControllerTests.cs # ErsatzTV/Controllers/Api/ChannelController.cs # ErsatzTV/wwwroot/openapi/v1.json
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
using System.ComponentModel;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Artwork;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class ArtworkUploadController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/artwork/uploads", Name = "UploadArtwork")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[Tags("Artwork")]
|
||||
[EndpointSummary("Upload channel logo or watermark artwork")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ArtworkUploadResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Upload(
|
||||
IFormFile file,
|
||||
[FromForm] [Description("Artwork target: 'logo' (default) or 'watermark'")] string target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BaseError.New("A non-empty image file is required").ToErrorResult();
|
||||
}
|
||||
|
||||
long maxBytes = (long)SystemEnvironment.MaximumUploadMb * 1024 * 1024;
|
||||
if (file.Length > maxBytes)
|
||||
{
|
||||
return BaseError.New($"Image exceeds the maximum allowed size of {SystemEnvironment.MaximumUploadMb} MB")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
if (!TryParseTarget(target, out ArtworkKind artworkKind))
|
||||
{
|
||||
return BaseError.New($"Unknown upload target '{target}'; expected 'logo' or 'watermark'").ToErrorResult();
|
||||
}
|
||||
|
||||
await using Stream stream = file.OpenReadStream();
|
||||
Either<BaseError, ArtworkUploadResponseModel> result = await mediator.Send(
|
||||
new UploadArtwork(stream, file.ContentType, artworkKind),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToCreatedResult(
|
||||
value => LocationFor(artworkKind, value.Path, value.ContentType),
|
||||
value => value);
|
||||
}
|
||||
|
||||
// "logo" (default) and "watermark" are the two channel-artwork surfaces the API exposes today.
|
||||
private static bool TryParseTarget(string target, out ArtworkKind artworkKind)
|
||||
{
|
||||
switch ((target ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case "":
|
||||
case "logo":
|
||||
artworkKind = ArtworkKind.Logo;
|
||||
return true;
|
||||
case "watermark":
|
||||
artworkKind = ArtworkKind.Watermark;
|
||||
return true;
|
||||
default:
|
||||
artworkKind = ArtworkKind.Logo;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Both GetImage (IptvController) and GetWatermark (ArtworkController) require a contentType
|
||||
// query param to serve the cached file, so the Location header must carry it too.
|
||||
private static string LocationFor(ArtworkKind artworkKind, string path, string contentType)
|
||||
{
|
||||
string encodedContentType = Uri.EscapeDataString(contentType);
|
||||
return artworkKind switch
|
||||
{
|
||||
// logo paths already carry the servable prefix ("iptv/logos/{file}")
|
||||
ArtworkKind.Logo => $"/{path}?contentType={encodedContentType}",
|
||||
_ => $"/artwork/watermarks/{path}?contentType={encodedContentType}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
@@ -28,6 +29,20 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
|
||||
|
||||
[HttpGet("/api/guide")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get the JSON channel guide (EPG)")]
|
||||
[EndpointDescription(
|
||||
"Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, " +
|
||||
"and end defaults to start plus the configured XmltvDaysToBuild window.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<ChannelGuideResponseModel> GetGuide(
|
||||
[FromQuery] DateTimeOffset? start,
|
||||
[FromQuery] DateTimeOffset? end,
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
@@ -144,18 +159,42 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
[EndpointDescription(
|
||||
"When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection " +
|
||||
"progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " +
|
||||
"Pass mode to force a specific PlayoutBuildMode.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ResetPlayout(string channelNumber)
|
||||
public async Task<IActionResult> ResetPlayout(
|
||||
string channelNumber,
|
||||
[FromQuery] PlayoutBuildMode? mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
|
||||
Option<int> maybePlayoutId =
|
||||
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
|
||||
foreach (int playoutId in maybePlayoutId)
|
||||
{
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset));
|
||||
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// Match Blazor's Playouts.razor reset semantics: classic playouts refresh (preserve progress),
|
||||
// every other kind resets from scratch.
|
||||
private async Task<PlayoutBuildMode> DefaultResetMode(int playoutId, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout =
|
||||
await mediator.Send(new GetPlayoutById(playoutId), cancellationToken);
|
||||
return maybePlayout.Match(
|
||||
Some: vm => vm.ScheduleKind switch
|
||||
{
|
||||
PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh,
|
||||
_ => PlayoutBuildMode.Reset
|
||||
},
|
||||
None: () => PlayoutBuildMode.Reset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Get all filler presets")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class GraphicsElementController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
|
||||
[Tags("Graphics Elements")]
|
||||
[EndpointSummary("Get all graphics elements")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<GraphicsElementResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using ErsatzTV.Application.Health;
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class HealthController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/health", Name = "GetHealthChecks")]
|
||||
[Tags("Health")]
|
||||
[EndpointSummary("Get health check results")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<HealthCheckResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<HealthCheckResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
|
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/libraries/scan-status", Name = "GetLibraryScanStatus")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get active library scan status")]
|
||||
[ProducesResponseType(typeof(List<LibraryScanStatusResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<LibraryScanStatusResponseModel>> GetScanStatus(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetLibraryScanStatus(), cancellationToken);
|
||||
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan library")]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class MediaSourcesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/media-sources", Name = "GetMediaSources")]
|
||||
[Tags("Media Sources")]
|
||||
[EndpointSummary("Get all media sources with their libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<MediaSourceResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<MediaSourceResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -13,6 +15,32 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/playouts", Name = "GetPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("List playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedPlayoutsResponseModel> GetAll(
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PagedPlayoutsViewModel result =
|
||||
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
|
||||
return new PagedPlayoutsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ToListItemResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Count playouts with a failed build")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(int), StatusCodes.Status200OK)]
|
||||
public async Task<int> GetWarningsCount(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a playout by id")]
|
||||
@@ -25,6 +53,34 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ToResponse).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedPlayoutItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(
|
||||
int id,
|
||||
[FromQuery] bool showFiller = false,
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
PagedPlayoutItemsViewModel result = await mediator.Send(
|
||||
new GetFuturePlayoutItemsById(id, showFiller, pageNum, pageSize),
|
||||
cancellationToken);
|
||||
return new OkObjectResult(
|
||||
new PagedPlayoutItemsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ToItemResponse).ToList()));
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Create a classic playout")]
|
||||
@@ -49,6 +105,17 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playouts/{id:int}")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Delete a playout")]
|
||||
@@ -71,5 +138,32 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
vm.PlayoutMode,
|
||||
vm.ScheduleName,
|
||||
vm.ScheduleFile,
|
||||
vm.DbDailyRebuildTime);
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus));
|
||||
|
||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
|
||||
new(
|
||||
vm.PlayoutId,
|
||||
vm.ChannelNumber,
|
||||
vm.ChannelName,
|
||||
vm.ScheduleKind,
|
||||
vm.ScheduleName,
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus));
|
||||
|
||||
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
||||
buildStatus is null
|
||||
? null
|
||||
: new PlayoutBuildStatusResponseModel(
|
||||
buildStatus.LastBuild,
|
||||
buildStatus.Success,
|
||||
buildStatus.Message);
|
||||
|
||||
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
|
||||
new(
|
||||
vm.Title,
|
||||
vm.Start,
|
||||
vm.Finish,
|
||||
vm.Duration,
|
||||
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
|
||||
}
|
||||
|
||||
@@ -99,8 +99,12 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Get schedule items")]
|
||||
[EndpointDescription(
|
||||
"Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a " +
|
||||
"nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " +
|
||||
"derived from referenced collection/media runtimes and are null when unbounded or unknown.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -110,8 +114,8 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<ProgramScheduleItemViewModel> items =
|
||||
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
|
||||
ProgramScheduleItemsWithDurationViewModel items =
|
||||
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
|
||||
return new OkObjectResult(items);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/watermarks", Name = "GetWatermarks")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Get all watermarks")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<WatermarkResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<WatermarkResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllWatermarksForApi(), cancellationToken);
|
||||
}
|
||||
Reference in New Issue
Block a user