From 4a7bd0b24cfc1fcea85cbffb9fdcace374d0c6fa Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 21:52:34 +0200 Subject: [PATCH] feat(69): auto-tune bulk-create orchestration handler Refs #69 --- .../Channels/CreateAutoTunedChannels.cs | 35 ++++++ .../CreateAutoTunedChannelsHandler.cs | 101 +++++++++++++++++ .../CreateAutoTunedChannelsHandlerTests.cs | 102 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs create mode 100644 ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs create mode 100644 ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs diff --git a/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs b/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs new file mode 100644 index 000000000..509a5cae3 --- /dev/null +++ b/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs @@ -0,0 +1,35 @@ +using ErsatzTV.Core.Domain; +using MediatR; + +namespace ErsatzTV.Application.Channels; + +public record CreateAutoTunedChannels( + int TemplateId, + string Group, + List Channels) : IRequest; + +public record AutoTuneChannelSelection( + AutoTuneAxis Axis, + string Value, + string Name, + string Number); + +public record AutoTuneResult(List Results) +{ + public int CreatedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Created); + public int SkippedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Skipped); + public int FailedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Failed); +} + +public record AutoTuneChannelOutcome( + string Name, + AutoTuneOutcomeStatus Status, + int? ChannelId, + string Reason); + +public enum AutoTuneOutcomeStatus +{ + Created, + Skipped, + Failed +} diff --git a/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs new file mode 100644 index 000000000..5d883e261 --- /dev/null +++ b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs @@ -0,0 +1,101 @@ +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels; + +public class CreateAutoTunedChannelsHandler(ISender mediator) + : IRequestHandler +{ + private const string NumberTakenError = "Channel number must be unique"; + private const string DefaultGroup = "Auto-Tuned"; + + public async Task Handle( + CreateAutoTunedChannels request, + CancellationToken cancellationToken) + { + string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim(); + var outcomes = new List(); + + foreach (AutoTuneChannelSelection selection in request.Channels ?? []) + { + outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken)); + } + + return new AutoTuneResult(outcomes); + } + + private async Task CreateOne( + int templateId, + string group, + AutoTuneChannelSelection selection, + CancellationToken cancellationToken) + { + string name = (selection.Name ?? string.Empty).Trim(); + if (name.Length is 0 or > 50) + { + return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name"); + } + + string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value); + PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis); + + // 1. Create the smart collection that drives this channel. + Either scResult = + await mediator.Send(new CreateSmartCollection(query, name), cancellationToken); + + SmartCollectionViewModel smartCollection = null; + foreach (BaseError error in scResult.LeftToSeq()) + { + return new AutoTuneChannelOutcome( + name, AutoTuneOutcomeStatus.Failed, null, $"Smart collection: {error.Value}"); + } + + foreach (SmartCollectionViewModel vm in scResult.RightToSeq()) + { + smartCollection = vm; + } + + // 2. Create the channel from a single-item lineup referencing the smart collection. + var command = new CreateChannelFromLineup( + name, + selection.Number, + group, + string.Empty, + ArtworkContentTypeModel.None, + IsEnabled: true, + ShowInEpg: true, + templateId, + new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order), + [ + new CreateChannelFromLineupItem( + LibraryBrowseMediaType.SmartCollection, + CollectionType.SmartCollection, + CollectionId: null, + MultiCollectionId: null, + SmartCollectionId: smartCollection.Id, + RerunCollectionId: null, + MediaItemId: null, + PlaylistId: null) + ]); + + Either channelResult = + await mediator.Send(command, cancellationToken); + + foreach (BaseError error in channelResult.LeftToSeq()) + { + AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal) + ? AutoTuneOutcomeStatus.Skipped + : AutoTuneOutcomeStatus.Failed; + return new AutoTuneChannelOutcome(name, status, null, error.Value); + } + + int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId); + return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null); + } +} diff --git a/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs new file mode 100644 index 000000000..9c39bd065 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Channels; + +[TestFixture] +public class CreateAutoTunedChannelsHandlerTests +{ + private ISender _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + // Smart collection creation always succeeds, echoing an incrementing id. + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(ci => + { + var cmd = ci.Arg(); + return (Either) + new SmartCollectionViewModel(7, cmd.Name, cmd.Query); + }); + } + + [Test] + public async Task Creates_Selected_Channels_And_Reports_Counts() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((Either) + new CreateChannelFromLineupResponseModel(88, null, 1, 2)); + + var result = await Handle(new CreateAutoTunedChannels( + TemplateId: 3, Group: "Auto-Tuned", + new List + { + new(AutoTuneAxis.TvShow, "The Office", "The Office", "500") + })); + + result.CreatedCount.ShouldBe(1); + result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Created); + result.Results[0].ChannelId.ShouldBe(88); + + // Smart collection built with the server-generated query. + await _mediator.Received().Send( + Arg.Is(c => c.Query == "type:episode AND show_title:\"The Office\""), + Arg.Any()); + + // Channel created referencing the smart collection id, number, and SeasonEpisode order. + await _mediator.Received().Send( + Arg.Is(c => + c.Number == "500" && + c.TemplateId == 3 && + c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode && + c.Lineup.Count == 1 && + c.Lineup[0].CollectionType == CollectionType.SmartCollection && + c.Lineup[0].SmartCollectionId == 7), + Arg.Any()); + } + + [Test] + public async Task Number_Collision_Is_Skipped_Not_Failed() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((Either) + BaseError.New("Channel number must be unique")); + + var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned", + new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") })); + + result.SkippedCount.ShouldBe(1); + result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped); + } + + [Test] + public async Task Other_Errors_Are_Failed() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((Either) + BaseError.New("FFmpegProfile 9 does not exist.")); + + var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned", + new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") })); + + result.FailedCount.ShouldBe(1); + result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed); + } + + private Task Handle(CreateAutoTunedChannels request) => + new CreateAutoTunedChannelsHandler(_mediator).Handle(request, CancellationToken.None); +}