diff --git a/ErsatzTV/Controllers/Api/BlockController.cs b/ErsatzTV/Controllers/Api/BlockController.cs new file mode 100644 index 000000000..142969761 --- /dev/null +++ b/ErsatzTV/Controllers/Api/BlockController.cs @@ -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> 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 CreateBlockGroup( + [Required] [FromBody] CreateBlockGroupRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateBlockGroup(request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/blocks/groups/{id:int}", Name = "DeleteBlockGroup")] + [Tags("Blocks")] + [EndpointSummary("Delete a block group")] + public async Task DeleteBlockGroup(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteBlockGroup(id), cancellationToken); + return result.Match(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> 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> 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 GetBlockById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetBlockById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/blocks/{id:int}/items", Name = "GetBlockItems")] + [Tags("Blocks")] + [EndpointSummary("Get block items")] + public async Task> 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 CreateBlock( + [Required] [FromBody] CreateBlockRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateBlock(request.BlockGroupId, request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/blocks/{id:int}", Name = "DeleteBlock")] + [Tags("Blocks")] + [EndpointSummary("Delete a block")] + public async Task DeleteBlock(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteBlock(id), cancellationToken); + return result.Match(error => Problem(error.ToString()), () => NoContent()); + } + + [HttpPost("/api/blocks/{id:int}/copy", Name = "CopyBlock")] + [Tags("Blocks")] + [EndpointSummary("Copy a block")] + public async Task CopyBlock( + int id, + [Required] [FromBody] CopyBlockRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CopyBlock(id, request.NewBlockGroupId, request.NewBlockName), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPut("/api/blocks/{id:int}/items", Name = "ReplaceBlockItems")] + [Tags("Blocks")] + [EndpointSummary("Replace block items")] + public async Task 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 result = await mediator.Send( + new ReplaceBlockItems(request.BlockGroupId, id, request.Name, request.Minutes, request.StopScheduling, items), + cancellationToken); + return result.Match(_ => 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? WatermarkIds, + List? GraphicsElementIds); +public record ReplaceBlockItemsRequest( + int BlockGroupId, + string Name, + int Minutes, + BlockStopScheduling StopScheduling, + List? Items); diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index baa1edb5d..b35b4ddae 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -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 workerChannel, IMediator mediator) +[EndpointGroupName("general")] +public class ChannelController(ChannelWriter workerChannel, IMediator mediator) : ControllerBase { - [HttpGet("/api/channels")] - [EndpointGroupName("general")] - public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi()); + [HttpGet("/api/channels", Name = "GetAllChannels")] + [Tags("Channels")] + [EndpointSummary("Get all channels")] + public async Task> 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 GetChannelById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetChannelById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/channels/by-number/{channelNumber}", Name = "GetChannelByNumber")] + [Tags("Channels")] + [EndpointSummary("Get channel by number")] + public async Task GetChannelByNumber(string channelNumber, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetChannelByNumber(channelNumber), cancellationToken); + return result.Match(Ok, () => NotFound()); + } - [HttpPost("/api/channels/{channelNumber}/playout/reset")] + [HttpPost("/api/channels", Name = "CreateChannel")] + [Tags("Channels")] + [EndpointSummary("Create a channel")] + public async Task CreateChannel( + [Required] [FromBody] CreateChannelRequest request, + CancellationToken cancellationToken) + { + var logo = request.LogoPath != null + ? new ArtworkContentTypeModel(request.LogoPath, request.LogoContentType ?? "") + : ArtworkContentTypeModel.None; + + Either 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(Ok, error => Problem(error.ToString())); + } + + [HttpPut("/api/channels/{id:int}", Name = "UpdateChannel")] + [Tags("Channels")] + [EndpointSummary("Update a channel")] + public async Task UpdateChannel( + int id, + [Required] [FromBody] UpdateChannelRequest request, + CancellationToken cancellationToken) + { + var logo = request.LogoPath != null + ? new ArtworkContentTypeModel(request.LogoPath, request.LogoContentType ?? "") + : ArtworkContentTypeModel.None; + + Either 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(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/channels/{id:int}", Name = "DeleteChannel")] + [Tags("Channels")] + [EndpointSummary("Delete a channel")] + public async Task DeleteChannel(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteChannel(id), cancellationToken); + return result.Match(_ => NoContent(), error => Problem(error.ToString())); + } + + [HttpPost("/api/channels/{channelNumber}/playout/reset", Name = "ResetChannelPlayout")] [Tags("Channels")] [EndpointSummary("Reset channel playout")] - [EndpointGroupName("general")] public async Task ResetPlayout(string channelNumber) { Option 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 ContinuePlayout(string channelNumber, [FromQuery] int days = 1) - // { - // Option 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 BuildPlayout(string channelNumber) + { + Option 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); diff --git a/ErsatzTV/Controllers/Api/CollectionController.cs b/ErsatzTV/Controllers/Api/CollectionController.cs new file mode 100644 index 000000000..19f61a2c7 --- /dev/null +++ b/ErsatzTV/Controllers/Api/CollectionController.cs @@ -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 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> 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 GetCollectionById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetCollectionById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")] + [Tags("Collections")] + [EndpointSummary("Get collection items")] + public async Task GetCollectionItems(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new GetCollectionCards(id), cancellationToken); + return result.Match(Ok, error => NotFound()); + } + + [HttpPost("/api/collections", Name = "CreateCollection")] + [Tags("Collections")] + [EndpointSummary("Create a new collection")] + public async Task CreateCollection( + [Required] [FromBody] CreateCollectionRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(new CreateCollection(request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPut("/api/collections/{id:int}", Name = "UpdateCollection")] + [Tags("Collections")] + [EndpointSummary("Update a collection")] + public async Task UpdateCollection( + int id, + [Required] [FromBody] UpdateCollectionRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(new UpdateCollection(id, request.Name), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpDelete("/api/collections/{id:int}", Name = "DeleteCollection")] + [Tags("Collections")] + [EndpointSummary("Delete a collection")] + public async Task DeleteCollection(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteCollection(id), cancellationToken); + return result.Match(_ => NoContent(), error => Problem(error.ToString())); + } + + [HttpPost("/api/collections/{id:int}/items", Name = "AddItemsToCollection")] + [Tags("Collections")] + [EndpointSummary("Add items to a collection")] + public async Task AddItemsToCollection( + int id, + [Required] [FromBody] AddItemsToCollectionRequest request, + CancellationToken cancellationToken) + { + Either 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(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpDelete("/api/collections/{id:int}/items", Name = "RemoveItemsFromCollection")] + [Tags("Collections")] + [EndpointSummary("Remove items from a collection")] + public async Task RemoveItemsFromCollection( + int id, + [Required] [FromBody] RemoveItemsFromCollectionRequest request, + CancellationToken cancellationToken) + { + var command = new RemoveItemsFromCollection(id) { MediaItemIds = request.MediaItemIds ?? [] }; + Either result = await mediator.Send(command, cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + // Multi-collections + [HttpGet("/api/collections/multi", Name = "GetMultiCollections")] + [Tags("Collections")] + [EndpointSummary("Get all multi-collections")] + public async Task> 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 GetMultiCollectionById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetMultiCollectionById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpPost("/api/collections/multi", Name = "CreateMultiCollection")] + [Tags("Collections")] + [EndpointSummary("Create a multi-collection")] + public async Task 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 result = await mediator.Send( + new CreateMultiCollection(request.Name, items), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPut("/api/collections/multi/{id:int}", Name = "UpdateMultiCollection")] + [Tags("Collections")] + [EndpointSummary("Update a multi-collection")] + public async Task 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 result = await mediator.Send( + new UpdateMultiCollection(id, request.Name, items), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpDelete("/api/collections/multi/{id:int}", Name = "DeleteMultiCollection")] + [Tags("Collections")] + [EndpointSummary("Delete a multi-collection")] + public async Task DeleteMultiCollection(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteMultiCollection(id), cancellationToken); + return result.Match(_ => NoContent(), error => Problem(error.ToString())); + } +} + +// Request models +public record CreateCollectionRequest(string Name); +public record UpdateCollectionRequest(string Name); +public record AddItemsToCollectionRequest( + List? MovieIds, + List? ShowIds, + List? SeasonIds, + List? EpisodeIds, + List? ArtistIds, + List? MusicVideoIds, + List? OtherVideoIds, + List? SongIds, + List? ImageIds, + List? RemoteStreamIds); +public record RemoveItemsFromCollectionRequest(List? MediaItemIds); +public record CreateMultiCollectionItemRequest(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder); +public record CreateMultiCollectionRequest(string Name, List? Items); +public record UpdateMultiCollectionItemRequest(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder); +public record UpdateMultiCollectionRequest(string Name, List? Items); diff --git a/ErsatzTV/Controllers/Api/ConfigController.cs b/ErsatzTV/Controllers/Api/ConfigController.cs new file mode 100644 index 000000000..d5e5918af --- /dev/null +++ b/ErsatzTV/Controllers/Api/ConfigController.cs @@ -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 GetPlayoutSettings(CancellationToken cancellationToken) => + await mediator.Send(new GetPlayoutSettings(), cancellationToken); + + [HttpPut("/api/config/playout", Name = "UpdatePlayoutSettings")] + [Tags("Configuration")] + [EndpointSummary("Update playout settings")] + public async Task UpdatePlayoutSettings( + [Required] [FromBody] PlayoutSettingsViewModel settings, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(new UpdatePlayoutSettings(settings), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpGet("/api/config/xmltv", Name = "GetXmltvSettings")] + [Tags("Configuration")] + [EndpointSummary("Get XMLTV settings")] + public async Task GetXmltvSettings(CancellationToken cancellationToken) => + await mediator.Send(new GetXmltvSettings(), cancellationToken); + + [HttpPut("/api/config/xmltv", Name = "UpdateXmltvSettings")] + [Tags("Configuration")] + [EndpointSummary("Update XMLTV settings")] + public async Task UpdateXmltvSettings( + [Required] [FromBody] XmltvSettingsViewModel settings, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(new UpdateXmltvSettings(settings), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpGet("/api/config/logging", Name = "GetLoggingSettings")] + [Tags("Configuration")] + [EndpointSummary("Get logging settings")] + public async Task GetLoggingSettings(CancellationToken cancellationToken) => + await mediator.Send(new GetLoggingSettings(), cancellationToken); + + [HttpPut("/api/config/logging", Name = "UpdateLoggingSettings")] + [Tags("Configuration")] + [EndpointSummary("Update logging settings")] + public async Task UpdateLoggingSettings( + [Required] [FromBody] LoggingSettingsViewModel settings, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(new UpdateLoggingSettings(settings), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpGet("/api/config/library-refresh-interval", Name = "GetLibraryRefreshInterval")] + [Tags("Configuration")] + [EndpointSummary("Get library refresh interval")] + public async Task 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 UpdateLibraryRefreshInterval( + [Required] [FromBody] UpdateLibraryRefreshIntervalRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new UpdateLibraryRefreshInterval(request.Interval), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } +} + +// Request models +public record UpdateLibraryRefreshIntervalRequest(int Interval); diff --git a/ErsatzTV/Controllers/Api/DecoController.cs b/ErsatzTV/Controllers/Api/DecoController.cs new file mode 100644 index 000000000..f85fa9780 --- /dev/null +++ b/ErsatzTV/Controllers/Api/DecoController.cs @@ -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> 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 CreateDecoGroup( + [Required] [FromBody] CreateDecoGroupRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateDecoGroup(request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/decos/groups/{id:int}", Name = "DeleteDecoGroup")] + [Tags("Decos")] + [EndpointSummary("Delete a deco group")] + public async Task DeleteDecoGroup(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteDecoGroup(id), cancellationToken); + return result.Match(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> 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 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 GetDecoById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetDecoById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpPost("/api/decos", Name = "CreateDeco")] + [Tags("Decos")] + [EndpointSummary("Create a deco")] + public async Task CreateDeco( + [Required] [FromBody] CreateDecoRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateDeco(request.DecoGroupId, request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPut("/api/decos/{id:int}", Name = "UpdateDeco")] + [Tags("Decos")] + [EndpointSummary("Update a deco")] + public async Task 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 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(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpDelete("/api/decos/{id:int}", Name = "DeleteDeco")] + [Tags("Decos")] + [EndpointSummary("Delete a deco")] + public async Task DeleteDeco(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteDeco(id), cancellationToken); + return result.Match(error => Problem(error.ToString()), () => NoContent()); + } + + [HttpPut("/api/playouts/{playoutId:int}/deco", Name = "UpdateDefaultDeco")] + [Tags("Decos")] + [EndpointSummary("Update default deco for playout")] + public async Task UpdateDefaultDeco( + int playoutId, + [Required] [FromBody] UpdateDefaultDecoRequest request, + CancellationToken cancellationToken) + { + Option result = await mediator.Send( + new UpdateDefaultDeco(playoutId, request.DecoId), cancellationToken); + return result.Match(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> 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 CreateDecoTemplateGroup( + [Required] [FromBody] CreateDecoTemplateGroupRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateDecoTemplateGroup(request.Name), cancellationToken); + return result.Match(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 DeleteDecoTemplateGroup(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteDecoTemplateGroup(id), cancellationToken); + return result.Match(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> 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 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 GetDecoTemplateById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetDecoTemplateById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/decos/templates/{id:int}/items", Name = "GetDecoTemplateItems")] + [Tags("Decos")] + [EndpointSummary("Get deco template items")] + public async Task> 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 CreateDecoTemplate( + [Required] [FromBody] CreateDecoTemplateRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateDecoTemplate(request.DecoTemplateGroupId, request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/decos/templates/{id:int}", Name = "DeleteDecoTemplate")] + [Tags("Decos")] + [EndpointSummary("Delete a deco template")] + public async Task DeleteDecoTemplate(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteDecoTemplate(id), cancellationToken); + return result.Match(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? WatermarkIds, + bool UseWatermarkDuringFiller, + DecoMode GraphicsElementsMode, + List? GraphicsElementIds, + bool UseGraphicsElementsDuringFiller, + DecoMode BreakContentMode, + List? 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); diff --git a/ErsatzTV/Controllers/Api/EmbyController.cs b/ErsatzTV/Controllers/Api/EmbyController.cs new file mode 100644 index 000000000..902adcdcc --- /dev/null +++ b/ErsatzTV/Controllers/Api/EmbyController.cs @@ -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> 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 GetEmbyMediaSourceById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/emby/sources/{id:int}/libraries", Name = "GetEmbyLibraries")] + [Tags("Emby")] + [EndpointSummary("Get Emby libraries by source")] + public async Task> 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> 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 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 result = await mediator.Send( + new UpdateEmbyPathReplacements(id, replacements), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpPut("/api/emby/library-preferences", Name = "UpdateEmbyLibraryPreferences")] + [Tags("Emby")] + [EndpointSummary("Update Emby library preferences")] + public async Task UpdateEmbyLibraryPreferences( + [Required] [FromBody] UpdateEmbyLibraryPreferencesRequest request, + CancellationToken cancellationToken) + { + var preferences = request.Preferences?.Select(p => + new EmbyLibraryPreference(p.Id, p.ShouldSyncItems)).ToList() ?? []; + Either result = await mediator.Send( + new UpdateEmbyLibraryPreferences(preferences), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpGet("/api/emby/secrets", Name = "GetEmbySecrets")] + [Tags("Emby")] + [EndpointSummary("Get Emby secrets")] + public async Task GetEmbySecrets(CancellationToken cancellationToken) => + await mediator.Send(new GetEmbySecrets(), cancellationToken); +} + +// Request models +public record EmbyPathReplacementRequest(int Id, string EmbyPath, string LocalPath); +public record UpdateEmbyPathReplacementsRequest(List? PathReplacements); +public record EmbyLibraryPreferenceRequest(int Id, bool ShouldSyncItems); +public record UpdateEmbyLibraryPreferencesRequest(List? Preferences); diff --git a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs index 2dfda93b8..7f567d882 100644 --- a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs +++ b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs @@ -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; [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> 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 GetFFmpegProfileById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetFFmpegProfileById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpPost("/api/ffmpeg/profiles/new", Name = "CreateFFmpegProfile")] + [Tags("FFmpeg")] + [EndpointSummary("Create FFmpeg profile")] public async Task AddOne( [Required] [FromBody] CreateFFmpegProfile request, CancellationToken cancellationToken) { - Either result = await mediator.Send(request, cancellationToken); + Either result = await mediator.Send(request, cancellationToken); return result.Match(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 UpdateOne( [Required] [FromBody] UpdateFFmpegProfile request, @@ -35,10 +51,45 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase return result.Match(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 DeleteProfileAsync(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteFFmpegProfile(id), cancellationToken); - return result.Match(_ => Ok(), error => Conflict(error.ToString())); + return result.Match(_ => NoContent(), error => Conflict(error.ToString())); + } + + [HttpPost("/api/ffmpeg/profiles/{id:int}/copy", Name = "CopyFFmpegProfile")] + [Tags("FFmpeg")] + [EndpointSummary("Copy FFmpeg profile")] + public async Task CopyFFmpegProfile( + int id, + [Required] [FromBody] CopyFFmpegProfileRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CopyFFmpegProfile(id, request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpGet("/api/ffmpeg/settings", Name = "GetFFmpegSettings")] + [Tags("FFmpeg")] + [EndpointSummary("Get FFmpeg settings")] + public async Task GetFFmpegSettings(CancellationToken cancellationToken) => + await mediator.Send(new GetFFmpegSettings(), cancellationToken); + + [HttpPut("/api/ffmpeg/settings", Name = "UpdateFFmpegSettings")] + [Tags("FFmpeg")] + [EndpointSummary("Update FFmpeg settings")] + public async Task UpdateFFmpegSettings( + [Required] [FromBody] FFmpegSettingsViewModel settings, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(new UpdateFFmpegSettings(settings), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); } } + +// Request models +public record CopyFFmpegProfileRequest(string Name); diff --git a/ErsatzTV/Controllers/Api/FillerController.cs b/ErsatzTV/Controllers/Api/FillerController.cs new file mode 100644 index 000000000..40990501a --- /dev/null +++ b/ErsatzTV/Controllers/Api/FillerController.cs @@ -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 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> 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 GetFillerPresetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetFillerPresetById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpPost("/api/filler/presets", Name = "CreateFillerPreset")] + [Tags("Filler")] + [EndpointSummary("Create a filler preset")] + public async Task CreateFillerPreset( + [Required] [FromBody] CreateFillerPresetRequest request, + CancellationToken cancellationToken) + { + Either 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(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpPut("/api/filler/presets/{id:int}", Name = "UpdateFillerPreset")] + [Tags("Filler")] + [EndpointSummary("Update a filler preset")] + public async Task UpdateFillerPreset( + int id, + [Required] [FromBody] UpdateFillerPresetRequest request, + CancellationToken cancellationToken) + { + Either 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(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpDelete("/api/filler/presets/{id:int}", Name = "DeleteFillerPreset")] + [Tags("Filler")] + [EndpointSummary("Delete a filler preset")] + public async Task DeleteFillerPreset(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteFillerPreset(id), cancellationToken); + return result.Match(_ => 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); diff --git a/ErsatzTV/Controllers/Api/JellyfinController.cs b/ErsatzTV/Controllers/Api/JellyfinController.cs new file mode 100644 index 000000000..e2796961d --- /dev/null +++ b/ErsatzTV/Controllers/Api/JellyfinController.cs @@ -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> 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 GetJellyfinMediaSourceById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/jellyfin/sources/{id:int}/libraries", Name = "GetJellyfinLibraries")] + [Tags("Jellyfin")] + [EndpointSummary("Get Jellyfin libraries by source")] + public async Task> 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> 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 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 result = await mediator.Send( + new UpdateJellyfinPathReplacements(id, replacements), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpPut("/api/jellyfin/library-preferences", Name = "UpdateJellyfinLibraryPreferences")] + [Tags("Jellyfin")] + [EndpointSummary("Update Jellyfin library preferences")] + public async Task UpdateJellyfinLibraryPreferences( + [Required] [FromBody] UpdateJellyfinLibraryPreferencesRequest request, + CancellationToken cancellationToken) + { + var preferences = request.Preferences?.Select(p => + new JellyfinLibraryPreference(p.Id, p.ShouldSyncItems)).ToList() ?? []; + Either result = await mediator.Send( + new UpdateJellyfinLibraryPreferences(preferences), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpGet("/api/jellyfin/secrets", Name = "GetJellyfinSecrets")] + [Tags("Jellyfin")] + [EndpointSummary("Get Jellyfin secrets")] + public async Task GetJellyfinSecrets(CancellationToken cancellationToken) => + await mediator.Send(new GetJellyfinSecrets(), cancellationToken); +} + +// Request models +public record JellyfinPathReplacementRequest(int Id, string JellyfinPath, string LocalPath); +public record UpdateJellyfinPathReplacementsRequest(List? PathReplacements); +public record JellyfinLibraryPreferenceRequest(int Id, bool ShouldSyncItems); +public record UpdateJellyfinLibraryPreferencesRequest(List? Preferences); diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index efeadd1ae..ece43813e 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -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; [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> 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> 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 GetLocalLibraryById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetLocalLibraryById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/libraries/local/{id:int}/paths", Name = "GetLocalLibraryPaths")] + [Tags("Libraries")] + [EndpointSummary("Get local library paths")] + public async Task> 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 CreateLocalLibrary( + [Required] [FromBody] CreateLocalLibraryRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateLocalLibrary(request.Name, request.MediaKind, request.Paths ?? []), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPut("/api/libraries/local/{id:int}", Name = "UpdateLocalLibrary")] + [Tags("Libraries")] + [EndpointSummary("Update a local library")] + public async Task UpdateLocalLibrary( + int id, + [Required] [FromBody] UpdateLocalLibraryRequest request, + CancellationToken cancellationToken) + { + var paths = request.Paths?.Select(p => new UpdateLocalLibraryPath(p.Id, p.Path)).ToList() ?? []; + Either result = await mediator.Send( + new UpdateLocalLibrary(id, request.Name, paths), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/libraries/local/{id:int}", Name = "DeleteLocalLibrary")] + [Tags("Libraries")] + [EndpointSummary("Delete a local library")] + public async Task DeleteLocalLibrary(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteLocalLibrary(id), cancellationToken); + return result.Match(_ => NoContent(), error => Problem(error.ToString())); + } + + [HttpPost("/api/libraries/{id:int}/scan", Name = "ScanLibrary")] [Tags("Libraries")] [EndpointSummary("Scan library")] public async Task 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 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 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> 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? Paths); +public record UpdateLocalLibraryPathRequest(int Id, string Path); +public record UpdateLocalLibraryRequest(string Name, List? Paths); diff --git a/ErsatzTV/Controllers/Api/MaintenanceController.cs b/ErsatzTV/Controllers/Api/MaintenanceController.cs index 048e65728..3a5fc777e 100644 --- a/ErsatzTV/Controllers/Api/MaintenanceController.cs +++ b/ErsatzTV/Controllers/Api/MaintenanceController.cs @@ -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 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 EmptyTrash() { Either 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(_ => 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 DeleteOrphanedArtwork() + { + Either result = await mediator.Send(new DeleteOrphanedArtwork()); + return result.Match(_ => Accepted(), error => Problem(error.ToString())); + } + + [HttpPost("/api/maintenance/delete-orphaned-subtitles", Name = "DeleteOrphanedSubtitles")] + [Tags("Maintenance")] + [EndpointSummary("Delete orphaned subtitles")] + public async Task DeleteOrphanedSubtitles() + { + Either result = await mediator.Send(new DeleteOrphanedSubtitles()); + return result.Match(_ => Accepted(), error => Problem(error.ToString())); } } diff --git a/ErsatzTV/Controllers/Api/MediaController.cs b/ErsatzTV/Controllers/Api/MediaController.cs new file mode 100644 index 000000000..0e85a670a --- /dev/null +++ b/ErsatzTV/Controllers/Api/MediaController.cs @@ -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 GetMovieById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetMovieById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + // TV Shows + [HttpGet("/api/media/shows/{id:int}", Name = "GetShowById")] + [Tags("Media")] + [EndpointSummary("Get TV show by ID")] + public async Task GetShowById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetTelevisionShowById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/media/shows/{id:int}/seasons", Name = "GetShowSeasons")] + [Tags("Media")] + [EndpointSummary("Get seasons for a TV show")] + public async Task 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 GetSeasonById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetTelevisionSeasonById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/media/seasons/{id:int}/episodes", Name = "GetSeasonEpisodes")] + [Tags("Media")] + [EndpointSummary("Get episodes for a season")] + public async Task 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 GetArtistById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetArtistById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } +} diff --git a/ErsatzTV/Controllers/Api/PlaylistController.cs b/ErsatzTV/Controllers/Api/PlaylistController.cs new file mode 100644 index 000000000..06f50bfa2 --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlaylistController.cs @@ -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> 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 CreatePlaylistGroup( + [Required] [FromBody] CreatePlaylistGroupRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreatePlaylistGroup(request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/playlists/groups/{id:int}", Name = "DeletePlaylistGroup")] + [Tags("Playlists")] + [EndpointSummary("Delete a playlist group")] + public async Task DeletePlaylistGroup(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeletePlaylistGroup(id), cancellationToken); + return result.Match(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> 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 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 GetPlaylistById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetPlaylistById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/playlists/{id:int}/items", Name = "GetPlaylistItems")] + [Tags("Playlists")] + [EndpointSummary("Get playlist items")] + public async Task> 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 CreatePlaylist( + [Required] [FromBody] CreatePlaylistRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreatePlaylist(request.PlaylistGroupId, request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")] + [Tags("Playlists")] + [EndpointSummary("Delete a playlist")] + public async Task DeletePlaylist(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeletePlaylist(id), cancellationToken); + return result.Match(error => Problem(error.ToString()), () => NoContent()); + } + + [HttpPut("/api/playlists/{id:int}/items", Name = "ReplacePlaylistItems")] + [Tags("Playlists")] + [EndpointSummary("Replace playlist items")] + public async Task 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> result = await mediator.Send( + new ReplacePlaylistItems(id, request.Name, items), cancellationToken); + return result.Match(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? Items); diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs new file mode 100644 index 000000000..04ff58bb2 --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -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 workerChannel, IMediator mediator) : ControllerBase +{ + [HttpGet("/api/playouts", Name = "GetAllPlayouts")] + [Tags("Playouts")] + [EndpointSummary("Get all playouts (paginated)")] + public async Task 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> 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 GetPlayoutById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetPlayoutById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")] + [Tags("Playouts")] + [EndpointSummary("Get playout items")] + public async Task 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 CreateClassicPlayout( + [Required] [FromBody] CreateClassicPlayoutRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateClassicPlayout(request.ChannelId, request.ProgramScheduleId), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPost("/api/playouts/block", Name = "CreateBlockPlayout")] + [Tags("Playouts")] + [EndpointSummary("Create a block playout")] + public async Task CreateBlockPlayout( + [Required] [FromBody] CreateBlockPlayoutRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateBlockPlayout(request.ChannelId), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPost("/api/playouts/sequential", Name = "CreateSequentialPlayout")] + [Tags("Playouts")] + [EndpointSummary("Create a sequential playout")] + public async Task CreateSequentialPlayout( + [Required] [FromBody] CreateSequentialPlayoutRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateSequentialPlayout(request.ChannelId, request.ScheduleFile), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPost("/api/playouts/scripted", Name = "CreateScriptedPlayout")] + [Tags("Playouts")] + [EndpointSummary("Create a scripted playout")] + public async Task CreateScriptedPlayout( + [Required] [FromBody] CreateScriptedPlayoutRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateScriptedPlayout(request.ChannelId, request.ScheduleFile), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPost("/api/playouts/external-json", Name = "CreateExternalJsonPlayout")] + [Tags("Playouts")] + [EndpointSummary("Create an external JSON playout")] + public async Task CreateExternalJsonPlayout( + [Required] [FromBody] CreateExternalJsonPlayoutRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateExternalJsonPlayout(request.ChannelId, request.ScheduleFile), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/playouts/{id:int}", Name = "DeletePlayout")] + [Tags("Playouts")] + [EndpointSummary("Delete a playout")] + public async Task DeletePlayout(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeletePlayout(id), cancellationToken); + return result.Match(_ => NoContent(), error => Problem(error.ToString())); + } + + [HttpPost("/api/playouts/{id:int}/build", Name = "BuildPlayout")] + [Tags("Playouts")] + [EndpointSummary("Build a playout")] + public async Task 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 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 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 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); diff --git a/ErsatzTV/Controllers/Api/PlexController.cs b/ErsatzTV/Controllers/Api/PlexController.cs new file mode 100644 index 000000000..584ca6efe --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlexController.cs @@ -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> 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 GetPlexMediaSourceById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/plex/sources/{id:int}/libraries", Name = "GetPlexLibraries")] + [Tags("Plex")] + [EndpointSummary("Get Plex libraries by source")] + public async Task> 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> 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 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 result = await mediator.Send( + new UpdatePlexPathReplacements(id, replacements), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } + + [HttpPut("/api/plex/library-preferences", Name = "UpdatePlexLibraryPreferences")] + [Tags("Plex")] + [EndpointSummary("Update Plex library preferences")] + public async Task UpdatePlexLibraryPreferences( + [Required] [FromBody] UpdatePlexLibraryPreferencesRequest request, + CancellationToken cancellationToken) + { + var preferences = request.Preferences?.Select(p => + new PlexLibraryPreference(p.Id, p.ShouldSyncItems)).ToList() ?? []; + Either result = await mediator.Send( + new UpdatePlexLibraryPreferences(preferences), cancellationToken); + return result.Match(_ => Ok(), error => Problem(error.ToString())); + } +} + +// Request models +public record PlexPathReplacementRequest(int Id, string PlexPath, string LocalPath); +public record UpdatePlexPathReplacementsRequest(List? PathReplacements); +public record PlexLibraryPreferenceRequest(int Id, bool ShouldSyncItems); +public record UpdatePlexLibraryPreferencesRequest(List? Preferences); diff --git a/ErsatzTV/Controllers/Api/ResolutionController.cs b/ErsatzTV/Controllers/Api/ResolutionController.cs index 2812747aa..71f80154d 100644 --- a/ErsatzTV/Controllers/Api/ResolutionController.cs +++ b/ErsatzTV/Controllers/Api/ResolutionController.cs @@ -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; [EndpointGroupName("general")] public class ResolutionController(IMediator mediator) : ControllerBase { - [HttpGet("/api/ffmpeg/resolution/by-name/{name}", Name="GetResolutionByName")] - public async Task> GetResolutionByName(string name, CancellationToken cancellationToken) + [HttpGet("/api/resolutions", Name = "GetAllResolutions")] + [Tags("Resolutions")] + [EndpointSummary("Get all resolutions")] + public async Task> 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 GetResolutionByName(string name, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetResolutionByName(name), cancellationToken); - return result.Match>(i => Ok(i), () => NotFound()); + return result.Match(Ok, () => NotFound()); + } + + [HttpPost("/api/resolutions", Name = "CreateCustomResolution")] + [Tags("Resolutions")] + [EndpointSummary("Create a custom resolution")] + public async Task CreateCustomResolution( + [Required] [FromBody] CreateCustomResolutionRequest request, + CancellationToken cancellationToken) + { + Option result = await mediator.Send( + new CreateCustomResolution(request.Width, request.Height), cancellationToken); + return result.Match(error => Problem(error.ToString()), () => Ok()); + } + + [HttpDelete("/api/resolutions/{id:int}", Name = "DeleteCustomResolution")] + [Tags("Resolutions")] + [EndpointSummary("Delete a custom resolution")] + public async Task DeleteCustomResolution(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteCustomResolution(id), cancellationToken); + return result.Match(error => Problem(error.ToString()), () => NoContent()); } } +// Request models +public record CreateCustomResolutionRequest(int Width, int Height); diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs new file mode 100644 index 000000000..179f7f477 --- /dev/null +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -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 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> 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 GetScheduleById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetProgramScheduleById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/schedules/{id:int}/items", Name = "GetScheduleItems")] + [Tags("Schedules")] + [EndpointSummary("Get schedule items")] + public async Task> 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 CreateSchedule( + [Required] [FromBody] CreateScheduleRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateProgramSchedule( + request.Name, + request.KeepMultiPartEpisodesTogether, + request.TreatCollectionsAsShows, + request.ShuffleScheduleItems, + request.RandomStartPoint, + request.FixedStartTimeBehavior), + cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/schedules/{id:int}", Name = "DeleteSchedule")] + [Tags("Schedules")] + [EndpointSummary("Delete a schedule")] + public async Task DeleteSchedule(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteProgramSchedule(id), cancellationToken); + return result.Match(_ => NoContent(), error => Problem(error.ToString())); + } + + [HttpPost("/api/schedules/{id:int}/copy", Name = "CopySchedule")] + [Tags("Schedules")] + [EndpointSummary("Copy a schedule")] + public async Task CopySchedule( + int id, + [Required] [FromBody] CopyScheduleRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CopyProgramSchedule(id, request.Name), cancellationToken); + return result.Match(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); diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs new file mode 100644 index 000000000..6115219ad --- /dev/null +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -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 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 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 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 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 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 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 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 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 RebuildSearchIndex(CancellationToken cancellationToken) + { + await mediator.Send(new RebuildSearchIndex(), cancellationToken); + return Accepted(); + } +} diff --git a/ErsatzTV/Controllers/Api/TemplateController.cs b/ErsatzTV/Controllers/Api/TemplateController.cs new file mode 100644 index 000000000..bf4847ec8 --- /dev/null +++ b/ErsatzTV/Controllers/Api/TemplateController.cs @@ -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> 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 CreateTemplateGroup( + [Required] [FromBody] CreateTemplateGroupRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateTemplateGroup(request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/templates/groups/{id:int}", Name = "DeleteTemplateGroup")] + [Tags("Templates")] + [EndpointSummary("Delete a template group")] + public async Task DeleteTemplateGroup(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteTemplateGroup(id), cancellationToken); + return result.Match(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> 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> 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 GetTemplateById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetTemplateById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpGet("/api/templates/{id:int}/items", Name = "GetTemplateItems")] + [Tags("Templates")] + [EndpointSummary("Get template items")] + public async Task> 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 CreateTemplate( + [Required] [FromBody] CreateTemplateRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CreateTemplate(request.TemplateGroupId, request.Name), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/templates/{id:int}", Name = "DeleteTemplate")] + [Tags("Templates")] + [EndpointSummary("Delete a template")] + public async Task DeleteTemplate(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteTemplate(id), cancellationToken); + return result.Match(error => Problem(error.ToString()), () => NoContent()); + } + + [HttpPost("/api/templates/{id:int}/copy", Name = "CopyTemplate")] + [Tags("Templates")] + [EndpointSummary("Copy a template")] + public async Task CopyTemplate( + int id, + [Required] [FromBody] CopyTemplateRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CopyTemplate(id, request.NewTemplateGroupId, request.NewTemplateName), cancellationToken); + return result.Match(Ok, error => Problem(error.ToString())); + } + + [HttpPut("/api/templates/{id:int}/items", Name = "ReplaceTemplateItems")] + [Tags("Templates")] + [EndpointSummary("Replace template items")] + public async Task ReplaceTemplateItems( + int id, + [Required] [FromBody] ReplaceTemplateItemsRequest request, + CancellationToken cancellationToken) + { + var items = request.Items.Select(i => new ReplaceTemplateItem(i.BlockId, i.StartTime)).ToList(); + Either> result = await mediator.Send( + new ReplaceTemplateItems(request.TemplateGroupId, id, request.Name, items), cancellationToken); + return result.Match(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 Items); diff --git a/ErsatzTV/Controllers/Api/TraktController.cs b/ErsatzTV/Controllers/Api/TraktController.cs new file mode 100644 index 000000000..381535c72 --- /dev/null +++ b/ErsatzTV/Controllers/Api/TraktController.cs @@ -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 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 GetTraktListById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetTraktListById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpPut("/api/trakt/lists/{id:int}", Name = "UpdateTraktList")] + [Tags("Trakt")] + [EndpointSummary("Update a Trakt list")] + public async Task UpdateTraktList( + int id, + [Required] [FromBody] UpdateTraktListRequest request, + CancellationToken cancellationToken) + { + Option result = await mediator.Send( + new UpdateTraktList(id, request.AutoRefresh, request.GeneratePlaylist), cancellationToken); + return result.Match(error => Problem(error.ToString()), () => Ok()); + } + + [HttpDelete("/api/trakt/lists/{id:int}", Name = "DeleteTraktList")] + [Tags("Trakt")] + [EndpointSummary("Delete a Trakt list")] + public async Task DeleteTraktList(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteTraktList(id), cancellationToken); + return result.Match(_ => NoContent(), error => Problem(error.ToString())); + } + + [HttpPost("/api/trakt/lists/{id:int}/match", Name = "MatchTraktListItems")] + [Tags("Trakt")] + [EndpointSummary("Match Trakt list items")] + public async Task MatchTraktListItems(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new MatchTraktListItems(id), cancellationToken); + return result.Match(_ => Accepted(), error => Problem(error.ToString())); + } +} + +// Request models +public record UpdateTraktListRequest(bool AutoRefresh, bool GeneratePlaylist); diff --git a/ErsatzTV/Controllers/Api/WatermarkController.cs b/ErsatzTV/Controllers/Api/WatermarkController.cs new file mode 100644 index 000000000..8c2ca279c --- /dev/null +++ b/ErsatzTV/Controllers/Api/WatermarkController.cs @@ -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> 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 GetWatermarkById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetWatermarkById(id), cancellationToken); + return result.Match(Ok, () => NotFound()); + } + + [HttpPost("/api/watermarks", Name = "CreateWatermark")] + [Tags("Watermarks")] + [EndpointSummary("Create a watermark")] + public async Task CreateWatermark( + [Required] [FromBody] CreateWatermarkRequest request, + CancellationToken cancellationToken) + { + var image = request.ImagePath != null + ? new ArtworkContentTypeModel(request.ImagePath, request.ImageContentType ?? "") + : ArtworkContentTypeModel.None; + + Either 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(Ok, error => Problem(error.ToString())); + } + + [HttpPut("/api/watermarks/{id:int}", Name = "UpdateWatermark")] + [Tags("Watermarks")] + [EndpointSummary("Update a watermark")] + public async Task UpdateWatermark( + int id, + [Required] [FromBody] UpdateWatermarkRequest request, + CancellationToken cancellationToken) + { + var image = request.ImagePath != null + ? new ArtworkContentTypeModel(request.ImagePath, request.ImageContentType ?? "") + : ArtworkContentTypeModel.None; + + Either 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(Ok, error => Problem(error.ToString())); + } + + [HttpDelete("/api/watermarks/{id:int}", Name = "DeleteWatermark")] + [Tags("Watermarks")] + [EndpointSummary("Delete a watermark")] + public async Task DeleteWatermark(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteWatermark(id), cancellationToken); + return result.Match(_ => NoContent(), error => Problem(error.ToString())); + } + + [HttpPost("/api/watermarks/{id:int}/copy", Name = "CopyWatermark")] + [Tags("Watermarks")] + [EndpointSummary("Copy a watermark")] + public async Task CopyWatermark( + int id, + [Required] [FromBody] CopyWatermarkRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new CopyWatermark(id, request.Name), cancellationToken); + return result.Match(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);