Browse Source

Add new API controllers

pull/2735/head
Adam Jacob Muller 7 months ago
parent
commit
220eb1b133
  1. 159
      ErsatzTV/Controllers/Api/BlockController.cs
  2. 234
      ErsatzTV/Controllers/Api/ChannelController.cs
  3. 195
      ErsatzTV/Controllers/Api/CollectionController.cs
  4. 84
      ErsatzTV/Controllers/Api/ConfigController.cs
  5. 266
      ErsatzTV/Controllers/Api/DecoController.cs
  6. 83
      ErsatzTV/Controllers/Api/EmbyController.cs
  7. 65
      ErsatzTV/Controllers/Api/FFmpegProfileController.cs
  8. 142
      ErsatzTV/Controllers/Api/FillerController.cs
  9. 83
      ErsatzTV/Controllers/Api/JellyfinController.cs
  10. 95
      ErsatzTV/Controllers/Api/LibrariesController.cs
  11. 36
      ErsatzTV/Controllers/Api/MaintenanceController.cs
  12. 73
      ErsatzTV/Controllers/Api/MediaController.cs
  13. 133
      ErsatzTV/Controllers/Api/PlaylistController.cs
  14. 162
      ErsatzTV/Controllers/Api/PlayoutController.cs
  15. 76
      ErsatzTV/Controllers/Api/PlexController.cs
  16. 39
      ErsatzTV/Controllers/Api/ResolutionController.cs
  17. 97
      ErsatzTV/Controllers/Api/ScheduleController.cs
  18. 98
      ErsatzTV/Controllers/Api/SearchController.cs
  19. 125
      ErsatzTV/Controllers/Api/TemplateController.cs
  20. 65
      ErsatzTV/Controllers/Api/TraktController.cs
  21. 159
      ErsatzTV/Controllers/Api/WatermarkController.cs

159
ErsatzTV/Controllers/Api/BlockController.cs

@ -0,0 +1,159 @@ @@ -0,0 +1,159 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class BlockController(IMediator mediator) : ControllerBase
{
// Block Groups
[HttpGet("/api/blocks/groups", Name = "GetBlockGroups")]
[Tags("Blocks")]
[EndpointSummary("Get all block groups")]
public async Task<List<BlockGroupViewModel>> GetBlockGroups(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllBlockGroups(), cancellationToken);
[HttpPost("/api/blocks/groups", Name = "CreateBlockGroup")]
[Tags("Blocks")]
[EndpointSummary("Create a block group")]
public async Task<IActionResult> CreateBlockGroup(
[Required] [FromBody] CreateBlockGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, BlockGroupViewModel> result = await mediator.Send(
new CreateBlockGroup(request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/blocks/groups/{id:int}", Name = "DeleteBlockGroup")]
[Tags("Blocks")]
[EndpointSummary("Delete a block group")]
public async Task<IActionResult> DeleteBlockGroup(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteBlockGroup(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
// Blocks
[HttpGet("/api/blocks/groups/{groupId:int}/blocks", Name = "GetBlocksByGroup")]
[Tags("Blocks")]
[EndpointSummary("Get blocks by group")]
public async Task<List<BlockViewModel>> GetBlocksByGroup(int groupId, CancellationToken cancellationToken) =>
await mediator.Send(new GetBlocksByBlockGroupId(groupId), cancellationToken);
[HttpGet("/api/blocks", Name = "GetAllBlocks")]
[Tags("Blocks")]
[EndpointSummary("Get all blocks")]
public async Task<List<BlockViewModel>> GetAllBlocks(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllBlocks(), cancellationToken);
[HttpGet("/api/blocks/{id:int}", Name = "GetBlockById")]
[Tags("Blocks")]
[EndpointSummary("Get block by ID")]
public async Task<IActionResult> GetBlockById(int id, CancellationToken cancellationToken)
{
Option<BlockViewModel> result = await mediator.Send(new GetBlockById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/blocks/{id:int}/items", Name = "GetBlockItems")]
[Tags("Blocks")]
[EndpointSummary("Get block items")]
public async Task<List<BlockItemViewModel>> GetBlockItems(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetBlockItems(id), cancellationToken);
[HttpPost("/api/blocks", Name = "CreateBlock")]
[Tags("Blocks")]
[EndpointSummary("Create a block")]
public async Task<IActionResult> CreateBlock(
[Required] [FromBody] CreateBlockRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, BlockViewModel> result = await mediator.Send(
new CreateBlock(request.BlockGroupId, request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/blocks/{id:int}", Name = "DeleteBlock")]
[Tags("Blocks")]
[EndpointSummary("Delete a block")]
public async Task<IActionResult> DeleteBlock(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteBlock(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
[HttpPost("/api/blocks/{id:int}/copy", Name = "CopyBlock")]
[Tags("Blocks")]
[EndpointSummary("Copy a block")]
public async Task<IActionResult> CopyBlock(
int id,
[Required] [FromBody] CopyBlockRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, BlockViewModel> result = await mediator.Send(
new CopyBlock(id, request.NewBlockGroupId, request.NewBlockName), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/blocks/{id:int}/items", Name = "ReplaceBlockItems")]
[Tags("Blocks")]
[EndpointSummary("Replace block items")]
public async Task<IActionResult> ReplaceBlockItems(
int id,
[Required] [FromBody] ReplaceBlockItemsRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?.Select(i => new ReplaceBlockItem(
i.Index,
i.CollectionType,
i.CollectionId,
i.MultiCollectionId,
i.SmartCollectionId,
i.MediaItemId,
i.SearchTitle ?? "",
i.SearchQuery ?? "",
i.PlaybackOrder,
i.IncludeInProgramGuide,
i.DisableWatermarks,
i.WatermarkIds ?? [],
i.GraphicsElementIds ?? [])).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new ReplaceBlockItems(request.BlockGroupId, id, request.Name, request.Minutes, request.StopScheduling, items),
cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
}
// Request models
public record CreateBlockGroupRequest(string Name);
public record CreateBlockRequest(int BlockGroupId, string Name);
public record CopyBlockRequest(int NewBlockGroupId, string NewBlockName);
public record ReplaceBlockItemRequest(
int Index,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
string? SearchTitle,
string? SearchQuery,
PlaybackOrder PlaybackOrder,
bool IncludeInProgramGuide,
bool DisableWatermarks,
List<int>? WatermarkIds,
List<int>? GraphicsElementIds);
public record ReplaceBlockItemsRequest(
int BlockGroupId,
string Name,
int Minutes,
BlockStopScheduling StopScheduling,
List<ReplaceBlockItemRequest>? Items);

234
ErsatzTV/Controllers/Api/ChannelController.cs

@ -1,55 +1,231 @@ @@ -1,55 +1,231 @@
using System.Threading.Channels;
#nullable enable
using System.ComponentModel.DataAnnotations;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Filters;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
[EndpointGroupName("general")]
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator) : ControllerBase
{
[HttpGet("/api/channels")]
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/channels", Name = "GetAllChannels")]
[Tags("Channels")]
[EndpointSummary("Get all channels")]
public async Task<List<ChannelResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllChannelsForApi(), cancellationToken);
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get channel by ID")]
public async Task<IActionResult> GetChannelById(int id, CancellationToken cancellationToken)
{
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/channels/by-number/{channelNumber}", Name = "GetChannelByNumber")]
[Tags("Channels")]
[EndpointSummary("Get channel by number")]
public async Task<IActionResult> GetChannelByNumber(string channelNumber, CancellationToken cancellationToken)
{
Option<ChannelViewModel> result = await mediator.Send(new GetChannelByNumber(channelNumber), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[HttpPost("/api/channels", Name = "CreateChannel")]
[Tags("Channels")]
[EndpointSummary("Create a channel")]
public async Task<IActionResult> CreateChannel(
[Required] [FromBody] CreateChannelRequest request,
CancellationToken cancellationToken)
{
var logo = request.LogoPath != null
? new ArtworkContentTypeModel(request.LogoPath, request.LogoContentType ?? "")
: ArtworkContentTypeModel.None;
Either<BaseError, CreateChannelResult> result = await mediator.Send(
new CreateChannel(
request.Name,
request.Number,
request.Group ?? "",
request.Categories ?? "",
request.FFmpegProfileId,
logo,
request.StreamSelectorMode,
request.StreamSelector ?? "",
request.PreferredAudioLanguageCode ?? "",
request.PreferredAudioTitle ?? "",
request.PlayoutSource,
request.PlayoutMode,
request.MirrorSourceChannelId,
request.PlayoutOffset,
request.StreamingMode,
request.WatermarkId,
request.FallbackFillerId,
request.PreferredSubtitleLanguageCode ?? "",
request.SubtitleMode,
request.MusicVideoCreditsMode,
request.MusicVideoCreditsTemplate ?? "",
request.SongVideoMode,
request.TranscodeMode,
request.IdleBehavior,
request.IsEnabled,
request.ShowInEpg),
cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/channels/{id:int}", Name = "UpdateChannel")]
[Tags("Channels")]
[EndpointSummary("Update a channel")]
public async Task<IActionResult> UpdateChannel(
int id,
[Required] [FromBody] UpdateChannelRequest request,
CancellationToken cancellationToken)
{
var logo = request.LogoPath != null
? new ArtworkContentTypeModel(request.LogoPath, request.LogoContentType ?? "")
: ArtworkContentTypeModel.None;
Either<BaseError, ChannelViewModel> result = await mediator.Send(
new UpdateChannel(
id,
request.Name,
request.Number,
request.Group ?? "",
request.Categories ?? "",
request.FFmpegProfileId,
logo,
request.StreamSelectorMode,
request.StreamSelector ?? "",
request.PreferredAudioLanguageCode ?? "",
request.PreferredAudioTitle ?? "",
request.PlayoutSource,
request.PlayoutMode,
request.MirrorSourceChannelId,
request.PlayoutOffset,
request.StreamingMode,
request.WatermarkId,
request.FallbackFillerId,
request.PreferredSubtitleLanguageCode ?? "",
request.SubtitleMode,
request.MusicVideoCreditsMode,
request.MusicVideoCreditsTemplate ?? "",
request.SongVideoMode,
request.TranscodeMode,
request.IdleBehavior,
request.IsEnabled,
request.ShowInEpg),
cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/channels/{id:int}", Name = "DeleteChannel")]
[Tags("Channels")]
[EndpointSummary("Delete a channel")]
public async Task<IActionResult> DeleteChannel(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannel(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
[HttpPost("/api/channels/{channelNumber}/playout/reset", Name = "ResetChannelPlayout")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
[EndpointGroupName("general")]
public async Task<IActionResult> ResetPlayout(string channelNumber)
{
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
foreach (int playoutId in maybePlayoutId)
{
await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset));
return new OkResult();
return Ok();
}
return new NotFoundResult();
return NotFound();
}
// for debugging by fast-forwarding a playout
// [HttpPost("/api/channels/{channelNumber}/playout/continue")]
// public async Task<IActionResult> ContinuePlayout(string channelNumber, [FromQuery] int days = 1)
// {
// Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
// foreach (int playoutId in maybePlayoutId)
// {
// DateTimeOffset start = DateTimeOffset.Now;
// for (int i = 0; i < 24 * days; i++)
// {
// await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue, start));
// start += TimeSpan.FromHours(1);
// }
//
// return new OkResult();
// }
//
// return new NotFoundResult();
// }
[HttpPost("/api/channels/{channelNumber}/playout/build", Name = "BuildChannelPlayout")]
[Tags("Channels")]
[EndpointSummary("Build channel playout")]
public async Task<IActionResult> BuildPlayout(string channelNumber)
{
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
foreach (int playoutId in maybePlayoutId)
{
await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
return Accepted();
}
return NotFound();
}
}
// Request models
public record CreateChannelRequest(
string Name,
string Number,
string? Group,
string? Categories,
int FFmpegProfileId,
string? LogoPath,
string? LogoContentType,
ChannelStreamSelectorMode StreamSelectorMode,
string? StreamSelector,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string? MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg);
public record UpdateChannelRequest(
string Name,
string Number,
string? Group,
string? Categories,
int FFmpegProfileId,
string? LogoPath,
string? LogoContentType,
ChannelStreamSelectorMode StreamSelectorMode,
string? StreamSelector,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string? MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg);

195
ErsatzTV/Controllers/Api/CollectionController.cs

@ -0,0 +1,195 @@ @@ -0,0 +1,195 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class CollectionController(IMediator mediator) : ControllerBase
{
// Collections
[HttpGet("/api/collections", Name = "GetCollections")]
[Tags("Collections")]
[EndpointSummary("Get all collections (paginated)")]
public async Task<PagedMediaCollectionsViewModel> GetCollections(
[FromQuery] string query = "",
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new GetPagedCollections(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/collections/all", Name = "GetAllCollections")]
[Tags("Collections")]
[EndpointSummary("Get all collections")]
public async Task<List<MediaCollectionViewModel>> GetAllCollections(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllCollections(), cancellationToken);
[HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")]
[Tags("Collections")]
[EndpointSummary("Get collection by ID")]
public async Task<IActionResult> GetCollectionById(int id, CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> result = await mediator.Send(new GetCollectionById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")]
[Tags("Collections")]
[EndpointSummary("Get collection items")]
public async Task<IActionResult> GetCollectionItems(int id, CancellationToken cancellationToken)
{
Either<BaseError, CollectionCardResultsViewModel> result = await mediator.Send(new GetCollectionCards(id), cancellationToken);
return result.Match<IActionResult>(Ok, error => NotFound());
}
[HttpPost("/api/collections", Name = "CreateCollection")]
[Tags("Collections")]
[EndpointSummary("Create a new collection")]
public async Task<IActionResult> CreateCollection(
[Required] [FromBody] CreateCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, MediaCollectionViewModel> result = await mediator.Send(new CreateCollection(request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/collections/{id:int}", Name = "UpdateCollection")]
[Tags("Collections")]
[EndpointSummary("Update a collection")]
public async Task<IActionResult> UpdateCollection(
int id,
[Required] [FromBody] UpdateCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new UpdateCollection(id, request.Name), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpDelete("/api/collections/{id:int}", Name = "DeleteCollection")]
[Tags("Collections")]
[EndpointSummary("Delete a collection")]
public async Task<IActionResult> DeleteCollection(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteCollection(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
[HttpPost("/api/collections/{id:int}/items", Name = "AddItemsToCollection")]
[Tags("Collections")]
[EndpointSummary("Add items to a collection")]
public async Task<IActionResult> AddItemsToCollection(
int id,
[Required] [FromBody] AddItemsToCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(
new AddItemsToCollection(
id,
request.MovieIds ?? [],
request.ShowIds ?? [],
request.SeasonIds ?? [],
request.EpisodeIds ?? [],
request.ArtistIds ?? [],
request.MusicVideoIds ?? [],
request.OtherVideoIds ?? [],
request.SongIds ?? [],
request.ImageIds ?? [],
request.RemoteStreamIds ?? []),
cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpDelete("/api/collections/{id:int}/items", Name = "RemoveItemsFromCollection")]
[Tags("Collections")]
[EndpointSummary("Remove items from a collection")]
public async Task<IActionResult> RemoveItemsFromCollection(
int id,
[Required] [FromBody] RemoveItemsFromCollectionRequest request,
CancellationToken cancellationToken)
{
var command = new RemoveItemsFromCollection(id) { MediaItemIds = request.MediaItemIds ?? [] };
Either<BaseError, Unit> result = await mediator.Send(command, cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
// Multi-collections
[HttpGet("/api/collections/multi", Name = "GetMultiCollections")]
[Tags("Collections")]
[EndpointSummary("Get all multi-collections")]
public async Task<List<MultiCollectionViewModel>> GetMultiCollections(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllMultiCollections(), cancellationToken);
[HttpGet("/api/collections/multi/{id:int}", Name = "GetMultiCollectionById")]
[Tags("Collections")]
[EndpointSummary("Get multi-collection by ID")]
public async Task<IActionResult> GetMultiCollectionById(int id, CancellationToken cancellationToken)
{
Option<MultiCollectionViewModel> result = await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpPost("/api/collections/multi", Name = "CreateMultiCollection")]
[Tags("Collections")]
[EndpointSummary("Create a multi-collection")]
public async Task<IActionResult> CreateMultiCollection(
[Required] [FromBody] CreateMultiCollectionRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?.Select(i => new CreateMultiCollectionItem(
i.CollectionId, i.SmartCollectionId, i.ScheduleAsGroup, i.PlaybackOrder)).ToList() ?? [];
Either<BaseError, MultiCollectionViewModel> result = await mediator.Send(
new CreateMultiCollection(request.Name, items), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/collections/multi/{id:int}", Name = "UpdateMultiCollection")]
[Tags("Collections")]
[EndpointSummary("Update a multi-collection")]
public async Task<IActionResult> UpdateMultiCollection(
int id,
[Required] [FromBody] UpdateMultiCollectionRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?.Select(i => new UpdateMultiCollectionItem(
i.CollectionId, i.SmartCollectionId, i.ScheduleAsGroup, i.PlaybackOrder)).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new UpdateMultiCollection(id, request.Name, items), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpDelete("/api/collections/multi/{id:int}", Name = "DeleteMultiCollection")]
[Tags("Collections")]
[EndpointSummary("Delete a multi-collection")]
public async Task<IActionResult> DeleteMultiCollection(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteMultiCollection(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
}
// Request models
public record CreateCollectionRequest(string Name);
public record UpdateCollectionRequest(string Name);
public record AddItemsToCollectionRequest(
List<int>? MovieIds,
List<int>? ShowIds,
List<int>? SeasonIds,
List<int>? EpisodeIds,
List<int>? ArtistIds,
List<int>? MusicVideoIds,
List<int>? OtherVideoIds,
List<int>? SongIds,
List<int>? ImageIds,
List<int>? RemoteStreamIds);
public record RemoveItemsFromCollectionRequest(List<int>? MediaItemIds);
public record CreateMultiCollectionItemRequest(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollectionRequest(string Name, List<CreateMultiCollectionItemRequest>? Items);
public record UpdateMultiCollectionItemRequest(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollectionRequest(string Name, List<UpdateMultiCollectionItemRequest>? Items);

84
ErsatzTV/Controllers/Api/ConfigController.cs

@ -0,0 +1,84 @@ @@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class ConfigController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/config/playout", Name = "GetPlayoutSettings")]
[Tags("Configuration")]
[EndpointSummary("Get playout settings")]
public async Task<PlayoutSettingsViewModel> GetPlayoutSettings(CancellationToken cancellationToken) =>
await mediator.Send(new GetPlayoutSettings(), cancellationToken);
[HttpPut("/api/config/playout", Name = "UpdatePlayoutSettings")]
[Tags("Configuration")]
[EndpointSummary("Update playout settings")]
public async Task<IActionResult> UpdatePlayoutSettings(
[Required] [FromBody] PlayoutSettingsViewModel settings,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new UpdatePlayoutSettings(settings), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpGet("/api/config/xmltv", Name = "GetXmltvSettings")]
[Tags("Configuration")]
[EndpointSummary("Get XMLTV settings")]
public async Task<XmltvSettingsViewModel> GetXmltvSettings(CancellationToken cancellationToken) =>
await mediator.Send(new GetXmltvSettings(), cancellationToken);
[HttpPut("/api/config/xmltv", Name = "UpdateXmltvSettings")]
[Tags("Configuration")]
[EndpointSummary("Update XMLTV settings")]
public async Task<IActionResult> UpdateXmltvSettings(
[Required] [FromBody] XmltvSettingsViewModel settings,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new UpdateXmltvSettings(settings), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpGet("/api/config/logging", Name = "GetLoggingSettings")]
[Tags("Configuration")]
[EndpointSummary("Get logging settings")]
public async Task<LoggingSettingsViewModel> GetLoggingSettings(CancellationToken cancellationToken) =>
await mediator.Send(new GetLoggingSettings(), cancellationToken);
[HttpPut("/api/config/logging", Name = "UpdateLoggingSettings")]
[Tags("Configuration")]
[EndpointSummary("Update logging settings")]
public async Task<IActionResult> UpdateLoggingSettings(
[Required] [FromBody] LoggingSettingsViewModel settings,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new UpdateLoggingSettings(settings), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpGet("/api/config/library-refresh-interval", Name = "GetLibraryRefreshInterval")]
[Tags("Configuration")]
[EndpointSummary("Get library refresh interval")]
public async Task<int> GetLibraryRefreshInterval(CancellationToken cancellationToken) =>
await mediator.Send(new GetLibraryRefreshInterval(), cancellationToken);
[HttpPut("/api/config/library-refresh-interval", Name = "UpdateLibraryRefreshInterval")]
[Tags("Configuration")]
[EndpointSummary("Update library refresh interval")]
public async Task<IActionResult> UpdateLibraryRefreshInterval(
[Required] [FromBody] UpdateLibraryRefreshIntervalRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(
new UpdateLibraryRefreshInterval(request.Interval), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
}
// Request models
public record UpdateLibraryRefreshIntervalRequest(int Interval);

266
ErsatzTV/Controllers/Api/DecoController.cs

@ -0,0 +1,266 @@ @@ -0,0 +1,266 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Application.Tree;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class DecoController(IMediator mediator) : ControllerBase
{
// Deco Groups
[HttpGet("/api/decos/groups", Name = "GetDecoGroups")]
[Tags("Decos")]
[EndpointSummary("Get all deco groups")]
public async Task<List<DecoGroupViewModel>> GetDecoGroups(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllDecoGroups(), cancellationToken);
[HttpPost("/api/decos/groups", Name = "CreateDecoGroup")]
[Tags("Decos")]
[EndpointSummary("Create a deco group")]
public async Task<IActionResult> CreateDecoGroup(
[Required] [FromBody] CreateDecoGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, DecoGroupViewModel> result = await mediator.Send(
new CreateDecoGroup(request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/decos/groups/{id:int}", Name = "DeleteDecoGroup")]
[Tags("Decos")]
[EndpointSummary("Delete a deco group")]
public async Task<IActionResult> DeleteDecoGroup(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteDecoGroup(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
// Decos
[HttpGet("/api/decos/groups/{groupId:int}/decos", Name = "GetDecosByGroup")]
[Tags("Decos")]
[EndpointSummary("Get decos by group")]
public async Task<List<DecoViewModel>> GetDecosByGroup(int groupId, CancellationToken cancellationToken) =>
await mediator.Send(new GetDecosByDecoGroupId(groupId), cancellationToken);
[HttpGet("/api/decos/tree", Name = "GetDecoTree")]
[Tags("Decos")]
[EndpointSummary("Get deco tree")]
public async Task<TreeViewModel> GetDecoTree(CancellationToken cancellationToken) =>
await mediator.Send(new GetDecoTree(), cancellationToken);
[HttpGet("/api/decos/{id:int}", Name = "GetDecoById")]
[Tags("Decos")]
[EndpointSummary("Get deco by ID")]
public async Task<IActionResult> GetDecoById(int id, CancellationToken cancellationToken)
{
Option<DecoViewModel> result = await mediator.Send(new GetDecoById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpPost("/api/decos", Name = "CreateDeco")]
[Tags("Decos")]
[EndpointSummary("Create a deco")]
public async Task<IActionResult> CreateDeco(
[Required] [FromBody] CreateDecoRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, DecoViewModel> result = await mediator.Send(
new CreateDeco(request.DecoGroupId, request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/decos/{id:int}", Name = "UpdateDeco")]
[Tags("Decos")]
[EndpointSummary("Update a deco")]
public async Task<IActionResult> UpdateDeco(
int id,
[Required] [FromBody] UpdateDecoRequest request,
CancellationToken cancellationToken)
{
var breakContent = request.BreakContent?.Select(b => new UpdateDecoBreakContent(
b.Id,
b.CollectionType,
b.CollectionId,
b.MediaItemId,
b.MultiCollectionId,
b.SmartCollectionId,
b.PlaylistId,
b.Placement)).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new UpdateDeco(
id,
request.DecoGroupId,
request.Name,
request.WatermarkMode,
request.WatermarkIds ?? [],
request.UseWatermarkDuringFiller,
request.GraphicsElementsMode,
request.GraphicsElementIds ?? [],
request.UseGraphicsElementsDuringFiller,
request.BreakContentMode,
breakContent,
request.DefaultFillerMode,
request.DefaultFillerCollectionType,
request.DefaultFillerCollectionId,
request.DefaultFillerMediaItemId,
request.DefaultFillerMultiCollectionId,
request.DefaultFillerSmartCollectionId,
request.DefaultFillerTrimToFit,
request.DeadAirFallbackMode,
request.DeadAirFallbackCollectionType,
request.DeadAirFallbackCollectionId,
request.DeadAirFallbackMediaItemId,
request.DeadAirFallbackMultiCollectionId,
request.DeadAirFallbackSmartCollectionId),
cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpDelete("/api/decos/{id:int}", Name = "DeleteDeco")]
[Tags("Decos")]
[EndpointSummary("Delete a deco")]
public async Task<IActionResult> DeleteDeco(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteDeco(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
[HttpPut("/api/playouts/{playoutId:int}/deco", Name = "UpdateDefaultDeco")]
[Tags("Decos")]
[EndpointSummary("Update default deco for playout")]
public async Task<IActionResult> UpdateDefaultDeco(
int playoutId,
[Required] [FromBody] UpdateDefaultDecoRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(
new UpdateDefaultDeco(playoutId, request.DecoId), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => Ok());
}
// Deco Template Groups
[HttpGet("/api/decos/template-groups", Name = "GetDecoTemplateGroups")]
[Tags("Decos")]
[EndpointSummary("Get all deco template groups")]
public async Task<List<DecoTemplateGroupViewModel>> GetDecoTemplateGroups(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllDecoTemplateGroups(), cancellationToken);
[HttpPost("/api/decos/template-groups", Name = "CreateDecoTemplateGroup")]
[Tags("Decos")]
[EndpointSummary("Create a deco template group")]
public async Task<IActionResult> CreateDecoTemplateGroup(
[Required] [FromBody] CreateDecoTemplateGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, DecoTemplateGroupViewModel> result = await mediator.Send(
new CreateDecoTemplateGroup(request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/decos/template-groups/{id:int}", Name = "DeleteDecoTemplateGroup")]
[Tags("Decos")]
[EndpointSummary("Delete a deco template group")]
public async Task<IActionResult> DeleteDecoTemplateGroup(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteDecoTemplateGroup(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
// Deco Templates
[HttpGet("/api/decos/template-groups/{groupId:int}/templates", Name = "GetDecoTemplatesByGroup")]
[Tags("Decos")]
[EndpointSummary("Get deco templates by group")]
public async Task<List<DecoTemplateViewModel>> GetDecoTemplatesByGroup(int groupId, CancellationToken cancellationToken) =>
await mediator.Send(new GetDecoTemplatesByDecoTemplateGroupId(groupId), cancellationToken);
[HttpGet("/api/decos/templates/tree", Name = "GetDecoTemplateTree")]
[Tags("Decos")]
[EndpointSummary("Get deco template tree")]
public async Task<TreeViewModel> GetDecoTemplateTree(CancellationToken cancellationToken) =>
await mediator.Send(new GetDecoTemplateTree(), cancellationToken);
[HttpGet("/api/decos/templates/{id:int}", Name = "GetDecoTemplateById")]
[Tags("Decos")]
[EndpointSummary("Get deco template by ID")]
public async Task<IActionResult> GetDecoTemplateById(int id, CancellationToken cancellationToken)
{
Option<DecoTemplateViewModel> result = await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/decos/templates/{id:int}/items", Name = "GetDecoTemplateItems")]
[Tags("Decos")]
[EndpointSummary("Get deco template items")]
public async Task<List<DecoTemplateItemViewModel>> GetDecoTemplateItems(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
[HttpPost("/api/decos/templates", Name = "CreateDecoTemplate")]
[Tags("Decos")]
[EndpointSummary("Create a deco template")]
public async Task<IActionResult> CreateDecoTemplate(
[Required] [FromBody] CreateDecoTemplateRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, DecoTemplateViewModel> result = await mediator.Send(
new CreateDecoTemplate(request.DecoTemplateGroupId, request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/decos/templates/{id:int}", Name = "DeleteDecoTemplate")]
[Tags("Decos")]
[EndpointSummary("Delete a deco template")]
public async Task<IActionResult> DeleteDecoTemplate(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteDecoTemplate(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
}
// Request models
public record CreateDecoGroupRequest(string Name);
public record CreateDecoRequest(int DecoGroupId, string Name);
public record UpdateDecoBreakContentRequest(
int Id,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId,
int? PlaylistId,
DecoBreakPlacement Placement);
public record UpdateDecoRequest(
int DecoGroupId,
string Name,
DecoMode WatermarkMode,
List<int>? WatermarkIds,
bool UseWatermarkDuringFiller,
DecoMode GraphicsElementsMode,
List<int>? GraphicsElementIds,
bool UseGraphicsElementsDuringFiller,
DecoMode BreakContentMode,
List<UpdateDecoBreakContentRequest>? BreakContent,
DecoMode DefaultFillerMode,
CollectionType DefaultFillerCollectionType,
int? DefaultFillerCollectionId,
int? DefaultFillerMediaItemId,
int? DefaultFillerMultiCollectionId,
int? DefaultFillerSmartCollectionId,
bool DefaultFillerTrimToFit,
DecoMode DeadAirFallbackMode,
CollectionType DeadAirFallbackCollectionType,
int? DeadAirFallbackCollectionId,
int? DeadAirFallbackMediaItemId,
int? DeadAirFallbackMultiCollectionId,
int? DeadAirFallbackSmartCollectionId);
public record UpdateDefaultDecoRequest(int? DecoId);
public record CreateDecoTemplateGroupRequest(string Name);
public record CreateDecoTemplateRequest(int DecoTemplateGroupId, string Name);

83
ErsatzTV/Controllers/Api/EmbyController.cs

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Emby;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class EmbyController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/emby/sources", Name = "GetEmbyMediaSources")]
[Tags("Emby")]
[EndpointSummary("Get all Emby media sources")]
public async Task<List<EmbyMediaSourceViewModel>> GetEmbyMediaSources(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllEmbyMediaSources(), cancellationToken);
[HttpGet("/api/emby/sources/{id:int}", Name = "GetEmbyMediaSourceById")]
[Tags("Emby")]
[EndpointSummary("Get Emby media source by ID")]
public async Task<IActionResult> GetEmbyMediaSourceById(int id, CancellationToken cancellationToken)
{
Option<EmbyMediaSourceViewModel> result = await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/emby/sources/{id:int}/libraries", Name = "GetEmbyLibraries")]
[Tags("Emby")]
[EndpointSummary("Get Emby libraries by source")]
public async Task<List<EmbyLibraryViewModel>> GetEmbyLibraries(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken);
[HttpGet("/api/emby/sources/{id:int}/path-replacements", Name = "GetEmbyPathReplacements")]
[Tags("Emby")]
[EndpointSummary("Get Emby path replacements by source")]
public async Task<List<EmbyPathReplacementViewModel>> GetEmbyPathReplacements(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken);
[HttpPut("/api/emby/sources/{id:int}/path-replacements", Name = "UpdateEmbyPathReplacements")]
[Tags("Emby")]
[EndpointSummary("Update Emby path replacements")]
public async Task<IActionResult> UpdateEmbyPathReplacements(
int id,
[Required] [FromBody] UpdateEmbyPathReplacementsRequest request,
CancellationToken cancellationToken)
{
var replacements = request.PathReplacements?.Select(p =>
new EmbyPathReplacementItem(p.Id, p.EmbyPath, p.LocalPath)).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new UpdateEmbyPathReplacements(id, replacements), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpPut("/api/emby/library-preferences", Name = "UpdateEmbyLibraryPreferences")]
[Tags("Emby")]
[EndpointSummary("Update Emby library preferences")]
public async Task<IActionResult> UpdateEmbyLibraryPreferences(
[Required] [FromBody] UpdateEmbyLibraryPreferencesRequest request,
CancellationToken cancellationToken)
{
var preferences = request.Preferences?.Select(p =>
new EmbyLibraryPreference(p.Id, p.ShouldSyncItems)).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new UpdateEmbyLibraryPreferences(preferences), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpGet("/api/emby/secrets", Name = "GetEmbySecrets")]
[Tags("Emby")]
[EndpointSummary("Get Emby secrets")]
public async Task<EmbySecrets> GetEmbySecrets(CancellationToken cancellationToken) =>
await mediator.Send(new GetEmbySecrets(), cancellationToken);
}
// Request models
public record EmbyPathReplacementRequest(int Id, string EmbyPath, string LocalPath);
public record UpdateEmbyPathReplacementsRequest(List<EmbyPathReplacementRequest>? PathReplacements);
public record EmbyLibraryPreferenceRequest(int Id, bool ShouldSyncItems);
public record UpdateEmbyLibraryPreferencesRequest(List<EmbyLibraryPreferenceRequest>? Preferences);

65
ErsatzTV/Controllers/Api/FFmpegProfileController.cs

@ -1,7 +1,8 @@ @@ -1,7 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.FFmpegProfiles;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@ -11,21 +12,36 @@ namespace ErsatzTV.Controllers.Api; @@ -11,21 +12,36 @@ namespace ErsatzTV.Controllers.Api;
[EndpointGroupName("general")]
public class FFmpegProfileController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/ffmpeg/profiles", Name="GetFFmpegProfiles")]
[HttpGet("/api/ffmpeg/profiles", Name = "GetFFmpegProfiles")]
[Tags("FFmpeg")]
[EndpointSummary("Get all FFmpeg profiles")]
public async Task<List<FFmpegFullProfileResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFFmpegProfilesForApi(), cancellationToken);
[HttpPost("/api/ffmpeg/profiles/new", Name="CreateFFmpegProfile")]
[HttpGet("/api/ffmpeg/profiles/{id:int}", Name = "GetFFmpegProfileById")]
[Tags("FFmpeg")]
[EndpointSummary("Get FFmpeg profile by ID")]
public async Task<IActionResult> GetFFmpegProfileById(int id, CancellationToken cancellationToken)
{
Option<FFmpegProfileViewModel> result = await mediator.Send(new GetFFmpegProfileById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpPost("/api/ffmpeg/profiles/new", Name = "CreateFFmpegProfile")]
[Tags("FFmpeg")]
[EndpointSummary("Create FFmpeg profile")]
public async Task<IActionResult> AddOne(
[Required] [FromBody]
CreateFFmpegProfile request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateFFmpegProfileResult> result = await mediator.Send(request, cancellationToken);
Either<BaseError, CreateFFmpegProfileResult> result = await mediator.Send(request, cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/ffmpeg/profiles/update", Name="UpdateFFmpegProfile")]
[HttpPut("/api/ffmpeg/profiles/update", Name = "UpdateFFmpegProfile")]
[Tags("FFmpeg")]
[EndpointSummary("Update FFmpeg profile")]
public async Task<IActionResult> UpdateOne(
[Required] [FromBody]
UpdateFFmpegProfile request,
@ -35,10 +51,45 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase @@ -35,10 +51,45 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/ffmpeg/delete/{id:int}", Name="DeleteFFmpegProfile")]
[HttpDelete("/api/ffmpeg/delete/{id:int}", Name = "DeleteFFmpegProfile")]
[Tags("FFmpeg")]
[EndpointSummary("Delete FFmpeg profile")]
public async Task<IActionResult> DeleteProfileAsync(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteFFmpegProfile(id), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Conflict(error.ToString()));
return result.Match<IActionResult>(_ => NoContent(), error => Conflict(error.ToString()));
}
[HttpPost("/api/ffmpeg/profiles/{id:int}/copy", Name = "CopyFFmpegProfile")]
[Tags("FFmpeg")]
[EndpointSummary("Copy FFmpeg profile")]
public async Task<IActionResult> CopyFFmpegProfile(
int id,
[Required] [FromBody] CopyFFmpegProfileRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, FFmpegProfileViewModel> result = await mediator.Send(
new CopyFFmpegProfile(id, request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpGet("/api/ffmpeg/settings", Name = "GetFFmpegSettings")]
[Tags("FFmpeg")]
[EndpointSummary("Get FFmpeg settings")]
public async Task<FFmpegSettingsViewModel> GetFFmpegSettings(CancellationToken cancellationToken) =>
await mediator.Send(new GetFFmpegSettings(), cancellationToken);
[HttpPut("/api/ffmpeg/settings", Name = "UpdateFFmpegSettings")]
[Tags("FFmpeg")]
[EndpointSummary("Update FFmpeg settings")]
public async Task<IActionResult> UpdateFFmpegSettings(
[Required] [FromBody] FFmpegSettingsViewModel settings,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new UpdateFFmpegSettings(settings), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
}
// Request models
public record CopyFFmpegProfileRequest(string Name);

142
ErsatzTV/Controllers/Api/FillerController.cs

@ -0,0 +1,142 @@ @@ -0,0 +1,142 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Filler;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class FillerController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/filler/presets", Name = "GetFillerPresets")]
[Tags("Filler")]
[EndpointSummary("Get all filler presets (paginated)")]
public async Task<PagedFillerPresetsViewModel> GetFillerPresets(
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new GetPagedFillerPresets(pageNumber, pageSize), cancellationToken);
[HttpGet("/api/filler/presets/all", Name = "GetAllFillerPresets")]
[Tags("Filler")]
[EndpointSummary("Get all filler presets")]
public async Task<List<FillerPresetViewModel>> GetAllFillerPresets(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresets(), cancellationToken);
[HttpGet("/api/filler/presets/{id:int}", Name = "GetFillerPresetById")]
[Tags("Filler")]
[EndpointSummary("Get filler preset by ID")]
public async Task<IActionResult> GetFillerPresetById(int id, CancellationToken cancellationToken)
{
Option<FillerPresetViewModel> result = await mediator.Send(new GetFillerPresetById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpPost("/api/filler/presets", Name = "CreateFillerPreset")]
[Tags("Filler")]
[EndpointSummary("Create a filler preset")]
public async Task<IActionResult> CreateFillerPreset(
[Required] [FromBody] CreateFillerPresetRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(
new CreateFillerPreset(
request.Name,
request.FillerKind,
request.FillerMode,
request.Duration,
request.Count,
request.PadToNearestMinute,
request.AllowWatermarks,
request.CollectionType,
request.CollectionId,
request.MediaItemId,
request.MultiCollectionId,
request.SmartCollectionId,
request.PlaylistId,
request.Expression ?? "",
request.UseChaptersAsMediaItems),
cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpPut("/api/filler/presets/{id:int}", Name = "UpdateFillerPreset")]
[Tags("Filler")]
[EndpointSummary("Update a filler preset")]
public async Task<IActionResult> UpdateFillerPreset(
int id,
[Required] [FromBody] UpdateFillerPresetRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(
new UpdateFillerPreset(
id,
request.Name,
request.FillerKind,
request.FillerMode,
request.Duration,
request.Count,
request.PadToNearestMinute,
request.AllowWatermarks,
request.CollectionType,
request.CollectionId,
request.MediaItemId,
request.MultiCollectionId,
request.SmartCollectionId,
request.PlaylistId,
request.Expression ?? "",
request.UseChaptersAsMediaItems),
cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpDelete("/api/filler/presets/{id:int}", Name = "DeleteFillerPreset")]
[Tags("Filler")]
[EndpointSummary("Delete a filler preset")]
public async Task<IActionResult> DeleteFillerPreset(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteFillerPreset(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
}
// Request models
public record CreateFillerPresetRequest(
string Name,
FillerKind FillerKind,
FillerMode FillerMode,
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId,
int? PlaylistId,
string? Expression,
bool UseChaptersAsMediaItems);
public record UpdateFillerPresetRequest(
string Name,
FillerKind FillerKind,
FillerMode FillerMode,
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId,
int? PlaylistId,
string? Expression,
bool UseChaptersAsMediaItems);

83
ErsatzTV/Controllers/Api/JellyfinController.cs

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class JellyfinController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/jellyfin/sources", Name = "GetJellyfinMediaSources")]
[Tags("Jellyfin")]
[EndpointSummary("Get all Jellyfin media sources")]
public async Task<List<JellyfinMediaSourceViewModel>> GetJellyfinMediaSources(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllJellyfinMediaSources(), cancellationToken);
[HttpGet("/api/jellyfin/sources/{id:int}", Name = "GetJellyfinMediaSourceById")]
[Tags("Jellyfin")]
[EndpointSummary("Get Jellyfin media source by ID")]
public async Task<IActionResult> GetJellyfinMediaSourceById(int id, CancellationToken cancellationToken)
{
Option<JellyfinMediaSourceViewModel> result = await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/jellyfin/sources/{id:int}/libraries", Name = "GetJellyfinLibraries")]
[Tags("Jellyfin")]
[EndpointSummary("Get Jellyfin libraries by source")]
public async Task<List<JellyfinLibraryViewModel>> GetJellyfinLibraries(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken);
[HttpGet("/api/jellyfin/sources/{id:int}/path-replacements", Name = "GetJellyfinPathReplacements")]
[Tags("Jellyfin")]
[EndpointSummary("Get Jellyfin path replacements by source")]
public async Task<List<JellyfinPathReplacementViewModel>> GetJellyfinPathReplacements(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken);
[HttpPut("/api/jellyfin/sources/{id:int}/path-replacements", Name = "UpdateJellyfinPathReplacements")]
[Tags("Jellyfin")]
[EndpointSummary("Update Jellyfin path replacements")]
public async Task<IActionResult> UpdateJellyfinPathReplacements(
int id,
[Required] [FromBody] UpdateJellyfinPathReplacementsRequest request,
CancellationToken cancellationToken)
{
var replacements = request.PathReplacements?.Select(p =>
new JellyfinPathReplacementItem(p.Id, p.JellyfinPath, p.LocalPath)).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new UpdateJellyfinPathReplacements(id, replacements), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpPut("/api/jellyfin/library-preferences", Name = "UpdateJellyfinLibraryPreferences")]
[Tags("Jellyfin")]
[EndpointSummary("Update Jellyfin library preferences")]
public async Task<IActionResult> UpdateJellyfinLibraryPreferences(
[Required] [FromBody] UpdateJellyfinLibraryPreferencesRequest request,
CancellationToken cancellationToken)
{
var preferences = request.Preferences?.Select(p =>
new JellyfinLibraryPreference(p.Id, p.ShouldSyncItems)).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new UpdateJellyfinLibraryPreferences(preferences), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpGet("/api/jellyfin/secrets", Name = "GetJellyfinSecrets")]
[Tags("Jellyfin")]
[EndpointSummary("Get Jellyfin secrets")]
public async Task<JellyfinSecrets> GetJellyfinSecrets(CancellationToken cancellationToken) =>
await mediator.Send(new GetJellyfinSecrets(), cancellationToken);
}
// Request models
public record JellyfinPathReplacementRequest(int Id, string JellyfinPath, string LocalPath);
public record UpdateJellyfinPathReplacementsRequest(List<JellyfinPathReplacementRequest>? PathReplacements);
public record JellyfinLibraryPreferenceRequest(int Id, bool ShouldSyncItems);
public record UpdateJellyfinLibraryPreferencesRequest(List<JellyfinLibraryPreferenceRequest>? Preferences);

95
ErsatzTV/Controllers/Api/LibrariesController.cs

@ -1,5 +1,10 @@ @@ -1,5 +1,10 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@ -7,24 +12,86 @@ namespace ErsatzTV.Controllers.Api; @@ -7,24 +12,86 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase
{
[HttpPost("/api/libraries/{id:int}/scan")]
[HttpGet("/api/libraries", Name = "GetAllLibraries")]
[Tags("Libraries")]
[EndpointSummary("Get all configured libraries")]
public async Task<List<LibraryViewModel>> GetAllLibraries(CancellationToken cancellationToken) =>
await mediator.Send(new GetConfiguredLibraries(), cancellationToken);
[HttpGet("/api/libraries/local", Name = "GetLocalLibraries")]
[Tags("Libraries")]
[EndpointSummary("Get all local libraries")]
public async Task<List<LocalLibraryViewModel>> GetLocalLibraries(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllLocalLibraries(), cancellationToken);
[HttpGet("/api/libraries/local/{id:int}", Name = "GetLocalLibraryById")]
[Tags("Libraries")]
[EndpointSummary("Get local library by ID")]
public async Task<IActionResult> GetLocalLibraryById(int id, CancellationToken cancellationToken)
{
Option<LocalLibraryViewModel> result = await mediator.Send(new GetLocalLibraryById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/libraries/local/{id:int}/paths", Name = "GetLocalLibraryPaths")]
[Tags("Libraries")]
[EndpointSummary("Get local library paths")]
public async Task<List<LocalLibraryPathViewModel>> GetLocalLibraryPaths(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetLocalLibraryPaths(id), cancellationToken);
[HttpPost("/api/libraries/local", Name = "CreateLocalLibrary")]
[Tags("Libraries")]
[EndpointSummary("Create a local library")]
public async Task<IActionResult> CreateLocalLibrary(
[Required] [FromBody] CreateLocalLibraryRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, LocalLibraryViewModel> result = await mediator.Send(
new CreateLocalLibrary(request.Name, request.MediaKind, request.Paths ?? []), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/libraries/local/{id:int}", Name = "UpdateLocalLibrary")]
[Tags("Libraries")]
[EndpointSummary("Update a local library")]
public async Task<IActionResult> UpdateLocalLibrary(
int id,
[Required] [FromBody] UpdateLocalLibraryRequest request,
CancellationToken cancellationToken)
{
var paths = request.Paths?.Select(p => new UpdateLocalLibraryPath(p.Id, p.Path)).ToList() ?? [];
Either<BaseError, LocalLibraryViewModel> result = await mediator.Send(
new UpdateLocalLibrary(id, request.Name, paths), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/libraries/local/{id:int}", Name = "DeleteLocalLibrary")]
[Tags("Libraries")]
[EndpointSummary("Delete a local library")]
public async Task<IActionResult> DeleteLocalLibrary(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteLocalLibrary(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
[HttpPost("/api/libraries/{id:int}/scan", Name = "ScanLibrary")]
[Tags("Libraries")]
[EndpointSummary("Scan library")]
public async Task<IActionResult> ScanLibrary(int id) =>
await mediator.Send(new QueueLibraryScanByLibraryId(id))
? new OkResult()
: new NotFoundResult();
? Ok()
: NotFound();
[HttpPost("/api/libraries/{id:int}/scan-show")]
[HttpPost("/api/libraries/{id:int}/scan-show", Name = "ScanShow")]
[Tags("Libraries")]
[EndpointSummary("Scan show")]
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
{
if (string.IsNullOrWhiteSpace(request.ShowTitle))
{
return new BadRequestObjectResult(new { error = "ShowTitle is required" });
return BadRequest(new { error = "ShowTitle is required" });
}
string trimmedTitle = request.ShowTitle.Trim();
@ -34,13 +101,23 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe @@ -34,13 +101,23 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, showId, trimmedTitle, request.DeepScan));
return result
? new OkResult()
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
? Ok()
: BadRequest(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
}
return new BadRequestObjectResult(
return BadRequest(
new { error = $"Unable to locate show with title {request.ShowTitle} in library {id}" });
}
[HttpGet("/api/libraries/external-collections", Name = "GetExternalCollections")]
[Tags("Libraries")]
[EndpointSummary("Get external collections")]
public async Task<List<LibraryViewModel>> GetExternalCollections(CancellationToken cancellationToken) =>
await mediator.Send(new GetExternalCollections(), cancellationToken);
}
// Request models
public record ScanShowRequest(string ShowTitle, bool DeepScan = false);
public record CreateLocalLibraryRequest(string Name, LibraryMediaKind MediaKind, List<string>? Paths);
public record UpdateLocalLibraryPathRequest(int Id, string Path);
public record UpdateLocalLibraryRequest(string Name, List<UpdateLocalLibraryPathRequest>? Paths);

36
ErsatzTV/Controllers/Api/MaintenanceController.cs

@ -7,33 +7,41 @@ namespace ErsatzTV.Controllers.Api; @@ -7,33 +7,41 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class MaintenanceController(IMediator mediator)
public class MaintenanceController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/maintenance/gc")]
[HttpPost("/api/maintenance/gc", Name = "GarbageCollection")]
[Tags("Maintenance")]
[EndpointSummary("Garbage collect")]
public async Task<IActionResult> GarbageCollection([FromQuery] bool force = false)
{
await mediator.Send(new ReleaseMemory(force));
return new OkResult();
return Ok();
}
[HttpPost("/api/maintenance/empty_trash")]
[HttpPost("/api/maintenance/empty-trash", Name = "EmptyTrash")]
[Tags("Maintenance")]
[EndpointSummary("Empty trash")]
public async Task<IActionResult> EmptyTrash()
{
Either<BaseError, Unit> result = await mediator.Send(new EmptyTrash());
foreach (BaseError error in result.LeftToSeq())
{
return new ContentResult
{
StatusCode = StatusCodes.Status500InternalServerError,
Content = error.ToString(),
ContentType = "text/plain"
};
}
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
return new OkResult();
[HttpPost("/api/maintenance/delete-orphaned-artwork", Name = "DeleteOrphanedArtwork")]
[Tags("Maintenance")]
[EndpointSummary("Delete orphaned artwork")]
public async Task<IActionResult> DeleteOrphanedArtwork()
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteOrphanedArtwork());
return result.Match<IActionResult>(_ => Accepted(), error => Problem(error.ToString()));
}
[HttpPost("/api/maintenance/delete-orphaned-subtitles", Name = "DeleteOrphanedSubtitles")]
[Tags("Maintenance")]
[EndpointSummary("Delete orphaned subtitles")]
public async Task<IActionResult> DeleteOrphanedSubtitles()
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteOrphanedSubtitles());
return result.Match<IActionResult>(_ => Accepted(), error => Problem(error.ToString()));
}
}

73
ErsatzTV/Controllers/Api/MediaController.cs

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
using ErsatzTV.Application.Artists;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.Movies;
using ErsatzTV.Application.Television;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class MediaController(IMediator mediator) : ControllerBase
{
// Movies
[HttpGet("/api/media/movies/{id:int}", Name = "GetMovieById")]
[Tags("Media")]
[EndpointSummary("Get movie by ID")]
public async Task<IActionResult> GetMovieById(int id, CancellationToken cancellationToken)
{
Option<MovieViewModel> result = await mediator.Send(new GetMovieById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
// TV Shows
[HttpGet("/api/media/shows/{id:int}", Name = "GetShowById")]
[Tags("Media")]
[EndpointSummary("Get TV show by ID")]
public async Task<IActionResult> GetShowById(int id, CancellationToken cancellationToken)
{
Option<TelevisionShowViewModel> result = await mediator.Send(new GetTelevisionShowById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/media/shows/{id:int}/seasons", Name = "GetShowSeasons")]
[Tags("Media")]
[EndpointSummary("Get seasons for a TV show")]
public async Task<TelevisionSeasonCardResultsViewModel> GetShowSeasons(
int id,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new GetTelevisionSeasonCards(id, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/media/seasons/{id:int}", Name = "GetSeasonById")]
[Tags("Media")]
[EndpointSummary("Get season by ID")]
public async Task<IActionResult> GetSeasonById(int id, CancellationToken cancellationToken)
{
Option<TelevisionSeasonViewModel> result = await mediator.Send(new GetTelevisionSeasonById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/media/seasons/{id:int}/episodes", Name = "GetSeasonEpisodes")]
[Tags("Media")]
[EndpointSummary("Get episodes for a season")]
public async Task<TelevisionEpisodeCardResultsViewModel> GetSeasonEpisodes(
int id,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new GetTelevisionEpisodeCards(id, pageNumber, pageSize), cancellationToken);
// Artists
[HttpGet("/api/media/artists/{id:int}", Name = "GetArtistById")]
[Tags("Media")]
[EndpointSummary("Get artist by ID")]
public async Task<IActionResult> GetArtistById(int id, CancellationToken cancellationToken)
{
Option<ArtistViewModel> result = await mediator.Send(new GetArtistById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
}

133
ErsatzTV/Controllers/Api/PlaylistController.cs

@ -0,0 +1,133 @@ @@ -0,0 +1,133 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Tree;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class PlaylistController(IMediator mediator) : ControllerBase
{
// Playlist Groups
[HttpGet("/api/playlists/groups", Name = "GetPlaylistGroups")]
[Tags("Playlists")]
[EndpointSummary("Get all playlist groups")]
public async Task<List<PlaylistGroupViewModel>> GetPlaylistGroups(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
[HttpPost("/api/playlists/groups", Name = "CreatePlaylistGroup")]
[Tags("Playlists")]
[EndpointSummary("Create a playlist group")]
public async Task<IActionResult> CreatePlaylistGroup(
[Required] [FromBody] CreatePlaylistGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, PlaylistGroupViewModel> result = await mediator.Send(
new CreatePlaylistGroup(request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/playlists/groups/{id:int}", Name = "DeletePlaylistGroup")]
[Tags("Playlists")]
[EndpointSummary("Delete a playlist group")]
public async Task<IActionResult> DeletePlaylistGroup(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeletePlaylistGroup(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
// Playlists
[HttpGet("/api/playlists/groups/{groupId:int}/playlists", Name = "GetPlaylistsByGroup")]
[Tags("Playlists")]
[EndpointSummary("Get playlists by group")]
public async Task<List<PlaylistViewModel>> GetPlaylistsByGroup(int groupId, CancellationToken cancellationToken) =>
await mediator.Send(new GetPlaylistsByPlaylistGroupId(groupId), cancellationToken);
[HttpGet("/api/playlists/tree", Name = "GetPlaylistTree")]
[Tags("Playlists")]
[EndpointSummary("Get playlist tree")]
public async Task<TreeViewModel> GetPlaylistTree(CancellationToken cancellationToken) =>
await mediator.Send(new GetPlaylistTree(), cancellationToken);
[HttpGet("/api/playlists/{id:int}", Name = "GetPlaylistById")]
[Tags("Playlists")]
[EndpointSummary("Get playlist by ID")]
public async Task<IActionResult> GetPlaylistById(int id, CancellationToken cancellationToken)
{
Option<PlaylistViewModel> result = await mediator.Send(new GetPlaylistById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/playlists/{id:int}/items", Name = "GetPlaylistItems")]
[Tags("Playlists")]
[EndpointSummary("Get playlist items")]
public async Task<List<PlaylistItemViewModel>> GetPlaylistItems(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetPlaylistItems(id), cancellationToken);
[HttpPost("/api/playlists", Name = "CreatePlaylist")]
[Tags("Playlists")]
[EndpointSummary("Create a playlist")]
public async Task<IActionResult> CreatePlaylist(
[Required] [FromBody] CreatePlaylistRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, PlaylistViewModel> result = await mediator.Send(
new CreatePlaylist(request.PlaylistGroupId, request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")]
[Tags("Playlists")]
[EndpointSummary("Delete a playlist")]
public async Task<IActionResult> DeletePlaylist(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeletePlaylist(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
[HttpPut("/api/playlists/{id:int}/items", Name = "ReplacePlaylistItems")]
[Tags("Playlists")]
[EndpointSummary("Replace playlist items")]
public async Task<IActionResult> ReplacePlaylistItems(
int id,
[Required] [FromBody] ReplacePlaylistItemsRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?.Select(i => new ReplacePlaylistItem(
i.Index,
i.CollectionType,
i.CollectionId,
i.MultiCollectionId,
i.SmartCollectionId,
i.MediaItemId,
i.PlaybackOrder,
i.Count,
i.PlayAll,
i.IncludeInProgramGuide)).ToList() ?? [];
Either<BaseError, List<PlaylistItemViewModel>> result = await mediator.Send(
new ReplacePlaylistItems(id, request.Name, items), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
}
// Request models
public record CreatePlaylistGroupRequest(string Name);
public record CreatePlaylistRequest(int PlaylistGroupId, string Name);
public record ReplacePlaylistItemRequest(
int Index,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? Count,
bool PlayAll,
bool IncludeInProgramGuide);
public record ReplacePlaylistItemsRequest(string Name, List<ReplacePlaylistItemRequest>? Items);

162
ErsatzTV/Controllers/Api/PlayoutController.cs

@ -0,0 +1,162 @@ @@ -0,0 +1,162 @@
using System.ComponentModel.DataAnnotations;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class PlayoutController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator) : ControllerBase
{
[HttpGet("/api/playouts", Name = "GetAllPlayouts")]
[Tags("Playouts")]
[EndpointSummary("Get all playouts (paginated)")]
public async Task<PagedPlayoutsViewModel> GetAllPlayouts(
[FromQuery] string query = "",
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new GetPagedPlayouts(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/playouts/block", Name = "GetBlockPlayouts")]
[Tags("Playouts")]
[EndpointSummary("Get all block playouts")]
public async Task<List<PlayoutNameViewModel>> GetBlockPlayouts(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllBlockPlayouts(), cancellationToken);
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
[Tags("Playouts")]
[EndpointSummary("Get playout by ID")]
public async Task<IActionResult> GetPlayoutById(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> result = await mediator.Send(new GetPlayoutById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
[Tags("Playouts")]
[EndpointSummary("Get playout items")]
public async Task<PagedPlayoutItemsViewModel> GetPlayoutItems(
int id,
[FromQuery] bool showFiller = false,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new GetFuturePlayoutItemsById(id, showFiller, pageNumber, pageSize), cancellationToken);
[HttpPost("/api/playouts/classic", Name = "CreateClassicPlayout")]
[Tags("Playouts")]
[EndpointSummary("Create a classic playout")]
public async Task<IActionResult> CreateClassicPlayout(
[Required] [FromBody] CreateClassicPlayoutRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreatePlayoutResponse> result = await mediator.Send(
new CreateClassicPlayout(request.ChannelId, request.ProgramScheduleId), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPost("/api/playouts/block", Name = "CreateBlockPlayout")]
[Tags("Playouts")]
[EndpointSummary("Create a block playout")]
public async Task<IActionResult> CreateBlockPlayout(
[Required] [FromBody] CreateBlockPlayoutRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreatePlayoutResponse> result = await mediator.Send(
new CreateBlockPlayout(request.ChannelId), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPost("/api/playouts/sequential", Name = "CreateSequentialPlayout")]
[Tags("Playouts")]
[EndpointSummary("Create a sequential playout")]
public async Task<IActionResult> CreateSequentialPlayout(
[Required] [FromBody] CreateSequentialPlayoutRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreatePlayoutResponse> result = await mediator.Send(
new CreateSequentialPlayout(request.ChannelId, request.ScheduleFile), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPost("/api/playouts/scripted", Name = "CreateScriptedPlayout")]
[Tags("Playouts")]
[EndpointSummary("Create a scripted playout")]
public async Task<IActionResult> CreateScriptedPlayout(
[Required] [FromBody] CreateScriptedPlayoutRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreatePlayoutResponse> result = await mediator.Send(
new CreateScriptedPlayout(request.ChannelId, request.ScheduleFile), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPost("/api/playouts/external-json", Name = "CreateExternalJsonPlayout")]
[Tags("Playouts")]
[EndpointSummary("Create an external JSON playout")]
public async Task<IActionResult> CreateExternalJsonPlayout(
[Required] [FromBody] CreateExternalJsonPlayoutRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreatePlayoutResponse> result = await mediator.Send(
new CreateExternalJsonPlayout(request.ChannelId, request.ScheduleFile), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/playouts/{id:int}", Name = "DeletePlayout")]
[Tags("Playouts")]
[EndpointSummary("Delete a playout")]
public async Task<IActionResult> DeletePlayout(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
[HttpPost("/api/playouts/{id:int}/build", Name = "BuildPlayout")]
[Tags("Playouts")]
[EndpointSummary("Build a playout")]
public async Task<IActionResult> BuildPlayout(int id)
{
await workerChannel.WriteAsync(new BuildPlayout(id, PlayoutBuildMode.Refresh));
return Accepted();
}
[HttpPost("/api/playouts/{id:int}/reset", Name = "ResetPlayout")]
[Tags("Playouts")]
[EndpointSummary("Reset a playout")]
public async Task<IActionResult> ResetPlayout(int id)
{
await workerChannel.WriteAsync(new BuildPlayout(id, PlayoutBuildMode.Reset));
return Accepted();
}
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
[Tags("Playouts")]
[EndpointSummary("Reset all playouts")]
public async Task<IActionResult> ResetAllPlayouts(CancellationToken cancellationToken)
{
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
return Accepted();
}
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
[Tags("Playouts")]
[EndpointSummary("Get playout warnings count")]
public async Task<int> GetPlayoutWarningsCount(CancellationToken cancellationToken) =>
await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken);
}
// Request models
public record CreateClassicPlayoutRequest(int ChannelId, int ProgramScheduleId);
public record CreateBlockPlayoutRequest(int ChannelId);
public record CreateSequentialPlayoutRequest(int ChannelId, string ScheduleFile);
public record CreateScriptedPlayoutRequest(int ChannelId, string ScheduleFile);
public record CreateExternalJsonPlayoutRequest(int ChannelId, string ScheduleFile);

76
ErsatzTV/Controllers/Api/PlexController.cs

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Plex;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class PlexController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/plex/sources", Name = "GetPlexMediaSources")]
[Tags("Plex")]
[EndpointSummary("Get all Plex media sources")]
public async Task<List<PlexMediaSourceViewModel>> GetPlexMediaSources(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllPlexMediaSources(), cancellationToken);
[HttpGet("/api/plex/sources/{id:int}", Name = "GetPlexMediaSourceById")]
[Tags("Plex")]
[EndpointSummary("Get Plex media source by ID")]
public async Task<IActionResult> GetPlexMediaSourceById(int id, CancellationToken cancellationToken)
{
Option<PlexMediaSourceViewModel> result = await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/plex/sources/{id:int}/libraries", Name = "GetPlexLibraries")]
[Tags("Plex")]
[EndpointSummary("Get Plex libraries by source")]
public async Task<List<PlexLibraryViewModel>> GetPlexLibraries(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken);
[HttpGet("/api/plex/sources/{id:int}/path-replacements", Name = "GetPlexPathReplacements")]
[Tags("Plex")]
[EndpointSummary("Get Plex path replacements by source")]
public async Task<List<PlexPathReplacementViewModel>> GetPlexPathReplacements(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetPlexPathReplacementsBySourceId(id), cancellationToken);
[HttpPut("/api/plex/sources/{id:int}/path-replacements", Name = "UpdatePlexPathReplacements")]
[Tags("Plex")]
[EndpointSummary("Update Plex path replacements")]
public async Task<IActionResult> UpdatePlexPathReplacements(
int id,
[Required] [FromBody] UpdatePlexPathReplacementsRequest request,
CancellationToken cancellationToken)
{
var replacements = request.PathReplacements?.Select(p =>
new PlexPathReplacementItem(p.Id, p.PlexPath, p.LocalPath)).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new UpdatePlexPathReplacements(id, replacements), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
[HttpPut("/api/plex/library-preferences", Name = "UpdatePlexLibraryPreferences")]
[Tags("Plex")]
[EndpointSummary("Update Plex library preferences")]
public async Task<IActionResult> UpdatePlexLibraryPreferences(
[Required] [FromBody] UpdatePlexLibraryPreferencesRequest request,
CancellationToken cancellationToken)
{
var preferences = request.Preferences?.Select(p =>
new PlexLibraryPreference(p.Id, p.ShouldSyncItems)).ToList() ?? [];
Either<BaseError, Unit> result = await mediator.Send(
new UpdatePlexLibraryPreferences(preferences), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
}
}
// Request models
public record PlexPathReplacementRequest(int Id, string PlexPath, string LocalPath);
public record UpdatePlexPathReplacementsRequest(List<PlexPathReplacementRequest>? PathReplacements);
public record PlexLibraryPreferenceRequest(int Id, bool ShouldSyncItems);
public record UpdatePlexLibraryPreferencesRequest(List<PlexLibraryPreferenceRequest>? Preferences);

39
ErsatzTV/Controllers/Api/ResolutionController.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Resolutions;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@ -8,11 +10,42 @@ namespace ErsatzTV.Controllers.Api; @@ -8,11 +10,42 @@ 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/resolutions", Name = "GetAllResolutions")]
[Tags("Resolutions")]
[EndpointSummary("Get all resolutions")]
public async Task<List<ResolutionViewModel>> GetAllResolutions(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllResolutions(), cancellationToken);
[HttpGet("/api/resolutions/by-name/{name}", Name = "GetResolutionByName")]
[Tags("Resolutions")]
[EndpointSummary("Get resolution by name")]
public async Task<IActionResult> 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<IActionResult>(Ok, () => NotFound());
}
[HttpPost("/api/resolutions", Name = "CreateCustomResolution")]
[Tags("Resolutions")]
[EndpointSummary("Create a custom resolution")]
public async Task<IActionResult> CreateCustomResolution(
[Required] [FromBody] CreateCustomResolutionRequest request,
CancellationToken cancellationToken)
{
Option<Core.BaseError> result = await mediator.Send(
new CreateCustomResolution(request.Width, request.Height), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => Ok());
}
[HttpDelete("/api/resolutions/{id:int}", Name = "DeleteCustomResolution")]
[Tags("Resolutions")]
[EndpointSummary("Delete a custom resolution")]
public async Task<IActionResult> DeleteCustomResolution(int id, CancellationToken cancellationToken)
{
Option<Core.BaseError> result = await mediator.Send(new DeleteCustomResolution(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
}
// Request models
public record CreateCustomResolutionRequest(int Width, int Height);

97
ErsatzTV/Controllers/Api/ScheduleController.cs

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class ScheduleController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/schedules", Name = "GetAllSchedules")]
[Tags("Schedules")]
[EndpointSummary("Get all schedules (paginated)")]
public async Task<PagedProgramSchedulesViewModel> GetAllSchedules(
[FromQuery] string query = "",
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new GetPagedProgramSchedules(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/schedules/all", Name = "GetAllSchedulesList")]
[Tags("Schedules")]
[EndpointSummary("Get all schedules")]
public async Task<List<ProgramScheduleViewModel>> GetAllSchedulesList(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
[HttpGet("/api/schedules/{id:int}", Name = "GetScheduleById")]
[Tags("Schedules")]
[EndpointSummary("Get schedule by ID")]
public async Task<IActionResult> GetScheduleById(int id, CancellationToken cancellationToken)
{
Option<ProgramScheduleViewModel> result = await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/schedules/{id:int}/items", Name = "GetScheduleItems")]
[Tags("Schedules")]
[EndpointSummary("Get schedule items")]
public async Task<List<ProgramScheduleItemViewModel>> GetScheduleItems(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
[HttpPost("/api/schedules", Name = "CreateSchedule")]
[Tags("Schedules")]
[EndpointSummary("Create a schedule")]
public async Task<IActionResult> CreateSchedule(
[Required] [FromBody] CreateScheduleRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateProgramScheduleResult> result = await mediator.Send(
new CreateProgramSchedule(
request.Name,
request.KeepMultiPartEpisodesTogether,
request.TreatCollectionsAsShows,
request.ShuffleScheduleItems,
request.RandomStartPoint,
request.FixedStartTimeBehavior),
cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/schedules/{id:int}", Name = "DeleteSchedule")]
[Tags("Schedules")]
[EndpointSummary("Delete a schedule")]
public async Task<IActionResult> DeleteSchedule(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteProgramSchedule(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
[HttpPost("/api/schedules/{id:int}/copy", Name = "CopySchedule")]
[Tags("Schedules")]
[EndpointSummary("Copy a schedule")]
public async Task<IActionResult> CopySchedule(
int id,
[Required] [FromBody] CopyScheduleRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ProgramScheduleViewModel> result = await mediator.Send(
new CopyProgramSchedule(id, request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
}
// Request models
public record CreateScheduleRequest(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior);
public record CopyScheduleRequest(string Name);

98
ErsatzTV/Controllers/Api/SearchController.cs

@ -0,0 +1,98 @@ @@ -0,0 +1,98 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.Search;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class SearchController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/search", Name = "SearchAll")]
[Tags("Search")]
[EndpointSummary("Search all media types")]
public async Task<SearchResultAllItemsViewModel> SearchAll(
[FromQuery] string query,
CancellationToken cancellationToken) =>
await mediator.Send(new QuerySearchIndexAllItems(query), cancellationToken);
[HttpGet("/api/search/movies", Name = "SearchMovies")]
[Tags("Search")]
[EndpointSummary("Search movies")]
public async Task<MovieCardResultsViewModel> SearchMovies(
[FromQuery] string query,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new QuerySearchIndexMovies(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/search/shows", Name = "SearchShows")]
[Tags("Search")]
[EndpointSummary("Search TV shows")]
public async Task<TelevisionShowCardResultsViewModel> SearchShows(
[FromQuery] string query,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new QuerySearchIndexShows(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/search/episodes", Name = "SearchEpisodes")]
[Tags("Search")]
[EndpointSummary("Search episodes")]
public async Task<TelevisionEpisodeCardResultsViewModel> SearchEpisodes(
[FromQuery] string query,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new QuerySearchIndexEpisodes(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/search/artists", Name = "SearchArtists")]
[Tags("Search")]
[EndpointSummary("Search artists")]
public async Task<ArtistCardResultsViewModel> SearchArtists(
[FromQuery] string query,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new QuerySearchIndexArtists(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/search/music-videos", Name = "SearchMusicVideos")]
[Tags("Search")]
[EndpointSummary("Search music videos")]
public async Task<MusicVideoCardResultsViewModel> SearchMusicVideos(
[FromQuery] string query,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new QuerySearchIndexMusicVideos(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/search/songs", Name = "SearchSongs")]
[Tags("Search")]
[EndpointSummary("Search songs")]
public async Task<SongCardResultsViewModel> SearchSongs(
[FromQuery] string query,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new QuerySearchIndexSongs(query, pageNumber, pageSize), cancellationToken);
[HttpGet("/api/search/images", Name = "SearchImages")]
[Tags("Search")]
[EndpointSummary("Search images")]
public async Task<ImageCardResultsViewModel> SearchImages(
[FromQuery] string query,
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new QuerySearchIndexImages(query, pageNumber, pageSize), cancellationToken);
[HttpPost("/api/search/rebuild", Name = "RebuildSearchIndex")]
[Tags("Search")]
[EndpointSummary("Rebuild search index")]
public async Task<IActionResult> RebuildSearchIndex(CancellationToken cancellationToken)
{
await mediator.Send(new RebuildSearchIndex(), cancellationToken);
return Accepted();
}
}

125
ErsatzTV/Controllers/Api/TemplateController.cs

@ -0,0 +1,125 @@ @@ -0,0 +1,125 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class TemplateController(IMediator mediator) : ControllerBase
{
// Template Groups
[HttpGet("/api/templates/groups", Name = "GetTemplateGroups")]
[Tags("Templates")]
[EndpointSummary("Get all template groups")]
public async Task<List<TemplateGroupViewModel>> GetTemplateGroups(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllTemplateGroups(), cancellationToken);
[HttpPost("/api/templates/groups", Name = "CreateTemplateGroup")]
[Tags("Templates")]
[EndpointSummary("Create a template group")]
public async Task<IActionResult> CreateTemplateGroup(
[Required] [FromBody] CreateTemplateGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, TemplateGroupViewModel> result = await mediator.Send(
new CreateTemplateGroup(request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/templates/groups/{id:int}", Name = "DeleteTemplateGroup")]
[Tags("Templates")]
[EndpointSummary("Delete a template group")]
public async Task<IActionResult> DeleteTemplateGroup(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteTemplateGroup(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
// Templates
[HttpGet("/api/templates/groups/{groupId:int}/templates", Name = "GetTemplatesByGroup")]
[Tags("Templates")]
[EndpointSummary("Get templates by group")]
public async Task<List<TemplateViewModel>> GetTemplatesByGroup(int groupId, CancellationToken cancellationToken) =>
await mediator.Send(new GetTemplatesByTemplateGroupId(groupId), cancellationToken);
[HttpGet("/api/templates", Name = "GetAllTemplates")]
[Tags("Templates")]
[EndpointSummary("Get all templates")]
public async Task<List<TemplateViewModel>> GetAllTemplates(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllTemplates(), cancellationToken);
[HttpGet("/api/templates/{id:int}", Name = "GetTemplateById")]
[Tags("Templates")]
[EndpointSummary("Get template by ID")]
public async Task<IActionResult> GetTemplateById(int id, CancellationToken cancellationToken)
{
Option<TemplateViewModel> result = await mediator.Send(new GetTemplateById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpGet("/api/templates/{id:int}/items", Name = "GetTemplateItems")]
[Tags("Templates")]
[EndpointSummary("Get template items")]
public async Task<List<TemplateItemViewModel>> GetTemplateItems(int id, CancellationToken cancellationToken) =>
await mediator.Send(new GetTemplateItems(id), cancellationToken);
[HttpPost("/api/templates", Name = "CreateTemplate")]
[Tags("Templates")]
[EndpointSummary("Create a template")]
public async Task<IActionResult> CreateTemplate(
[Required] [FromBody] CreateTemplateRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, TemplateViewModel> result = await mediator.Send(
new CreateTemplate(request.TemplateGroupId, request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/templates/{id:int}", Name = "DeleteTemplate")]
[Tags("Templates")]
[EndpointSummary("Delete a template")]
public async Task<IActionResult> DeleteTemplate(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteTemplate(id), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => NoContent());
}
[HttpPost("/api/templates/{id:int}/copy", Name = "CopyTemplate")]
[Tags("Templates")]
[EndpointSummary("Copy a template")]
public async Task<IActionResult> CopyTemplate(
int id,
[Required] [FromBody] CopyTemplateRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, TemplateViewModel> result = await mediator.Send(
new CopyTemplate(id, request.NewTemplateGroupId, request.NewTemplateName), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/templates/{id:int}/items", Name = "ReplaceTemplateItems")]
[Tags("Templates")]
[EndpointSummary("Replace template items")]
public async Task<IActionResult> ReplaceTemplateItems(
int id,
[Required] [FromBody] ReplaceTemplateItemsRequest request,
CancellationToken cancellationToken)
{
var items = request.Items.Select(i => new ReplaceTemplateItem(i.BlockId, i.StartTime)).ToList();
Either<BaseError, List<TemplateItemViewModel>> result = await mediator.Send(
new ReplaceTemplateItems(request.TemplateGroupId, id, request.Name, items), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
}
// Request models
public record CreateTemplateGroupRequest(string Name);
public record CreateTemplateRequest(int TemplateGroupId, string Name);
public record CopyTemplateRequest(int NewTemplateGroupId, string NewTemplateName);
public record ReplaceTemplateItemRequest(int BlockId, TimeSpan StartTime);
public record ReplaceTemplateItemsRequest(int TemplateGroupId, string Name, List<ReplaceTemplateItemRequest> Items);

65
ErsatzTV/Controllers/Api/TraktController.cs

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class TraktController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/trakt/lists", Name = "GetTraktLists")]
[Tags("Trakt")]
[EndpointSummary("Get all Trakt lists (paginated)")]
public async Task<PagedTraktListsViewModel> GetTraktLists(
[FromQuery] int pageNumber = 0,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default) =>
await mediator.Send(new GetPagedTraktLists(pageNumber, pageSize), cancellationToken);
[HttpGet("/api/trakt/lists/{id:int}", Name = "GetTraktListById")]
[Tags("Trakt")]
[EndpointSummary("Get Trakt list by ID")]
public async Task<IActionResult> GetTraktListById(int id, CancellationToken cancellationToken)
{
Option<TraktListViewModel> result = await mediator.Send(new GetTraktListById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpPut("/api/trakt/lists/{id:int}", Name = "UpdateTraktList")]
[Tags("Trakt")]
[EndpointSummary("Update a Trakt list")]
public async Task<IActionResult> UpdateTraktList(
int id,
[Required] [FromBody] UpdateTraktListRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(
new UpdateTraktList(id, request.AutoRefresh, request.GeneratePlaylist), cancellationToken);
return result.Match<IActionResult>(error => Problem(error.ToString()), () => Ok());
}
[HttpDelete("/api/trakt/lists/{id:int}", Name = "DeleteTraktList")]
[Tags("Trakt")]
[EndpointSummary("Delete a Trakt list")]
public async Task<IActionResult> DeleteTraktList(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteTraktList(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
[HttpPost("/api/trakt/lists/{id:int}/match", Name = "MatchTraktListItems")]
[Tags("Trakt")]
[EndpointSummary("Match Trakt list items")]
public async Task<IActionResult> MatchTraktListItems(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new MatchTraktListItems(id), cancellationToken);
return result.Match<IActionResult>(_ => Accepted(), error => Problem(error.ToString()));
}
}
// Request models
public record UpdateTraktListRequest(bool AutoRefresh, bool GeneratePlaylist);

159
ErsatzTV/Controllers/Api/WatermarkController.cs

@ -0,0 +1,159 @@ @@ -0,0 +1,159 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class WatermarkController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/watermarks", Name = "GetAllWatermarks")]
[Tags("Watermarks")]
[EndpointSummary("Get all watermarks")]
public async Task<List<WatermarkViewModel>> GetAllWatermarks(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllWatermarks(), cancellationToken);
[HttpGet("/api/watermarks/{id:int}", Name = "GetWatermarkById")]
[Tags("Watermarks")]
[EndpointSummary("Get watermark by ID")]
public async Task<IActionResult> GetWatermarkById(int id, CancellationToken cancellationToken)
{
Option<WatermarkViewModel> result = await mediator.Send(new GetWatermarkById(id), cancellationToken);
return result.Match<IActionResult>(Ok, () => NotFound());
}
[HttpPost("/api/watermarks", Name = "CreateWatermark")]
[Tags("Watermarks")]
[EndpointSummary("Create a watermark")]
public async Task<IActionResult> CreateWatermark(
[Required] [FromBody] CreateWatermarkRequest request,
CancellationToken cancellationToken)
{
var image = request.ImagePath != null
? new ArtworkContentTypeModel(request.ImagePath, request.ImageContentType ?? "")
: ArtworkContentTypeModel.None;
Either<BaseError, CreateWatermarkResult> result = await mediator.Send(
new CreateWatermark(
request.Name,
image,
request.Mode,
request.ImageSource,
request.Location,
request.Size,
request.Width,
request.HorizontalMargin,
request.VerticalMargin,
request.FrequencyMinutes,
request.DurationSeconds,
request.Opacity,
request.PlaceWithinSourceContent,
request.OpacityExpression ?? "",
request.ZIndex),
cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpPut("/api/watermarks/{id:int}", Name = "UpdateWatermark")]
[Tags("Watermarks")]
[EndpointSummary("Update a watermark")]
public async Task<IActionResult> UpdateWatermark(
int id,
[Required] [FromBody] UpdateWatermarkRequest request,
CancellationToken cancellationToken)
{
var image = request.ImagePath != null
? new ArtworkContentTypeModel(request.ImagePath, request.ImageContentType ?? "")
: ArtworkContentTypeModel.None;
Either<BaseError, UpdateWatermarkResult> result = await mediator.Send(
new UpdateWatermark(
id,
request.Name,
image,
request.Mode,
request.ImageSource,
request.Location,
request.Size,
request.Width,
request.HorizontalMargin,
request.VerticalMargin,
request.FrequencyMinutes,
request.DurationSeconds,
request.Opacity,
request.PlaceWithinSourceContent,
request.OpacityExpression ?? "",
request.ZIndex),
cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
[HttpDelete("/api/watermarks/{id:int}", Name = "DeleteWatermark")]
[Tags("Watermarks")]
[EndpointSummary("Delete a watermark")]
public async Task<IActionResult> DeleteWatermark(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteWatermark(id), cancellationToken);
return result.Match<IActionResult>(_ => NoContent(), error => Problem(error.ToString()));
}
[HttpPost("/api/watermarks/{id:int}/copy", Name = "CopyWatermark")]
[Tags("Watermarks")]
[EndpointSummary("Copy a watermark")]
public async Task<IActionResult> CopyWatermark(
int id,
[Required] [FromBody] CopyWatermarkRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, WatermarkViewModel> result = await mediator.Send(
new CopyWatermark(id, request.Name), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
}
}
// Request models
public record CreateWatermarkRequest(
string Name,
string? ImagePath,
string? ImageContentType,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
WatermarkLocation Location,
WatermarkSize Size,
double Width,
double HorizontalMargin,
double VerticalMargin,
int FrequencyMinutes,
int DurationSeconds,
int Opacity,
bool PlaceWithinSourceContent,
string? OpacityExpression,
int ZIndex);
public record UpdateWatermarkRequest(
string Name,
string? ImagePath,
string? ImageContentType,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
WatermarkLocation Location,
WatermarkSize Size,
double Width,
double HorizontalMargin,
double VerticalMargin,
int FrequencyMinutes,
int DurationSeconds,
int Opacity,
bool PlaceWithinSourceContent,
string? OpacityExpression,
int ZIndex);
public record CopyWatermarkRequest(string Name);
Loading…
Cancel
Save