From 6857a0d19123053c41f206f87d7daea3d720f107 Mon Sep 17 00:00:00 2001 From: Timothy Date: Mon, 6 Jul 2026 20:27:02 +0200 Subject: [PATCH 1/2] feat(api): composite create-channel endpoint (#63) --- .../Commands/CreateChannelFromLineup.cs | 56 ++ .../CreateChannelFromLineupHandler.cs | 535 ++++++++++++++++++ .../CreateChannelFromLineupResponseModel.cs | 9 + .../CreateChannelFromLineupHandlerTests.cs | 289 ++++++++++ .../ApiErrorResponseMetadataTests.cs | 2 + .../Controllers/ChannelControllerTests.cs | 73 +++ .../OpenApiErrorResponseContractTests.cs | 2 + ErsatzTV/Controllers/Api/ChannelController.cs | 19 + .../CreateChannelFromLineupRequest.cs | 111 ++++ ErsatzTV/wwwroot/openapi/v1.json | 444 +++++++++++++++ web/src/api/generated/v1.d.ts | 54 ++ 11 files changed, 1594 insertions(+) create mode 100644 ErsatzTV.Application/Channels/Commands/CreateChannelFromLineup.cs create mode 100644 ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs create mode 100644 ErsatzTV.Core/Api/Channels/CreateChannelFromLineupResponseModel.cs create mode 100644 ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineup.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineup.cs new file mode 100644 index 000000000..8b8858931 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineup.cs @@ -0,0 +1,56 @@ +using ErsatzTV.Application.Artworks; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; + +namespace ErsatzTV.Application.Channels; + +public record CreateChannelFromLineup( + string Name, + string Number, + string Group, + string Categories, + ArtworkContentTypeModel Logo, + bool IsEnabled, + bool ShowInEpg, + int TemplateId, + CreateChannelFromLineupAdvancedOptions Advanced, + List Lineup) : IRequest>; + +public record CreateChannelFromLineupAdvancedOptions( + PlaybackOrder? PlaybackOrder = null, + int? FFmpegProfileId = null, + int? WatermarkId = null, + int? FallbackFillerId = null, + int? PreRollFillerId = null, + int? MidRollFillerId = null, + int? PostRollFillerId = null, + ChannelStreamSelectorMode? StreamSelectorMode = null, + string StreamSelector = null, + string PreferredAudioLanguageCode = null, + string PreferredAudioTitle = null, + ChannelPlayoutSource? PlayoutSource = null, + ChannelPlayoutMode? PlayoutMode = null, + StreamingMode? StreamingMode = null, + string PreferredSubtitleLanguageCode = null, + ChannelSubtitleMode? SubtitleMode = null, + ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null, + string MusicVideoCreditsTemplate = null, + ChannelSongVideoMode? SongVideoMode = null, + ChannelTranscodeMode? TranscodeMode = null, + ChannelIdleBehavior? IdleBehavior = null, + bool? ShuffleScheduleItems = null, + bool? RandomStartPoint = null, + FixedStartTimeBehavior? FixedStartTimeBehavior = null); + +public record CreateChannelFromLineupItem( + LibraryBrowseMediaType MediaType, + CollectionType CollectionType, + int? CollectionId, + int? MultiCollectionId, + int? SmartCollectionId, + int? RerunCollectionId, + int? MediaItemId, + int? PlaylistId); diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs new file mode 100644 index 000000000..b28bc1a84 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs @@ -0,0 +1,535 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using System.Threading.Channels; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Channel = ErsatzTV.Core.Domain.Channel; + +namespace ErsatzTV.Application.Channels; + +public class CreateChannelFromLineupHandler( + ChannelWriter workerChannel, + IDbContextFactory dbContextFactory, + ISearchTargets searchTargets) + : IRequestHandler> +{ + public async Task> Handle( + CreateChannelFromLineup request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + PreparedCreate prepared; + Either validation = await Validate(dbContext, request, cancellationToken); + foreach (BaseError error in validation.LeftToSeq()) + { + return error; + } + + prepared = validation.IfLeft(() => throw new InvalidOperationException("Validation failed without error")); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + try + { + dbContext.Channels.Add(prepared.Channel); + dbContext.Collections.Add(prepared.Collection); + dbContext.ProgramSchedules.Add(prepared.ProgramSchedule); + dbContext.Playouts.Add(prepared.Playout); + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + await transaction.RollbackAsync(cancellationToken); + return BaseError.New($"Unable to create channel from lineup: {ex.GetBaseException().Message}"); + } + + searchTargets.SearchTargetsChanged(); + await workerChannel.WriteAsync(new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset), cancellationToken); + await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken); + + return new CreateChannelFromLineupResponseModel( + prepared.Channel.Id, + prepared.Collection.Id, + prepared.ProgramSchedule.Id, + prepared.Playout.Id); + } + + private static async Task> Validate( + TvContext dbContext, + CreateChannelFromLineup request, + CancellationToken cancellationToken) + { + string name = (request.Name ?? string.Empty).Trim(); + string number = (request.Number ?? string.Empty).Trim(); + string group = (request.Group ?? string.Empty).Trim(); + string categories = (request.Categories ?? string.Empty).Trim(); + CreateChannelFromLineupAdvancedOptions advanced = request.Advanced ?? new CreateChannelFromLineupAdvancedOptions(); + + if (string.IsNullOrWhiteSpace(name)) + { + return BaseError.New("Channel name is required"); + } + + if (name.Length > 50) + { + return BaseError.New("Channel name must be 50 characters or fewer"); + } + + if (string.IsNullOrWhiteSpace(group)) + { + return BaseError.New("Channel group is required"); + } + + if (!Regex.IsMatch(number, Channel.NumberValidator)) + { + return BaseError.New("Invalid channel number; two decimals are allowed for subchannels"); + } + + if (await dbContext.Channels.AnyAsync(c => c.Number == number, cancellationToken)) + { + return BaseError.New("Channel number must be unique"); + } + + if (!request.IsEnabled && request.ShowInEpg) + { + return BaseError.New("Disabled channels cannot be shown in EPG"); + } + + if (!string.IsNullOrWhiteSpace(request.Logo?.Path) && + Uri.TryCreate(request.Logo.Path, UriKind.Absolute, out _) && + !Artwork.IsExternalUrl(request.Logo.Path)) + { + return BaseError.New("External logo url is invalid"); + } + + if (request.Lineup is null || request.Lineup.Count == 0) + { + return BaseError.New("Lineup must contain at least one item"); + } + + ChannelTemplate template = await dbContext.ChannelTemplates + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == request.TemplateId, cancellationToken); + if (template is null) + { + return new NotFoundError($"Channel template {request.TemplateId} does not exist."); + } + + Either referenceValidation = await ValidateReferences( + dbContext, + template, + advanced, + request.Lineup, + cancellationToken); + foreach (BaseError error in referenceValidation.LeftToSeq()) + { + return error; + } + + int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId; + int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId; + int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId; + int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId; + int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId; + PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological; + + Channel channel = BuildChannel( + request, + template, + advanced, + name, + number, + group, + categories, + ffmpegProfileId, + fallbackFillerId); + + Collection collection = BuildCollection(number, name, request.Lineup); + ProgramSchedule schedule = BuildProgramSchedule(number, name, template, advanced); + schedule.Items = BuildScheduleItems( + collection, + request.Lineup, + playbackOrder, + template, + advanced, + fallbackFillerId, + preRollFillerId, + midRollFillerId, + postRollFillerId); + + var playout = new Playout + { + Channel = channel, + ProgramSchedule = schedule, + ScheduleKind = PlayoutScheduleKind.Classic + }; + + return new PreparedCreate(channel, collection, schedule, playout); + } + + private static Channel BuildChannel( + CreateChannelFromLineup request, + ChannelTemplate template, + CreateChannelFromLineupAdvancedOptions advanced, + string name, + string number, + string group, + string categories, + int ffmpegProfileId, + int? fallbackFillerId) + { + var artwork = new List(); + if (!string.IsNullOrWhiteSpace(request.Logo?.Path)) + { + string logo = request.Logo.Path; + if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal)) + { + logo = logo.Replace("iptv/logos/", string.Empty); + } + + artwork.Add(new Artwork + { + Path = logo, + ArtworkKind = ArtworkKind.Logo, + OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null, + DateAdded = DateTime.UtcNow, + DateUpdated = DateTime.UtcNow + }); + } + + return new Channel(Guid.NewGuid()) + { + Name = name, + Number = number, + SortNumber = double.Parse(number, CultureInfo.InvariantCulture), + Group = group, + Categories = categories, + FFmpegProfileId = ffmpegProfileId, + SlugSeconds = null, + PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource, + PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode, + StreamingMode = advanced.StreamingMode ?? template.StreamingMode, + WatermarkId = advanced.WatermarkId ?? template.WatermarkId, + FallbackFillerId = fallbackFillerId, + Artwork = artwork, + StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode, + StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty, + PreferredAudioLanguageCode = + advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty, + PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty, + PreferredSubtitleLanguageCode = + advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty, + SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode, + MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode, + MusicVideoCreditsTemplate = + advanced.MusicVideoCreditsTemplate ?? template.MusicVideoCreditsTemplate ?? string.Empty, + SongVideoMode = advanced.SongVideoMode ?? template.SongVideoMode, + TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode, + IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior, + IsEnabled = request.IsEnabled, + ShowInEpg = request.IsEnabled && request.ShowInEpg + }; + } + + private static Collection BuildCollection(string channelNumber, string channelName, List lineup) + { + var collection = new Collection + { + Name = GeneratedName(channelNumber, channelName, "Lineup"), + UseCustomPlaybackOrder = true, + CollectionItems = [] + }; + + int index = 1; + foreach (CreateChannelFromLineupItem item in lineup.Where(IsMediaLineupItem)) + { + collection.CollectionItems.Add(new CollectionItem + { + Collection = collection, + MediaItemId = item.MediaItemId.GetValueOrDefault(), + CustomIndex = index++ + }); + } + + return collection; + } + + private static ProgramSchedule BuildProgramSchedule( + string channelNumber, + string channelName, + ChannelTemplate template, + CreateChannelFromLineupAdvancedOptions advanced) => + new() + { + Name = GeneratedName(channelNumber, channelName, "Schedule"), + KeepMultiPartEpisodesTogether = true, + TreatCollectionsAsShows = true, + ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems, + RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint, + FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior, + Items = [] + }; + + private static string GeneratedName(string channelNumber, string channelName, string suffix) + { + string prefix = $"{channelNumber} {channelName}".Trim(); + int maxPrefixLength = Math.Max(0, 50 - suffix.Length - 1); + if (prefix.Length > maxPrefixLength) + { + prefix = prefix[..maxPrefixLength].TrimEnd(); + } + + return $"{prefix} {suffix}".Trim(); + } + + private static List BuildScheduleItems( + Collection collection, + List lineup, + PlaybackOrder playbackOrder, + ChannelTemplate template, + CreateChannelFromLineupAdvancedOptions advanced, + int? fallbackFillerId, + int? preRollFillerId, + int? midRollFillerId, + int? postRollFillerId) + { + var result = new List(); + if (collection.CollectionItems.Count != 0) + { + result.Add(new ProgramScheduleItemFlood + { + Index = result.Count + 1, + Collection = collection, + CollectionType = CollectionType.Collection, + PlaybackOrder = playbackOrder, + GuideMode = GuideMode.Normal, + CustomTitle = string.Empty, + SearchTitle = string.Empty, + SearchQuery = string.Empty, + PreRollFillerId = preRollFillerId, + MidRollFillerId = midRollFillerId, + PostRollFillerId = postRollFillerId, + FallbackFillerId = fallbackFillerId, + PreferredAudioLanguageCode = + advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty, + PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty, + PreferredSubtitleLanguageCode = + advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty, + SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode + }); + } + + foreach (CreateChannelFromLineupItem item in lineup.Where(i => !IsMediaLineupItem(i))) + { + result.Add(new ProgramScheduleItemFlood + { + Index = result.Count + 1, + CollectionType = item.CollectionType, + CollectionId = item.CollectionId, + MultiCollectionId = item.MultiCollectionId, + SmartCollectionId = item.SmartCollectionId, + RerunCollectionId = item.RerunCollectionId, + PlaylistId = item.PlaylistId, + PlaybackOrder = playbackOrder, + GuideMode = GuideMode.Normal, + CustomTitle = string.Empty, + SearchTitle = string.Empty, + SearchQuery = string.Empty, + PreRollFillerId = preRollFillerId, + MidRollFillerId = midRollFillerId, + PostRollFillerId = postRollFillerId, + FallbackFillerId = fallbackFillerId, + PreferredAudioLanguageCode = + advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty, + PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty, + PreferredSubtitleLanguageCode = + advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty, + SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode + }); + } + + return result; + } + + private static async Task> ValidateReferences( + TvContext dbContext, + ChannelTemplate template, + CreateChannelFromLineupAdvancedOptions advanced, + List lineup, + CancellationToken cancellationToken) + { + int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId; + if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken)) + { + return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist."); + } + + Either channelReferences = await ValidateChannelReferences( + dbContext, + advanced.WatermarkId ?? template.WatermarkId, + advanced.FallbackFillerId ?? template.FallbackFillerId, + cancellationToken); + foreach (BaseError error in channelReferences.LeftToSeq()) + { + return error; + } + + Either itemFillers = await ValidateItemFillers( + dbContext, + advanced.PreRollFillerId ?? template.PreRollFillerId, + advanced.MidRollFillerId ?? template.MidRollFillerId, + advanced.PostRollFillerId ?? template.PostRollFillerId, + cancellationToken); + foreach (BaseError error in itemFillers.LeftToSeq()) + { + return error; + } + + for (int i = 0; i < lineup.Count; i++) + { + Either itemValidation = await ValidateLineupItem(dbContext, lineup[i], i, cancellationToken); + foreach (BaseError error in itemValidation.LeftToSeq()) + { + return error; + } + } + + return Unit.Default; + } + + private static async Task> ValidateChannelReferences( + TvContext dbContext, + int? watermarkId, + int? fallbackFillerId, + CancellationToken cancellationToken) + { + if (watermarkId.HasValue && + !await dbContext.ChannelWatermarks.AnyAsync(w => w.Id == watermarkId.Value, cancellationToken)) + { + return new NotFoundError($"Watermark {watermarkId.Value} does not exist."); + } + + if (fallbackFillerId.HasValue && !await dbContext.FillerPresets.AnyAsync( + fp => fp.Id == fallbackFillerId.Value && fp.FillerKind == FillerKind.Fallback, + cancellationToken)) + { + return new NotFoundError($"Fallback filler {fallbackFillerId.Value} does not exist."); + } + + return Unit.Default; + } + + private static async Task> ValidateItemFillers( + TvContext dbContext, + int? preRollFillerId, + int? midRollFillerId, + int? postRollFillerId, + CancellationToken cancellationToken) + { + if (preRollFillerId.HasValue && !await FillerExists(dbContext, preRollFillerId.Value, FillerKind.PreRoll, cancellationToken)) + { + return new NotFoundError($"Pre-roll filler {preRollFillerId.Value} does not exist."); + } + + if (midRollFillerId.HasValue && !await FillerExists(dbContext, midRollFillerId.Value, FillerKind.MidRoll, cancellationToken)) + { + return new NotFoundError($"Mid-roll filler {midRollFillerId.Value} does not exist."); + } + + if (postRollFillerId.HasValue && !await FillerExists(dbContext, postRollFillerId.Value, FillerKind.PostRoll, cancellationToken)) + { + return new NotFoundError($"Post-roll filler {postRollFillerId.Value} does not exist."); + } + + return Unit.Default; + } + + private static Task FillerExists( + TvContext dbContext, + int id, + FillerKind fillerKind, + CancellationToken cancellationToken) => + dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken); + + private static async Task> ValidateLineupItem( + TvContext dbContext, + CreateChannelFromLineupItem item, + int index, + CancellationToken cancellationToken) + { + if (item.MediaType is LibraryBrowseMediaType.RerunCollection) + { + item = item with { CollectionType = CollectionType.RerunFirstRun }; + } + + int providedIds = new int?[] + { + item.CollectionId, + item.MultiCollectionId, + item.SmartCollectionId, + item.RerunCollectionId, + item.MediaItemId, + item.PlaylistId + }.Count(id => id.HasValue); + + if (providedIds != 1) + { + return BaseError.New($"lineup[{index}] must provide exactly one typed id."); + } + + return item.CollectionType switch + { + CollectionType.Movie when item.MediaType is LibraryBrowseMediaType.Movie && item.MediaItemId.HasValue => + await Exists(dbContext.Movies, item.MediaItemId.Value, $"lineup[{index}] Movie", cancellationToken), + CollectionType.TelevisionShow when item.MediaType is LibraryBrowseMediaType.TelevisionShow && item.MediaItemId.HasValue => + await Exists(dbContext.Shows, item.MediaItemId.Value, $"lineup[{index}] TelevisionShow", cancellationToken), + CollectionType.TelevisionSeason when item.MediaType is LibraryBrowseMediaType.TelevisionSeason && item.MediaItemId.HasValue => + await Exists(dbContext.Seasons, item.MediaItemId.Value, $"lineup[{index}] TelevisionSeason", cancellationToken), + CollectionType.Artist when item.MediaType is LibraryBrowseMediaType.Artist && item.MediaItemId.HasValue => + await Exists(dbContext.Artists, item.MediaItemId.Value, $"lineup[{index}] Artist", cancellationToken), + CollectionType.Collection when item.MediaType is LibraryBrowseMediaType.Collection && item.CollectionId.HasValue => + await Exists(dbContext.Collections, item.CollectionId.Value, $"lineup[{index}] Collection", cancellationToken), + CollectionType.SmartCollection when item.MediaType is LibraryBrowseMediaType.SmartCollection && item.SmartCollectionId.HasValue => + await Exists(dbContext.SmartCollections, item.SmartCollectionId.Value, $"lineup[{index}] SmartCollection", cancellationToken), + CollectionType.MultiCollection when item.MediaType is LibraryBrowseMediaType.MultiCollection && item.MultiCollectionId.HasValue => + await Exists(dbContext.MultiCollections, item.MultiCollectionId.Value, $"lineup[{index}] MultiCollection", cancellationToken), + CollectionType.RerunFirstRun when item.MediaType is LibraryBrowseMediaType.RerunCollection && item.RerunCollectionId.HasValue => + await Exists(dbContext.RerunCollections, item.RerunCollectionId.Value, $"lineup[{index}] RerunCollection", cancellationToken), + CollectionType.Playlist when item.MediaType is LibraryBrowseMediaType.Playlist && item.PlaylistId.HasValue => + await Exists(dbContext.Playlists, item.PlaylistId.Value, $"lineup[{index}] Playlist", cancellationToken), + _ => BaseError.New($"lineup[{index}] has an unsupported or mismatched media type, collection type, and id.") + }; + } + + private static async Task> Exists( + DbSet set, + int id, + string label, + CancellationToken cancellationToken) + where TEntity : class + { + TEntity entity = await set.FindAsync([id], cancellationToken); + return entity is null ? new NotFoundError($"{label} {id} does not exist.") : Unit.Default; + } + + private static bool IsMediaLineupItem(CreateChannelFromLineupItem item) => + item.CollectionType is CollectionType.Movie or CollectionType.TelevisionShow or CollectionType.TelevisionSeason + or CollectionType.Artist; + + private sealed record PreparedCreate( + Channel Channel, + Collection Collection, + ProgramSchedule ProgramSchedule, + Playout Playout); +} diff --git a/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupResponseModel.cs b/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupResponseModel.cs new file mode 100644 index 000000000..b6ec22946 --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupResponseModel.cs @@ -0,0 +1,9 @@ +#nullable enable + +namespace ErsatzTV.Core.Api.Channels; + +public record CreateChannelFromLineupResponseModel( + int ChannelId, + int CollectionId, + int ProgramScheduleId, + int PlayoutId); diff --git a/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs new file mode 100644 index 000000000..229728253 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs @@ -0,0 +1,289 @@ +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.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using DomainChannel = ErsatzTV.Core.Domain.Channel; + +namespace ErsatzTV.Tests.Application.Channels; + +[TestFixture] +public class CreateChannelFromLineupHandlerTests +{ + private Channel _background = null!; + private InMemoryTvContext _db = null!; + private ISearchTargets _searchTargets = null!; + + [SetUp] + public async Task SetUp() + { + _background = System.Threading.Channels.Channel.CreateUnbounded(); + _db = await InMemoryTvContext.CreateAsync(); + _searchTargets = Substitute.For(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Should_Create_Channel_Collection_Schedule_Items_And_Playout() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + + Either result = + await MakeHandler().Handle(MakeRequest(), CancellationToken.None); + + CreateChannelFromLineupResponseModel response = RightOf(result); + response.ChannelId.ShouldBeGreaterThan(0); + response.CollectionId.ShouldBeGreaterThan(0); + response.ProgramScheduleId.ShouldBeGreaterThan(0); + response.PlayoutId.ShouldBeGreaterThan(0); + + await using TvContext context = _db.CreateContext(); + DomainChannel channel = await context.Channels.SingleAsync(); + channel.Name.ShouldBe("Movies"); + channel.Number.ShouldBe("12"); + channel.Group.ShouldBe("Kids"); + channel.FFmpegProfileId.ShouldBe(1); + channel.FallbackFillerId.ShouldBe(5); + channel.StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingSegmenter); + channel.ShowInEpg.ShouldBeTrue(); + + Collection collection = await context.Collections + .Include(c => c.CollectionItems) + .SingleAsync(); + collection.Name.ShouldBe("12 Movies Lineup"); + collection.UseCustomPlaybackOrder.ShouldBeTrue(); + collection.CollectionItems.Single().MediaItemId.ShouldBe(42); + collection.CollectionItems.Single().CustomIndex.ShouldBe(1); + + ProgramSchedule schedule = await context.ProgramSchedules + .Include(ps => ps.Items) + .SingleAsync(); + schedule.Name.ShouldBe("12 Movies Schedule"); + schedule.ShuffleScheduleItems.ShouldBeTrue(); + schedule.RandomStartPoint.ShouldBeTrue(); + schedule.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Strict); + + ProgramScheduleItem item = schedule.Items.Single(); + item.CollectionType.ShouldBe(CollectionType.Collection); + item.CollectionId.ShouldBe(collection.Id); + item.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle); + item.PreRollFillerId.ShouldBe(2); + item.MidRollFillerId.ShouldBe(3); + item.PostRollFillerId.ShouldBe(4); + item.FallbackFillerId.ShouldBe(5); + + Playout playout = await context.Playouts.SingleAsync(); + playout.ChannelId.ShouldBe(channel.Id); + playout.ProgramScheduleId.ShouldBe(schedule.Id); + playout.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic); + + _background.Reader.TryRead(out IBackgroundServiceRequest? buildRequest).ShouldBeTrue(); + BuildPlayout buildPlayout = buildRequest.ShouldBeOfType(); + buildPlayout.PlayoutId.ShouldBe(playout.Id); + buildPlayout.Mode.ShouldBe(PlayoutBuildMode.Reset); + + _background.Reader.TryRead(out IBackgroundServiceRequest? refreshRequest).ShouldBeTrue(); + refreshRequest.ShouldBeOfType(); + _searchTargets.Received(1).SearchTargetsChanged(); + } + + [Test] + public async Task Should_Return_NotFound_When_Template_Is_Missing() + { + await SeedTemplateDependencies(); + await SeedMovie(42); + + Either result = + await MakeHandler().Handle(MakeRequest(templateId: 999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Should_Return_Validation_Error_When_Channel_Number_Already_Exists_After_Trim() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + await using (TvContext context = _db.CreateContext()) + { + context.Channels.Add(new DomainChannel(Guid.NewGuid()) + { + Number = "12", + Name = "Existing", + Group = "Kids", + Categories = string.Empty, + FFmpegProfileId = 1, + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty + }); + await context.SaveChangesAsync(); + } + + Either result = + await MakeHandler().Handle(MakeRequest(number: " 12 "), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("Channel number must be unique"); + } + + [Test] + public async Task Should_Return_NotFound_When_Lineup_Target_Is_Missing() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + + Either result = + await MakeHandler().Handle(MakeRequest(), CancellationToken.None); + + NotFoundError error = LeftOf(result).ShouldBeOfType(); + error.Value.ShouldContain("lineup[0]"); + error.Value.ShouldContain("Movie 42"); + } + + [Test] + public async Task Should_Roll_Back_When_Save_Fails() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + await using (TvContext context = _db.CreateContext()) + { + context.ProgramSchedules.Add(new ProgramSchedule + { + Name = "12 Movies Schedule", + FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible + }); + await context.SaveChangesAsync(); + } + + Either result = + await MakeHandler().Handle(MakeRequest(), CancellationToken.None); + + LeftOf(result).ShouldNotBeOfType(); + await using TvContext assertContext = _db.CreateContext(); + (await assertContext.Channels.CountAsync()).ShouldBe(0); + (await assertContext.Collections.CountAsync()).ShouldBe(0); + (await assertContext.Playouts.CountAsync()).ShouldBe(0); + _background.Reader.TryRead(out _).ShouldBeFalse(); + } + + private CreateChannelFromLineupHandler MakeHandler() => + new(_background.Writer, _db.Factory, _searchTargets); + + private async Task SeedTemplateDependencies() + { + await using TvContext context = _db.CreateContext(); + context.FFmpegProfiles.Add(new FFmpegProfile { Id = 1, Name = "profile" }); + context.FillerPresets.AddRange( + MakeFiller(2, FillerKind.PreRoll), + MakeFiller(3, FillerKind.MidRoll), + MakeFiller(4, FillerKind.PostRoll), + MakeFiller(5, FillerKind.Fallback)); + await context.SaveChangesAsync(); + } + + private async Task SeedTemplate() + { + await using TvContext context = _db.CreateContext(); + context.ChannelTemplates.Add(new ChannelTemplate + { + Id = 10, + Name = "Template", + Description = string.Empty, + FFmpegProfileId = 1, + FallbackFillerId = 5, + PreRollFillerId = 2, + MidRollFillerId = 3, + PostRollFillerId = 4, + StreamSelectorMode = ChannelStreamSelectorMode.Default, + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous, + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + PreferredSubtitleLanguageCode = string.Empty, + SubtitleMode = ChannelSubtitleMode.None, + MusicVideoCreditsMode = ChannelMusicVideoCreditsMode.None, + MusicVideoCreditsTemplate = string.Empty, + SongVideoMode = ChannelSongVideoMode.Default, + TranscodeMode = ChannelTranscodeMode.OnDemand, + IdleBehavior = ChannelIdleBehavior.StopOnDisconnect, + ShuffleScheduleItems = true, + RandomStartPoint = true, + FixedStartTimeBehavior = FixedStartTimeBehavior.Strict + }); + await context.SaveChangesAsync(); + } + + private async Task SeedMovie(int id) + { + await using TvContext context = _db.CreateContext(); + context.Movies.Add(new Movie { Id = id }); + await context.SaveChangesAsync(); + } + + private static FillerPreset MakeFiller(int id, FillerKind kind) => + new() + { + Id = id, + Name = $"filler-{id}", + FillerKind = kind, + FillerMode = FillerMode.Count, + Count = 1, + CollectionType = CollectionType.Collection + }; + + private static CreateChannelFromLineup MakeRequest( + string number = "12", + int templateId = 10) => + new( + "Movies", + number, + "Kids", + string.Empty, + ArtworkContentTypeModel.None, + true, + true, + templateId, + new CreateChannelFromLineupAdvancedOptions(PlaybackOrder.Shuffle), + [new CreateChannelFromLineupItem( + LibraryBrowseMediaType.Movie, + CollectionType.Movie, + null, + null, + null, + null, + 42, + null)]); + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); + + private static TR RightOf(Either either) => + either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e.Value}"), Right: r => r); +} diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index c3e5db090..d4d4a1b56 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -13,6 +13,8 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(ChannelController), nameof(ChannelController.GetById), StatusCodes.Status404NotFound)] [TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status404NotFound)] [TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status404NotFound)] + [TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status404NotFound)] [TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)] diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 5593c9164..3c23a0b4d 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -7,6 +7,7 @@ using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; @@ -98,6 +99,54 @@ public class ChannelControllerTests result.ShouldBeOfType(); } + [Test] + public async Task CreateFromLineup_Should_Return_201_With_Location_And_Body() + { + var response = new CreateChannelFromLineupResponseModel(5, 6, 7, 8); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(response)); + + IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/channels/5"); + created.Value.ShouldBe(response); + } + + [Test] + public async Task CreateFromLineup_Should_Map_Request_To_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right( + new CreateChannelFromLineupResponseModel(5, 6, 7, 8))); + + await _controller.CreateFromLineup( + MakeLineupRequest(number: "12", name: "Movies", templateId: 9), + CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => + c.Number == "12" && + c.Name == "Movies" && + c.TemplateId == 9 && + c.Lineup.Count == 1 && + c.Lineup[0].CollectionType == CollectionType.Movie && + c.Lineup[0].MediaItemId == 42), + Arg.Any()); + } + + [Test] + public async Task CreateFromLineup_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + [Test] public async Task Update_Should_Return_200_And_Map_Route_Id() { @@ -413,6 +462,30 @@ public class ChannelControllerTests true, false); + private static CreateChannelFromLineupRequest MakeLineupRequest( + string number = "5", + string name = "Test", + int templateId = 1) => + new( + name, + number, + "ErsatzTV", + string.Empty, + ArtworkContentTypeModel.None, + true, + true, + templateId, + null, + [new CreateChannelFromLineupItemRequest( + LibraryBrowseMediaType.Movie, + CollectionType.Movie, + null, + null, + null, + null, + 42, + null)]); + private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") => new( name, diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 4d0b5cc40..1cbec4683 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -102,6 +102,8 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/channels/{id}", "get", "404")] [TestCase("/api/channels", "post", "404")] [TestCase("/api/channels", "post", "422")] + [TestCase("/api/channels/from-lineup", "post", "404")] + [TestCase("/api/channels/from-lineup", "post", "422")] [TestCase("/api/channels/{id}", "put", "404")] [TestCase("/api/channels/{id}", "put", "422")] [TestCase("/api/channels/{id}", "delete", "404")] diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 4ec99cdf5..786ec1a86 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -79,6 +79,25 @@ public class ChannelController(ChannelWriter workerCh }); } + [HttpPost("/api/channels/from-lineup", Name = "CreateChannelFromLineup")] + [Tags("Channels")] + [EndpointSummary("Create a channel from a library lineup")] + [EndpointDescription( + "Atomically creates the channel, generated lineup collection, program schedule, schedule items, " + + "and classic playout. Template defaults are stamped at create time; advanced overrides win.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(CreateChannelFromLineupResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task CreateFromLineup( + [Required] [FromBody] CreateChannelFromLineupRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult(response => $"/api/channels/{response.ChannelId}", response => response); + } + [HttpPut("/api/channels/{id:int}")] [Tags("Channels")] [EndpointSummary("Update a channel")] diff --git a/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs new file mode 100644 index 000000000..e513912e6 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs @@ -0,0 +1,111 @@ +#nullable enable + +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateChannelFromLineupRequest( + string Name, + string Number, + string Group, + string Categories, + ArtworkContentTypeModel Logo, + bool IsEnabled, + bool ShowInEpg, + int TemplateId, + CreateChannelFromLineupAdvancedOptionsRequest? Advanced, + List Lineup) +{ + public CreateChannelFromLineup ToCommand() => + new( + Name, + Number, + Group, + Categories, + Logo, + IsEnabled, + ShowInEpg, + TemplateId, + Advanced?.ToCommand() ?? new CreateChannelFromLineupAdvancedOptions(), + Lineup.Map(i => i.ToCommand()).ToList()); +} + +public record CreateChannelFromLineupAdvancedOptionsRequest( + PlaybackOrder? PlaybackOrder = null, + int? FFmpegProfileId = null, + int? WatermarkId = null, + int? FallbackFillerId = null, + int? PreRollFillerId = null, + int? MidRollFillerId = null, + int? PostRollFillerId = null, + ChannelStreamSelectorMode? StreamSelectorMode = null, + string? StreamSelector = null, + string? PreferredAudioLanguageCode = null, + string? PreferredAudioTitle = null, + ChannelPlayoutSource? PlayoutSource = null, + ChannelPlayoutMode? PlayoutMode = null, + StreamingMode? StreamingMode = null, + string? PreferredSubtitleLanguageCode = null, + ChannelSubtitleMode? SubtitleMode = null, + ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null, + string? MusicVideoCreditsTemplate = null, + ChannelSongVideoMode? SongVideoMode = null, + ChannelTranscodeMode? TranscodeMode = null, + ChannelIdleBehavior? IdleBehavior = null, + bool? ShuffleScheduleItems = null, + bool? RandomStartPoint = null, + FixedStartTimeBehavior? FixedStartTimeBehavior = null) +{ + public CreateChannelFromLineupAdvancedOptions ToCommand() => + new( + PlaybackOrder, + FFmpegProfileId, + WatermarkId, + FallbackFillerId, + PreRollFillerId, + MidRollFillerId, + PostRollFillerId, + StreamSelectorMode, + StreamSelector, + PreferredAudioLanguageCode, + PreferredAudioTitle, + PlayoutSource, + PlayoutMode, + StreamingMode, + PreferredSubtitleLanguageCode, + SubtitleMode, + MusicVideoCreditsMode, + MusicVideoCreditsTemplate, + SongVideoMode, + TranscodeMode, + IdleBehavior, + ShuffleScheduleItems, + RandomStartPoint, + FixedStartTimeBehavior); +} + +public record CreateChannelFromLineupItemRequest( + LibraryBrowseMediaType MediaType, + CollectionType CollectionType, + int? CollectionId, + int? MultiCollectionId, + int? SmartCollectionId, + int? RerunCollectionId, + int? MediaItemId, + int? PlaylistId) +{ + public CreateChannelFromLineupItem ToCommand() => + new( + MediaType, + CollectionType, + CollectionId, + MultiCollectionId, + SmartCollectionId, + RerunCollectionId, + MediaItemId, + PlaylistId); +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 474b8c951..d4c3ee0c3 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -531,6 +531,103 @@ } } }, + "/api/channels/from-lineup": { + "post": { + "tags": [ + "Channels" + ], + "summary": "Create a channel from a library lineup", + "description": "Atomically creates the channel, generated lineup collection, program schedule, schedule items, and classic playout. Template defaults are stamped at create time; advanced overrides win.", + "operationId": "CreateChannelFromLineup", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateChannelFromLineupRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateChannelFromLineupRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateChannelFromLineupRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateChannelFromLineupRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CreateChannelFromLineupResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateChannelFromLineupResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateChannelFromLineupResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/channels/bulk/renumber": { "post": { "tags": [ @@ -5187,6 +5284,353 @@ } } }, + "CreateChannelFromLineupAdvancedOptionsRequest": { + "type": "object", + "properties": { + "playbackOrder": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PlaybackOrder" + } + ] + }, + "fFmpegProfileId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "watermarkId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "fallbackFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "preRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "midRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "postRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "streamSelectorMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelStreamSelectorMode" + } + ] + }, + "streamSelector": { + "type": [ + "null", + "string" + ] + }, + "preferredAudioLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "preferredAudioTitle": { + "type": [ + "null", + "string" + ] + }, + "playoutSource": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelPlayoutSource" + } + ] + }, + "playoutMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelPlayoutMode" + } + ] + }, + "streamingMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StreamingMode" + } + ] + }, + "preferredSubtitleLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "subtitleMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelSubtitleMode" + } + ] + }, + "musicVideoCreditsMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelMusicVideoCreditsMode" + } + ] + }, + "musicVideoCreditsTemplate": { + "type": [ + "null", + "string" + ] + }, + "songVideoMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelSongVideoMode" + } + ] + }, + "transcodeMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelTranscodeMode" + } + ] + }, + "idleBehavior": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelIdleBehavior" + } + ] + }, + "shuffleScheduleItems": { + "type": [ + "null", + "boolean" + ] + }, + "randomStartPoint": { + "type": [ + "null", + "boolean" + ] + }, + "fixedStartTimeBehavior": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FixedStartTimeBehavior" + } + ] + } + } + }, + "CreateChannelFromLineupItemRequest": { + "required": [ + "mediaType", + "collectionType", + "collectionId", + "multiCollectionId", + "smartCollectionId", + "rerunCollectionId", + "mediaItemId", + "playlistId" + ], + "type": "object", + "properties": { + "mediaType": { + "$ref": "#/components/schemas/LibraryBrowseMediaType" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "rerunCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + } + } + }, + "CreateChannelFromLineupRequest": { + "required": [ + "name", + "number", + "group", + "categories", + "logo", + "isEnabled", + "showInEpg", + "templateId", + "advanced", + "lineup" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "number": { + "type": "string" + }, + "group": { + "type": "string" + }, + "categories": { + "type": "string" + }, + "logo": { + "$ref": "#/components/schemas/ArtworkContentTypeModel" + }, + "isEnabled": { + "type": "boolean" + }, + "showInEpg": { + "type": "boolean" + }, + "templateId": { + "type": "integer", + "format": "int32" + }, + "advanced": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CreateChannelFromLineupAdvancedOptionsRequest" + } + ] + }, + "lineup": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateChannelFromLineupItemRequest" + } + } + } + }, + "CreateChannelFromLineupResponseModel": { + "required": [ + "channelId", + "collectionId", + "programScheduleId", + "playoutId" + ], + "type": "object", + "properties": { + "channelId": { + "type": "integer", + "format": "int32" + }, + "collectionId": { + "type": "integer", + "format": "int32" + }, + "programScheduleId": { + "type": "integer", + "format": "int32" + }, + "playoutId": { + "type": "integer", + "format": "int32" + } + } + }, "CreateChannelRequest": { "required": [ "name", diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 406508c1d..5feccd51b 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -158,6 +158,60 @@ export interface components { "CombinedVersion": { "apiVersion": number; "appVersion": null | string; + }; + "CreateChannelFromLineupAdvancedOptionsRequest": { + "playbackOrder"?: null | components["schemas"]["PlaybackOrder"]; + "fFmpegProfileId"?: null | number; + "watermarkId"?: null | number; + "fallbackFillerId"?: null | number; + "preRollFillerId"?: null | number; + "midRollFillerId"?: null | number; + "postRollFillerId"?: null | number; + "streamSelectorMode"?: null | components["schemas"]["ChannelStreamSelectorMode"]; + "streamSelector"?: null | string; + "preferredAudioLanguageCode"?: null | string; + "preferredAudioTitle"?: null | string; + "playoutSource"?: null | components["schemas"]["ChannelPlayoutSource"]; + "playoutMode"?: null | components["schemas"]["ChannelPlayoutMode"]; + "streamingMode"?: null | components["schemas"]["StreamingMode"]; + "preferredSubtitleLanguageCode"?: null | string; + "subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"]; + "musicVideoCreditsMode"?: null | components["schemas"]["ChannelMusicVideoCreditsMode"]; + "musicVideoCreditsTemplate"?: null | string; + "songVideoMode"?: null | components["schemas"]["ChannelSongVideoMode"]; + "transcodeMode"?: null | components["schemas"]["ChannelTranscodeMode"]; + "idleBehavior"?: null | components["schemas"]["ChannelIdleBehavior"]; + "shuffleScheduleItems"?: null | boolean; + "randomStartPoint"?: null | boolean; + "fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"]; + }; + "CreateChannelFromLineupItemRequest": { + "mediaType": components["schemas"]["LibraryBrowseMediaType"]; + "collectionType": components["schemas"]["CollectionType"]; + "collectionId": null | number; + "multiCollectionId": null | number; + "smartCollectionId": null | number; + "rerunCollectionId": null | number; + "mediaItemId": null | number; + "playlistId": null | number; + }; + "CreateChannelFromLineupRequest": { + "name": string; + "number": string; + "group": string; + "categories": string; + "logo": components["schemas"]["ArtworkContentTypeModel"]; + "isEnabled": boolean; + "showInEpg": boolean; + "templateId": number; + "advanced": null | components["schemas"]["CreateChannelFromLineupAdvancedOptionsRequest"]; + "lineup": Array; + }; + "CreateChannelFromLineupResponseModel": { + "channelId": number; + "collectionId": number; + "programScheduleId": number; + "playoutId": number; }; "CreateChannelRequest": { "name": null | string; From cf36c30997ec6f4030d47ec92df51ee6f50125d0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Mon, 6 Jul 2026 21:09:00 +0200 Subject: [PATCH 2/2] fix(api): rework from-lineup to generated playlist design + review fixes (#63) Adversarial review found the previous multi-flood design non-viable: PlayoutModeSchedulerFlood never yields to a following Dynamic-start schedule item, so only the first item ever played, and grouping media items into a Collection silently dropped the requested order. Redesign: - Single-item lineup: one ProgramScheduleItemFlood referencing the target directly (media item / collection / smart / multi / rerun / playlist); no generated collection or playlist. Response PlaylistId is null. - Multi-item lineup (>= 2): one generated IsSystem Playlist in a get-or-created IsSystem PlaylistGroup ("Channel Lineups"), one PlaylistItem per entry in lineup order with PlayAll=true, referenced by a single Flood schedule item. Rerun collections and playlists are rejected (422) in multi lineups (PlaylistItem/CollectionKey lack the fields to enumerate them). Review fixes: - Normalize + strict-validate MediaType<->CollectionType pairs once up front (422 on mismatch / wrong id / not exactly one id). - Reject MultiCollection with non-Shuffle order (mirrors PlayoutModeMustBeValid), Mirror playout source, all via 422. - OnDemand parity: queue TimeShiftOnDemandPlayout post-commit. - De-collide generated ProgramSchedule and Playlist names against their unique indexes instead of leaking a UNIQUE-constraint DbUpdateException. - Generic 422 on save failure + ILogger; AnyAsync existence checks; Either/Validation unwrap via Match; XML doc on request DTO + endpoint. - Response model: ChannelId, PlaylistId (nullable), ProgramScheduleId, PlayoutId (CollectionId removed). Regenerated OpenAPI v1.json + v1.d.ts. Co-Authored-By: Claude Fable 5 --- .../CreateChannelFromLineupHandler.cs | 547 ++++++++++++----- .../CreateChannelFromLineupResponseModel.cs | 2 +- .../CreateChannelFromLineupHandlerTests.cs | 568 ++++++++++++++++-- ErsatzTV/Controllers/Api/ChannelController.cs | 11 +- .../CreateChannelFromLineupRequest.cs | 6 + ErsatzTV/wwwroot/openapi/v1.json | 11 +- web/src/api/generated/v1.d.ts | 2 +- 7 files changed, 922 insertions(+), 225 deletions(-) diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs index b28bc1a84..fd8773482 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text.RegularExpressions; using System.Threading.Channels; using ErsatzTV.Application.Playouts; @@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Channel = ErsatzTV.Core.Domain.Channel; namespace ErsatzTV.Application.Channels; @@ -19,29 +20,40 @@ namespace ErsatzTV.Application.Channels; public class CreateChannelFromLineupHandler( ChannelWriter workerChannel, IDbContextFactory dbContextFactory, - ISearchTargets searchTargets) + ISearchTargets searchTargets, + ILogger logger) : IRequestHandler> { + // The single system playlist group that holds every generated channel-lineup playlist. + // Matches the Trakt "Trakt Lists" precedent (DbInitializer + delete guards on IsSystem). + private const string SystemPlaylistGroupName = "Channel Lineups"; + public async Task> Handle( CreateChannelFromLineup request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - PreparedCreate prepared; Either validation = await Validate(dbContext, request, cancellationToken); - foreach (BaseError error in validation.LeftToSeq()) - { - return error; - } - - prepared = validation.IfLeft(() => throw new InvalidOperationException("Validation failed without error")); + return await validation.Match( + Left: error => Task.FromResult>(error), + Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken)); + } + private async Task> PersistAndDispatch( + TvContext dbContext, + PreparedCreate prepared, + CancellationToken cancellationToken) + { await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); try { dbContext.Channels.Add(prepared.Channel); - dbContext.Collections.Add(prepared.Collection); + if (prepared.Playlist is not null) + { + dbContext.Playlists.Add(prepared.Playlist); + } + dbContext.ProgramSchedules.Add(prepared.ProgramSchedule); dbContext.Playouts.Add(prepared.Playout); @@ -51,16 +63,28 @@ public class CreateChannelFromLineupHandler( catch (DbUpdateException ex) { await transaction.RollbackAsync(cancellationToken); - return BaseError.New($"Unable to create channel from lineup: {ex.GetBaseException().Message}"); + logger.LogError(ex, "Failed to persist channel created from lineup"); + return BaseError.New("Unable to create channel from lineup"); } searchTargets.SearchTargetsChanged(); - await workerChannel.WriteAsync(new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset), cancellationToken); + await workerChannel.WriteAsync( + new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset), + cancellationToken); + + // Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build. + if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand) + { + await workerChannel.WriteAsync( + new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false), + cancellationToken); + } + await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken); return new CreateChannelFromLineupResponseModel( prepared.Channel.Id, - prepared.Collection.Id, + prepared.Playlist?.Id, prepared.ProgramSchedule.Id, prepared.Playout.Id); } @@ -126,23 +150,71 @@ public class CreateChannelFromLineupHandler( return new NotFoundError($"Channel template {request.TemplateId} does not exist."); } - Either referenceValidation = await ValidateReferences( - dbContext, - template, - advanced, - request.Lineup, - cancellationToken); - foreach (BaseError error in referenceValidation.LeftToSeq()) - { - return error; - } - int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId; int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId; int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId; int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId; int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId; PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological; + ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource; + + // Mirror channels need special MirrorSourceChannelId plumbing (see CreateChannelHandler); + // this endpoint only builds generated playouts. + if (playoutSource is ChannelPlayoutSource.Mirror) + { + return BaseError.New("Mirror playout source is not supported by this endpoint"); + } + + Either referenceValidation = await ValidateReferences( + dbContext, + advanced, + template, + cancellationToken); + foreach (BaseError error in referenceValidation.LeftToSeq()) + { + return error; + } + + // Normalize + validate every lineup entry once, so validation and build see the same data. + var normalized = new List(); + for (int i = 0; i < request.Lineup.Count; i++) + { + Either itemValidation = + await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken); + foreach (BaseError error in itemValidation.LeftToSeq()) + { + return error; + } + + foreach (NormalizedLineupItem item in itemValidation.RightToSeq()) + { + normalized.Add(item); + } + } + + bool multiItem = normalized.Count >= 2; + + // MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid). + if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) && + playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder)) + { + return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'"); + } + + if (multiItem) + { + // The generated playlist cannot express rerun collections or nested playlists + // (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields). + for (int i = 0; i < normalized.Count; i++) + { + if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist) + { + return BaseError.New( + $"lineup[{normalized[i].Index}]: rerun collections and playlists are only " + + "supported as a single-item lineup"); + } + } + } Channel channel = BuildChannel( request, @@ -155,19 +227,43 @@ public class CreateChannelFromLineupHandler( ffmpegProfileId, fallbackFillerId); - Collection collection = BuildCollection(number, name, request.Lineup); - ProgramSchedule schedule = BuildProgramSchedule(number, name, template, advanced); - schedule.Items = BuildScheduleItems( - collection, - request.Lineup, + string scheduleName = await DeCollideName( + GeneratedName(number, name, "Schedule"), + (candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct), + cancellationToken); + + ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced); + + Playlist playlist = null; + ProgramScheduleItemFlood floodItem = BuildFloodBase( playbackOrder, - template, advanced, + template, fallbackFillerId, preRollFillerId, midRollFillerId, postRollFillerId); + if (multiItem) + { + playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken); + floodItem.CollectionType = CollectionType.Playlist; + floodItem.Playlist = playlist; + } + else + { + NormalizedLineupItem only = normalized[0]; + floodItem.CollectionType = only.CollectionType; + floodItem.CollectionId = only.CollectionId; + floodItem.MultiCollectionId = only.MultiCollectionId; + floodItem.SmartCollectionId = only.SmartCollectionId; + floodItem.RerunCollectionId = only.RerunCollectionId; + floodItem.MediaItemId = only.MediaItemId; + floodItem.PlaylistId = only.PlaylistId; + } + + schedule.Items = [floodItem]; + var playout = new Playout { Channel = channel, @@ -175,7 +271,60 @@ public class CreateChannelFromLineupHandler( ScheduleKind = PlayoutScheduleKind.Classic }; - return new PreparedCreate(channel, collection, schedule, playout); + return new PreparedCreate(channel, playlist, schedule, playout); + } + + private static async Task BuildPlaylist( + TvContext dbContext, + string channelNumber, + string channelName, + List lineup, + PlaybackOrder playbackOrder, + CancellationToken cancellationToken) + { + // Reuse the single system playlist group, creating it on first use. + PlaylistGroup playlistGroup = await dbContext.PlaylistGroups + .FirstOrDefaultAsync(pg => pg.IsSystem && pg.Name == SystemPlaylistGroupName, cancellationToken); + playlistGroup ??= new PlaylistGroup { Name = SystemPlaylistGroupName, IsSystem = true }; + + string playlistName = await DeCollideName( + GeneratedName(channelNumber, channelName, "Lineup"), + (candidate, ct) => dbContext.Playlists.AnyAsync( + p => p.PlaylistGroupId == playlistGroup.Id && p.Name == candidate, + ct), + cancellationToken); + + var playlist = new Playlist + { + Name = playlistName, + IsSystem = true, + PlaylistGroup = playlistGroup, + PlaylistGroupId = playlistGroup.Id, + Items = [] + }; + + int index = 1; + foreach (NormalizedLineupItem item in lineup) + { + playlist.Items.Add(new PlaylistItem + { + Playlist = playlist, + Index = index++, + CollectionType = item.CollectionType, + CollectionId = item.CollectionId, + MultiCollectionId = item.MultiCollectionId, + SmartCollectionId = item.SmartCollectionId, + MediaItemId = item.MediaItemId, + PlaybackOrder = playbackOrder, + + // Play every item in each entry before advancing so lineup order is honored + // (PlaylistEnumerator round-robins one-per-entry unless PlayAll/Count). + PlayAll = true, + IncludeInProgramGuide = true + }); + } + + return playlist; } private static Channel BuildChannel( @@ -242,37 +391,13 @@ public class CreateChannelFromLineupHandler( }; } - private static Collection BuildCollection(string channelNumber, string channelName, List lineup) - { - var collection = new Collection - { - Name = GeneratedName(channelNumber, channelName, "Lineup"), - UseCustomPlaybackOrder = true, - CollectionItems = [] - }; - - int index = 1; - foreach (CreateChannelFromLineupItem item in lineup.Where(IsMediaLineupItem)) - { - collection.CollectionItems.Add(new CollectionItem - { - Collection = collection, - MediaItemId = item.MediaItemId.GetValueOrDefault(), - CustomIndex = index++ - }); - } - - return collection; - } - private static ProgramSchedule BuildProgramSchedule( - string channelNumber, - string channelName, + string scheduleName, ChannelTemplate template, CreateChannelFromLineupAdvancedOptions advanced) => new() { - Name = GeneratedName(channelNumber, channelName, "Schedule"), + Name = scheduleName, KeepMultiPartEpisodesTogether = true, TreatCollectionsAsShows = true, ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems, @@ -281,6 +406,34 @@ public class CreateChannelFromLineupHandler( Items = [] }; + private static ProgramScheduleItemFlood BuildFloodBase( + PlaybackOrder playbackOrder, + CreateChannelFromLineupAdvancedOptions advanced, + ChannelTemplate template, + int? fallbackFillerId, + int? preRollFillerId, + int? midRollFillerId, + int? postRollFillerId) => + new() + { + Index = 1, + PlaybackOrder = playbackOrder, + GuideMode = GuideMode.Normal, + CustomTitle = string.Empty, + SearchTitle = string.Empty, + SearchQuery = string.Empty, + PreRollFillerId = preRollFillerId, + MidRollFillerId = midRollFillerId, + PostRollFillerId = postRollFillerId, + FallbackFillerId = fallbackFillerId, + PreferredAudioLanguageCode = + advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty, + PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty, + PreferredSubtitleLanguageCode = + advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty, + SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode + }; + private static string GeneratedName(string channelNumber, string channelName, string suffix) { string prefix = $"{channelNumber} {channelName}".Trim(); @@ -293,80 +446,36 @@ public class CreateChannelFromLineupHandler( return $"{prefix} {suffix}".Trim(); } - private static List BuildScheduleItems( - Collection collection, - List lineup, - PlaybackOrder playbackOrder, - ChannelTemplate template, - CreateChannelFromLineupAdvancedOptions advanced, - int? fallbackFillerId, - int? preRollFillerId, - int? midRollFillerId, - int? postRollFillerId) + // De-collide a generated (already <= 50 char) name against a unique index by appending " 2", " 3", ... + // rather than leaking a raw UNIQUE-constraint failure. + private static async Task DeCollideName( + string baseName, + Func> exists, + CancellationToken cancellationToken) { - var result = new List(); - if (collection.CollectionItems.Count != 0) + if (!await exists(baseName, cancellationToken)) { - result.Add(new ProgramScheduleItemFlood - { - Index = result.Count + 1, - Collection = collection, - CollectionType = CollectionType.Collection, - PlaybackOrder = playbackOrder, - GuideMode = GuideMode.Normal, - CustomTitle = string.Empty, - SearchTitle = string.Empty, - SearchQuery = string.Empty, - PreRollFillerId = preRollFillerId, - MidRollFillerId = midRollFillerId, - PostRollFillerId = postRollFillerId, - FallbackFillerId = fallbackFillerId, - PreferredAudioLanguageCode = - advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty, - PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty, - PreferredSubtitleLanguageCode = - advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty, - SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode - }); + return baseName; } - foreach (CreateChannelFromLineupItem item in lineup.Where(i => !IsMediaLineupItem(i))) + for (int n = 2; ; n++) { - result.Add(new ProgramScheduleItemFlood - { - Index = result.Count + 1, - CollectionType = item.CollectionType, - CollectionId = item.CollectionId, - MultiCollectionId = item.MultiCollectionId, - SmartCollectionId = item.SmartCollectionId, - RerunCollectionId = item.RerunCollectionId, - PlaylistId = item.PlaylistId, - PlaybackOrder = playbackOrder, - GuideMode = GuideMode.Normal, - CustomTitle = string.Empty, - SearchTitle = string.Empty, - SearchQuery = string.Empty, - PreRollFillerId = preRollFillerId, - MidRollFillerId = midRollFillerId, - PostRollFillerId = postRollFillerId, - FallbackFillerId = fallbackFillerId, - PreferredAudioLanguageCode = - advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty, - PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty, - PreferredSubtitleLanguageCode = - advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty, - SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode - }); - } + string suffix = $" {n}"; + string candidate = baseName.Length + suffix.Length > 50 + ? baseName[..(50 - suffix.Length)].TrimEnd() + suffix + : baseName + suffix; - return result; + if (!await exists(candidate, cancellationToken)) + { + return candidate; + } + } } private static async Task> ValidateReferences( TvContext dbContext, - ChannelTemplate template, CreateChannelFromLineupAdvancedOptions advanced, - List lineup, + ChannelTemplate template, CancellationToken cancellationToken) { int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId; @@ -396,15 +505,6 @@ public class CreateChannelFromLineupHandler( return error; } - for (int i = 0; i < lineup.Count; i++) - { - Either itemValidation = await ValidateLineupItem(dbContext, lineup[i], i, cancellationToken); - foreach (BaseError error in itemValidation.LeftToSeq()) - { - return error; - } - } - return Unit.Default; } @@ -462,17 +562,12 @@ public class CreateChannelFromLineupHandler( CancellationToken cancellationToken) => dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken); - private static async Task> ValidateLineupItem( + private static async Task> NormalizeLineupItem( TvContext dbContext, CreateChannelFromLineupItem item, int index, CancellationToken cancellationToken) { - if (item.MediaType is LibraryBrowseMediaType.RerunCollection) - { - item = item with { CollectionType = CollectionType.RerunFirstRun }; - } - int providedIds = new int?[] { item.CollectionId, @@ -488,48 +583,172 @@ public class CreateChannelFromLineupHandler( return BaseError.New($"lineup[{index}] must provide exactly one typed id."); } - return item.CollectionType switch + switch (item.MediaType) { - CollectionType.Movie when item.MediaType is LibraryBrowseMediaType.Movie && item.MediaItemId.HasValue => - await Exists(dbContext.Movies, item.MediaItemId.Value, $"lineup[{index}] Movie", cancellationToken), - CollectionType.TelevisionShow when item.MediaType is LibraryBrowseMediaType.TelevisionShow && item.MediaItemId.HasValue => - await Exists(dbContext.Shows, item.MediaItemId.Value, $"lineup[{index}] TelevisionShow", cancellationToken), - CollectionType.TelevisionSeason when item.MediaType is LibraryBrowseMediaType.TelevisionSeason && item.MediaItemId.HasValue => - await Exists(dbContext.Seasons, item.MediaItemId.Value, $"lineup[{index}] TelevisionSeason", cancellationToken), - CollectionType.Artist when item.MediaType is LibraryBrowseMediaType.Artist && item.MediaItemId.HasValue => - await Exists(dbContext.Artists, item.MediaItemId.Value, $"lineup[{index}] Artist", cancellationToken), - CollectionType.Collection when item.MediaType is LibraryBrowseMediaType.Collection && item.CollectionId.HasValue => - await Exists(dbContext.Collections, item.CollectionId.Value, $"lineup[{index}] Collection", cancellationToken), - CollectionType.SmartCollection when item.MediaType is LibraryBrowseMediaType.SmartCollection && item.SmartCollectionId.HasValue => - await Exists(dbContext.SmartCollections, item.SmartCollectionId.Value, $"lineup[{index}] SmartCollection", cancellationToken), - CollectionType.MultiCollection when item.MediaType is LibraryBrowseMediaType.MultiCollection && item.MultiCollectionId.HasValue => - await Exists(dbContext.MultiCollections, item.MultiCollectionId.Value, $"lineup[{index}] MultiCollection", cancellationToken), - CollectionType.RerunFirstRun when item.MediaType is LibraryBrowseMediaType.RerunCollection && item.RerunCollectionId.HasValue => - await Exists(dbContext.RerunCollections, item.RerunCollectionId.Value, $"lineup[{index}] RerunCollection", cancellationToken), - CollectionType.Playlist when item.MediaType is LibraryBrowseMediaType.Playlist && item.PlaylistId.HasValue => - await Exists(dbContext.Playlists, item.PlaylistId.Value, $"lineup[{index}] Playlist", cancellationToken), - _ => BaseError.New($"lineup[{index}] has an unsupported or mismatched media type, collection type, and id.") - }; + case LibraryBrowseMediaType.Movie: + return await NormalizeMediaItem( + dbContext.Movies, item, CollectionType.Movie, index, "Movie", cancellationToken); + case LibraryBrowseMediaType.TelevisionShow: + return await NormalizeMediaItem( + dbContext.Shows, item, CollectionType.TelevisionShow, index, "TelevisionShow", cancellationToken); + case LibraryBrowseMediaType.TelevisionSeason: + return await NormalizeMediaItem( + dbContext.Seasons, item, CollectionType.TelevisionSeason, index, "TelevisionSeason", cancellationToken); + case LibraryBrowseMediaType.Artist: + return await NormalizeMediaItem( + dbContext.Artists, item, CollectionType.Artist, index, "Artist", cancellationToken); + case LibraryBrowseMediaType.Collection: + if (item.CollectionType != CollectionType.Collection) + { + return Mismatch(index, item); + } + + if (!item.CollectionId.HasValue) + { + return WrongId(index, item.MediaType, "collectionId"); + } + + return await ExistsThen( + dbContext.Collections, + item.CollectionId.Value, + $"lineup[{index}] Collection", + new NormalizedLineupItem(index, CollectionType.Collection, CollectionId: item.CollectionId), + cancellationToken); + case LibraryBrowseMediaType.SmartCollection: + if (item.CollectionType != CollectionType.SmartCollection) + { + return Mismatch(index, item); + } + + if (!item.SmartCollectionId.HasValue) + { + return WrongId(index, item.MediaType, "smartCollectionId"); + } + + return await ExistsThen( + dbContext.SmartCollections, + item.SmartCollectionId.Value, + $"lineup[{index}] SmartCollection", + new NormalizedLineupItem(index, CollectionType.SmartCollection, SmartCollectionId: item.SmartCollectionId), + cancellationToken); + case LibraryBrowseMediaType.MultiCollection: + if (item.CollectionType != CollectionType.MultiCollection) + { + return Mismatch(index, item); + } + + if (!item.MultiCollectionId.HasValue) + { + return WrongId(index, item.MediaType, "multiCollectionId"); + } + + return await ExistsThen( + dbContext.MultiCollections, + item.MultiCollectionId.Value, + $"lineup[{index}] MultiCollection", + new NormalizedLineupItem(index, CollectionType.MultiCollection, MultiCollectionId: item.MultiCollectionId), + cancellationToken); + case LibraryBrowseMediaType.RerunCollection: + if (item.CollectionType != CollectionType.RerunFirstRun) + { + return Mismatch(index, item); + } + + if (!item.RerunCollectionId.HasValue) + { + return WrongId(index, item.MediaType, "rerunCollectionId"); + } + + return await ExistsThen( + dbContext.RerunCollections, + item.RerunCollectionId.Value, + $"lineup[{index}] RerunCollection", + new NormalizedLineupItem(index, CollectionType.RerunFirstRun, RerunCollectionId: item.RerunCollectionId), + cancellationToken); + case LibraryBrowseMediaType.Playlist: + if (item.CollectionType != CollectionType.Playlist) + { + return Mismatch(index, item); + } + + if (!item.PlaylistId.HasValue) + { + return WrongId(index, item.MediaType, "playlistId"); + } + + return await ExistsThen( + dbContext.Playlists, + item.PlaylistId.Value, + $"lineup[{index}] Playlist", + new NormalizedLineupItem(index, CollectionType.Playlist, PlaylistId: item.PlaylistId), + cancellationToken); + default: + return BaseError.New($"lineup[{index}] has an unsupported media type '{item.MediaType}'."); + } } - private static async Task> Exists( + private static async Task> NormalizeMediaItem( DbSet set, - int id, + CreateChannelFromLineupItem item, + CollectionType expectedType, + int index, string label, CancellationToken cancellationToken) where TEntity : class { - TEntity entity = await set.FindAsync([id], cancellationToken); - return entity is null ? new NotFoundError($"{label} {id} does not exist.") : Unit.Default; + if (item.CollectionType != expectedType) + { + return Mismatch(index, item); + } + + if (!item.MediaItemId.HasValue) + { + return WrongId(index, item.MediaType, "mediaItemId"); + } + + return await ExistsThen( + set, + item.MediaItemId.Value, + $"lineup[{index}] {label}", + new NormalizedLineupItem(index, expectedType, MediaItemId: item.MediaItemId), + cancellationToken); } - private static bool IsMediaLineupItem(CreateChannelFromLineupItem item) => - item.CollectionType is CollectionType.Movie or CollectionType.TelevisionShow or CollectionType.TelevisionSeason - or CollectionType.Artist; + private static BaseError Mismatch(int index, CreateChannelFromLineupItem item) => + BaseError.New( + $"lineup[{index}]: media type '{item.MediaType}' does not match collection type '{item.CollectionType}'."); + + private static BaseError WrongId(int index, LibraryBrowseMediaType mediaType, string expectedField) => + BaseError.New($"lineup[{index}]: media type '{mediaType}' requires a {expectedField}."); + + private static async Task> ExistsThen( + DbSet set, + int id, + string label, + NormalizedLineupItem normalized, + CancellationToken cancellationToken) + where TEntity : class + { + bool exists = await set.AsNoTracking() + .AnyAsync(e => EF.Property(e, "Id") == id, cancellationToken); + return exists + ? normalized + : new NotFoundError($"{label} {id} does not exist."); + } + + private sealed record NormalizedLineupItem( + int Index, + CollectionType CollectionType, + int? CollectionId = null, + int? MultiCollectionId = null, + int? SmartCollectionId = null, + int? RerunCollectionId = null, + int? MediaItemId = null, + int? PlaylistId = null); private sealed record PreparedCreate( Channel Channel, - Collection Collection, + Playlist Playlist, ProgramSchedule ProgramSchedule, Playout Playout); } diff --git a/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupResponseModel.cs b/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupResponseModel.cs index b6ec22946..9d0ecca56 100644 --- a/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupResponseModel.cs @@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Api.Channels; public record CreateChannelFromLineupResponseModel( int ChannelId, - int CollectionId, + int? PlaylistId, int ProgramScheduleId, int PlayoutId); diff --git a/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs index 229728253..a4e46ce90 100644 --- a/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs @@ -15,10 +15,12 @@ using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NUnit.Framework; using Shouldly; using DomainChannel = ErsatzTV.Core.Domain.Channel; +using DomainPlaylistItem = ErsatzTV.Core.Domain.PlaylistItem; namespace ErsatzTV.Tests.Application.Channels; @@ -41,7 +43,7 @@ public class CreateChannelFromLineupHandlerTests public async Task TearDown() => await _db.DisposeAsync(); [Test] - public async Task Should_Create_Channel_Collection_Schedule_Items_And_Playout() + public async Task Should_Create_Single_Media_Item_Directly_Without_Playlist() { await SeedTemplateDependencies(); await SeedTemplate(); @@ -52,11 +54,16 @@ public class CreateChannelFromLineupHandlerTests CreateChannelFromLineupResponseModel response = RightOf(result); response.ChannelId.ShouldBeGreaterThan(0); - response.CollectionId.ShouldBeGreaterThan(0); + response.PlaylistId.ShouldBeNull(); response.ProgramScheduleId.ShouldBeGreaterThan(0); response.PlayoutId.ShouldBeGreaterThan(0); await using TvContext context = _db.CreateContext(); + + // No generated playlist or collection for a single-item lineup. + (await context.Playlists.CountAsync()).ShouldBe(0); + (await context.Collections.CountAsync()).ShouldBe(0); + DomainChannel channel = await context.Channels.SingleAsync(); channel.Name.ShouldBe("Movies"); channel.Number.ShouldBe("12"); @@ -66,25 +73,18 @@ public class CreateChannelFromLineupHandlerTests channel.StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingSegmenter); channel.ShowInEpg.ShouldBeTrue(); - Collection collection = await context.Collections - .Include(c => c.CollectionItems) - .SingleAsync(); - collection.Name.ShouldBe("12 Movies Lineup"); - collection.UseCustomPlaybackOrder.ShouldBeTrue(); - collection.CollectionItems.Single().MediaItemId.ShouldBe(42); - collection.CollectionItems.Single().CustomIndex.ShouldBe(1); - - ProgramSchedule schedule = await context.ProgramSchedules - .Include(ps => ps.Items) - .SingleAsync(); + ProgramSchedule schedule = await context.ProgramSchedules.Include(ps => ps.Items).SingleAsync(); schedule.Name.ShouldBe("12 Movies Schedule"); schedule.ShuffleScheduleItems.ShouldBeTrue(); schedule.RandomStartPoint.ShouldBeTrue(); schedule.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Strict); ProgramScheduleItem item = schedule.Items.Single(); - item.CollectionType.ShouldBe(CollectionType.Collection); - item.CollectionId.ShouldBe(collection.Id); + item.ShouldBeOfType(); + item.CollectionType.ShouldBe(CollectionType.Movie); + item.MediaItemId.ShouldBe(42); + item.CollectionId.ShouldBeNull(); + item.PlaylistId.ShouldBeNull(); item.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle); item.PreRollFillerId.ShouldBe(2); item.MidRollFillerId.ShouldBe(3); @@ -106,6 +106,393 @@ public class CreateChannelFromLineupHandlerTests _searchTargets.Received(1).SearchTargetsChanged(); } + [Test] + public async Task Should_Create_Single_Collection_Item_Directly() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedCollection(7); + + Either result = + await MakeHandler().Handle(MakeRequest(lineup: [CollectionItem(7)]), CancellationToken.None); + + RightOf(result).PlaylistId.ShouldBeNull(); + + await using TvContext context = _db.CreateContext(); + (await context.Playlists.CountAsync()).ShouldBe(0); + ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync(); + item.CollectionType.ShouldBe(CollectionType.Collection); + item.CollectionId.ShouldBe(7); + } + + [Test] + public async Task Should_Create_Single_Playlist_Item_Directly() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedExistingPlaylist(9); + + Either result = + await MakeHandler().Handle(MakeRequest(lineup: [PlaylistEntry(9)]), CancellationToken.None); + + // The generated-playlist id is null for a single-item lineup, even when it references a playlist. + RightOf(result).PlaylistId.ShouldBeNull(); + + await using TvContext context = _db.CreateContext(); + ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync(); + item.CollectionType.ShouldBe(CollectionType.Playlist); + item.PlaylistId.ShouldBe(9); + } + + [Test] + public async Task Should_Create_Single_Rerun_Collection_As_First_Run() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedRerunCollection(11); + + Either result = + await MakeHandler().Handle(MakeRequest(lineup: [RerunItem(11)]), CancellationToken.None); + + RightOf(result).PlaylistId.ShouldBeNull(); + + await using TvContext context = _db.CreateContext(); + ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync(); + item.CollectionType.ShouldBe(CollectionType.RerunFirstRun); + item.RerunCollectionId.ShouldBe(11); + } + + [Test] + public async Task Should_Create_Multi_Item_Lineup_As_Generated_System_Playlist() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + await SeedShow(43); + await SeedCollection(7); + + Either result = + await MakeHandler().Handle( + MakeRequest(lineup: [MovieItem(42), ShowItem(43), CollectionItem(7)]), + CancellationToken.None); + + CreateChannelFromLineupResponseModel response = RightOf(result); + response.PlaylistId.ShouldNotBeNull(); + + await using TvContext context = _db.CreateContext(); + + PlaylistGroup group = await context.PlaylistGroups.SingleAsync(); + group.Name.ShouldBe("Channel Lineups"); + group.IsSystem.ShouldBeTrue(); + + Playlist playlist = await context.Playlists.Include(p => p.Items).SingleAsync(); + playlist.Id.ShouldBe(response.PlaylistId!.Value); + playlist.Name.ShouldBe("12 Movies Lineup"); + playlist.IsSystem.ShouldBeTrue(); + playlist.PlaylistGroupId.ShouldBe(group.Id); + + List items = playlist.Items.OrderBy(i => i.Index).ToList(); + items.Count.ShouldBe(3); + items.ShouldAllBe(i => i.PlayAll); + items.ShouldAllBe(i => i.IncludeInProgramGuide); + items.ShouldAllBe(i => i.PlaybackOrder == PlaybackOrder.Shuffle); + + items[0].Index.ShouldBe(1); + items[0].CollectionType.ShouldBe(CollectionType.Movie); + items[0].MediaItemId.ShouldBe(42); + + items[1].Index.ShouldBe(2); + items[1].CollectionType.ShouldBe(CollectionType.TelevisionShow); + items[1].MediaItemId.ShouldBe(43); + + items[2].Index.ShouldBe(3); + items[2].CollectionType.ShouldBe(CollectionType.Collection); + items[2].CollectionId.ShouldBe(7); + + // Exactly one flood schedule item, referencing the generated playlist. + ProgramScheduleItem scheduleItem = await context.ProgramScheduleItems.SingleAsync(); + scheduleItem.ShouldBeOfType(); + scheduleItem.CollectionType.ShouldBe(CollectionType.Playlist); + scheduleItem.PlaylistId.ShouldBe(playlist.Id); + } + + [Test] + public async Task Should_Reject_Rerun_Collection_In_Multi_Item_Lineup() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + await SeedRerunCollection(11); + + Either result = + await MakeHandler().Handle( + MakeRequest(lineup: [MovieItem(42), RerunItem(11)]), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("single-item"); + } + + [Test] + public async Task Should_Reject_Playlist_In_Multi_Item_Lineup() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + await SeedExistingPlaylist(9); + + Either result = + await MakeHandler().Handle( + MakeRequest(lineup: [MovieItem(42), PlaylistEntry(9)]), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("single-item"); + } + + [Test] + public async Task Advanced_Overrides_Should_Beat_Template_Defaults() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + + await using (TvContext context = _db.CreateContext()) + { + context.FFmpegProfiles.Add(new FFmpegProfile { Id = 2, Name = "advanced-profile" }); + context.ChannelWatermarks.Add(new ChannelWatermark { Id = 21, Name = "wm", Image = "wm.png" }); + context.FillerPresets.AddRange( + MakeFiller(6, FillerKind.Fallback), + MakeFiller(7, FillerKind.PreRoll)); + await context.SaveChangesAsync(); + } + + var advanced = new CreateChannelFromLineupAdvancedOptions( + PlaybackOrder: PlaybackOrder.Shuffle, + FFmpegProfileId: 2, + WatermarkId: 21, + FallbackFillerId: 6, + PreRollFillerId: 7, + StreamingMode: StreamingMode.TransportStream); + + Either result = + await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None); + + RightOf(result); + + await using TvContext assert = _db.CreateContext(); + DomainChannel channel = await assert.Channels.SingleAsync(); + + // Advanced wins over the template's values (template = profile 1, no watermark, HLS, fallback 5). + channel.FFmpegProfileId.ShouldBe(2); + channel.WatermarkId.ShouldBe(21); + channel.StreamingMode.ShouldBe(StreamingMode.TransportStream); + channel.FallbackFillerId.ShouldBe(6); + + ProgramScheduleItem item = await assert.ProgramScheduleItems.SingleAsync(); + item.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle); + item.PreRollFillerId.ShouldBe(7); + item.FallbackFillerId.ShouldBe(6); + + // Not overridden in Advanced -> falls through to the template value. + item.MidRollFillerId.ShouldBe(3); + } + + [Test] + public async Task Should_Return_Validation_Error_When_Not_Exactly_One_Id_Provided() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + + var item = new CreateChannelFromLineupItem( + LibraryBrowseMediaType.Movie, CollectionType.Movie, null, null, null, null, null, null); + + Either result = + await MakeHandler().Handle(MakeRequest(lineup: [item]), CancellationToken.None); + + LeftOf(result).Value.ShouldContain("exactly one typed id"); + } + + [Test] + public async Task Should_Return_Validation_Error_When_Media_Type_Does_Not_Match_Collection_Type() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + + var item = new CreateChannelFromLineupItem( + LibraryBrowseMediaType.Movie, CollectionType.Playlist, null, null, null, null, 42, null); + + Either result = + await MakeHandler().Handle(MakeRequest(lineup: [item]), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("does not match"); + } + + [Test] + public async Task Should_Reject_MultiCollection_With_Non_Shuffle_Playback_Order() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMultiCollection(15); + + var advanced = new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: PlaybackOrder.Chronological); + + Either result = + await MakeHandler().Handle( + MakeRequest(advanced: advanced, lineup: [MultiCollectionItem(15)]), + CancellationToken.None); + + LeftOf(result).Value.ShouldContain("Invalid playback order for multi collection"); + } + + [Test] + public async Task Should_Reject_Mirror_Playout_Source() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + + var advanced = new CreateChannelFromLineupAdvancedOptions( + PlaybackOrder: PlaybackOrder.Shuffle, + PlayoutSource: ChannelPlayoutSource.Mirror); + + Either result = + await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None); + + LeftOf(result).Value.ShouldContain("Mirror playout source"); + } + + [Test] + public async Task OnDemand_Playout_Mode_Should_Queue_TimeShift_After_Build() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + + var advanced = new CreateChannelFromLineupAdvancedOptions( + PlaybackOrder: PlaybackOrder.Shuffle, + PlayoutMode: ChannelPlayoutMode.OnDemand); + + Either result = + await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None); + + CreateChannelFromLineupResponseModel response = RightOf(result); + + _background.Reader.TryRead(out IBackgroundServiceRequest? first).ShouldBeTrue(); + first.ShouldBeOfType(); + + _background.Reader.TryRead(out IBackgroundServiceRequest? second).ShouldBeTrue(); + TimeShiftOnDemandPlayout timeShift = second.ShouldBeOfType(); + timeShift.PlayoutId.ShouldBe(response.PlayoutId); + timeShift.Force.ShouldBeFalse(); + + _background.Reader.TryRead(out IBackgroundServiceRequest? third).ShouldBeTrue(); + third.ShouldBeOfType(); + } + + [Test] + public async Task Should_Recreate_With_De_Collided_Names_After_Channel_Delete() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + await SeedShow(43); + + // First create builds "12 Movies Schedule" + "12 Movies Lineup". + RightOf(await MakeHandler().Handle( + MakeRequest(lineup: [MovieItem(42), ShowItem(43)]), + CancellationToken.None)); + + // Delete the channel + playout but leave the generated schedule/playlist rows behind. + await using (TvContext context = _db.CreateContext()) + { + context.Playouts.RemoveRange(await context.Playouts.ToListAsync()); + context.Channels.RemoveRange(await context.Channels.ToListAsync()); + await context.SaveChangesAsync(); + } + + // Second create with the same name must succeed via de-collided names. + CreateChannelFromLineupResponseModel response = RightOf(await MakeHandler().Handle( + MakeRequest(lineup: [MovieItem(42), ShowItem(43)]), + CancellationToken.None)); + + await using TvContext assert = _db.CreateContext(); + + bool scheduleExists = await assert.ProgramSchedules.AnyAsync(ps => ps.Name == "12 Movies Schedule 2"); + scheduleExists.ShouldBeTrue(); + + Playlist newPlaylist = await assert.Playlists.SingleAsync(p => p.Id == response.PlaylistId!.Value); + newPlaylist.Name.ShouldBe("12 Movies Lineup 2"); + + // Only one system playlist group is ever created. + (await assert.PlaylistGroups.CountAsync(pg => pg.Name == "Channel Lineups")).ShouldBe(1); + } + + [Test] + public async Task Should_Return_NotFound_For_Missing_Advanced_References() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + + await AssertNotFound( + new CreateChannelFromLineupAdvancedOptions(FFmpegProfileId: 999), + "FFmpegProfile 999"); + await AssertNotFound( + new CreateChannelFromLineupAdvancedOptions(WatermarkId: 999), + "Watermark 999"); + await AssertNotFound( + new CreateChannelFromLineupAdvancedOptions(FallbackFillerId: 999), + "Fallback filler 999"); + await AssertNotFound( + new CreateChannelFromLineupAdvancedOptions(PreRollFillerId: 999), + "Pre-roll filler 999"); + await AssertNotFound( + new CreateChannelFromLineupAdvancedOptions(MidRollFillerId: 999), + "Mid-roll filler 999"); + await AssertNotFound( + new CreateChannelFromLineupAdvancedOptions(PostRollFillerId: 999), + "Post-roll filler 999"); + } + + [Test] + public async Task Should_Roll_Back_When_Save_Fails() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + await SeedShow(43); + + // A non-system group with the reserved name forces the handler's new system group insert + // to violate the unique Name index at save time (multi-item lineup path). + await using (TvContext context = _db.CreateContext()) + { + context.PlaylistGroups.Add(new PlaylistGroup { Id = 99, Name = "Channel Lineups", IsSystem = false }); + await context.SaveChangesAsync(); + } + + Either result = + await MakeHandler().Handle( + MakeRequest(lineup: [MovieItem(42), ShowItem(43)]), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldBe("Unable to create channel from lineup"); + + await using TvContext assertContext = _db.CreateContext(); + (await assertContext.Channels.CountAsync()).ShouldBe(0); + (await assertContext.Playlists.CountAsync()).ShouldBe(0); + (await assertContext.ProgramSchedules.CountAsync()).ShouldBe(0); + (await assertContext.Playouts.CountAsync()).ShouldBe(0); + _background.Reader.TryRead(out _).ShouldBeFalse(); + } + [Test] public async Task Should_Return_NotFound_When_Template_Is_Missing() { @@ -150,6 +537,38 @@ public class CreateChannelFromLineupHandlerTests error.Value.ShouldContain("Channel number must be unique"); } + [Test] + public async Task Should_Return_Validation_Error_When_Disabled_But_Shown_In_Epg() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + + Either result = + await MakeHandler().Handle( + MakeRequest(isEnabled: false, showInEpg: true), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("Disabled channels cannot be shown in EPG"); + } + + [Test] + public async Task Should_Return_Validation_Error_For_Invalid_External_Logo() + { + await SeedTemplateDependencies(); + await SeedTemplate(); + await SeedMovie(42); + + var logo = new ArtworkContentTypeModel("ftp://example.com/logo.png", string.Empty); + + Either result = + await MakeHandler().Handle(MakeRequest(logo: logo), CancellationToken.None); + + LeftOf(result).Value.ShouldContain("External logo url is invalid"); + } + [Test] public async Task Should_Return_NotFound_When_Lineup_Target_Is_Missing() { @@ -164,35 +583,21 @@ public class CreateChannelFromLineupHandlerTests error.Value.ShouldContain("Movie 42"); } - [Test] - public async Task Should_Roll_Back_When_Save_Fails() + private async Task AssertNotFound(CreateChannelFromLineupAdvancedOptions advanced, string expectedFragment) { - await SeedTemplateDependencies(); - await SeedTemplate(); - await SeedMovie(42); - await using (TvContext context = _db.CreateContext()) - { - context.ProgramSchedules.Add(new ProgramSchedule - { - Name = "12 Movies Schedule", - FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible - }); - await context.SaveChangesAsync(); - } - Either result = - await MakeHandler().Handle(MakeRequest(), CancellationToken.None); + await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None); - LeftOf(result).ShouldNotBeOfType(); - await using TvContext assertContext = _db.CreateContext(); - (await assertContext.Channels.CountAsync()).ShouldBe(0); - (await assertContext.Collections.CountAsync()).ShouldBe(0); - (await assertContext.Playouts.CountAsync()).ShouldBe(0); - _background.Reader.TryRead(out _).ShouldBeFalse(); + NotFoundError error = LeftOf(result).ShouldBeOfType(); + error.Value.ShouldContain(expectedFragment); } private CreateChannelFromLineupHandler MakeHandler() => - new(_background.Writer, _db.Factory, _searchTargets); + new( + _background.Writer, + _db.Factory, + _searchTargets, + NullLogger.Instance); private async Task SeedTemplateDependencies() { @@ -247,6 +652,47 @@ public class CreateChannelFromLineupHandlerTests await context.SaveChangesAsync(); } + private async Task SeedShow(int id) + { + await using TvContext context = _db.CreateContext(); + context.Shows.Add(new Show { Id = id }); + await context.SaveChangesAsync(); + } + + private async Task SeedCollection(int id) + { + await using TvContext context = _db.CreateContext(); + context.Collections.Add(new Collection { Id = id, Name = $"Collection {id}" }); + await context.SaveChangesAsync(); + } + + private async Task SeedMultiCollection(int id) + { + await using TvContext context = _db.CreateContext(); + context.MultiCollections.Add(new MultiCollection { Id = id, Name = $"Multi {id}" }); + await context.SaveChangesAsync(); + } + + private async Task SeedRerunCollection(int id) + { + await using TvContext context = _db.CreateContext(); + context.RerunCollections.Add(new RerunCollection + { + Id = id, + Name = $"Rerun {id}", + CollectionType = CollectionType.Collection + }); + await context.SaveChangesAsync(); + } + + private async Task SeedExistingPlaylist(int id) + { + await using TvContext context = _db.CreateContext(); + context.PlaylistGroups.Add(new PlaylistGroup { Id = 500, Name = "User Group", IsSystem = false }); + context.Playlists.Add(new Playlist { Id = id, Name = $"Playlist {id}", PlaylistGroupId = 500, IsSystem = false }); + await context.SaveChangesAsync(); + } + private static FillerPreset MakeFiller(int id, FillerKind kind) => new() { @@ -258,28 +704,44 @@ public class CreateChannelFromLineupHandlerTests CollectionType = CollectionType.Collection }; + private static CreateChannelFromLineupItem MovieItem(int id) => + new(LibraryBrowseMediaType.Movie, CollectionType.Movie, null, null, null, null, id, null); + + private static CreateChannelFromLineupItem ShowItem(int id) => + new(LibraryBrowseMediaType.TelevisionShow, CollectionType.TelevisionShow, null, null, null, null, id, null); + + private static CreateChannelFromLineupItem CollectionItem(int id) => + new(LibraryBrowseMediaType.Collection, CollectionType.Collection, id, null, null, null, null, null); + + private static CreateChannelFromLineupItem MultiCollectionItem(int id) => + new(LibraryBrowseMediaType.MultiCollection, CollectionType.MultiCollection, null, id, null, null, null, null); + + private static CreateChannelFromLineupItem RerunItem(int id) => + new(LibraryBrowseMediaType.RerunCollection, CollectionType.RerunFirstRun, null, null, null, id, null, null); + + private static CreateChannelFromLineupItem PlaylistEntry(int id) => + new(LibraryBrowseMediaType.Playlist, CollectionType.Playlist, null, null, null, null, null, id); + private static CreateChannelFromLineup MakeRequest( string number = "12", - int templateId = 10) => + string name = "Movies", + int templateId = 10, + bool isEnabled = true, + bool showInEpg = true, + ArtworkContentTypeModel logo = null, + CreateChannelFromLineupAdvancedOptions advanced = null, + List lineup = null) => new( - "Movies", + name, number, "Kids", string.Empty, - ArtworkContentTypeModel.None, - true, - true, + logo ?? ArtworkContentTypeModel.None, + isEnabled, + showInEpg, templateId, - new CreateChannelFromLineupAdvancedOptions(PlaybackOrder.Shuffle), - [new CreateChannelFromLineupItem( - LibraryBrowseMediaType.Movie, - CollectionType.Movie, - null, - null, - null, - null, - 42, - null)]); + advanced ?? new CreateChannelFromLineupAdvancedOptions(PlaybackOrder.Shuffle), + lineup ?? [MovieItem(42)]); private static BaseError LeftOf(Either either) => either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 786ec1a86..9082d7f3b 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -83,8 +83,15 @@ public class ChannelController(ChannelWriter workerCh [Tags("Channels")] [EndpointSummary("Create a channel from a library lineup")] [EndpointDescription( - "Atomically creates the channel, generated lineup collection, program schedule, schedule items, " + - "and classic playout. Template defaults are stamped at create time; advanced overrides win.")] + "Atomically creates the channel, program schedule, a classic playout, and (for multi-item lineups) a " + + "generated system playlist. A single-item lineup produces one flood schedule item that references the " + + "target directly (movie, show, season, artist, collection, smart/multi collection, rerun collection, or " + + "playlist) and no generated playlist. A lineup with two or more items produces one generated system " + + "playlist whose entries play in the given order (each entry played in full before the next) referenced by " + + "one flood schedule item; only movies, shows, seasons, artists, collections, smart collections and multi " + + "collections are allowed there (rerun collections and playlists are single-item only). playbackOrder sets " + + "how items within each lineup entry are ordered. Template defaults are stamped at create time; advanced " + + "overrides win.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(CreateChannelFromLineupResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] diff --git a/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs index e513912e6..60df675b4 100644 --- a/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs @@ -8,6 +8,12 @@ using ErsatzTV.Core.Scheduling; namespace ErsatzTV.Controllers.Api.Requests; +/// +/// Composite request to create a channel, its generated schedule/playlist and a classic playout in one call. +/// Template defaults are stamped at create time; any value set in overrides the template. +/// A single-item lineup references its target directly; a multi-item lineup is played in order via a generated +/// system playlist. +/// public record CreateChannelFromLineupRequest( string Name, string Number, diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index d4c3ee0c3..f9d01c35d 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -537,7 +537,7 @@ "Channels" ], "summary": "Create a channel from a library lineup", - "description": "Atomically creates the channel, generated lineup collection, program schedule, schedule items, and classic playout. Template defaults are stamped at create time; advanced overrides win.", + "description": "Atomically creates the channel, program schedule, a classic playout, and (for multi-item lineups) a generated system playlist. A single-item lineup produces one flood schedule item that references the target directly (movie, show, season, artist, collection, smart/multi collection, rerun collection, or playlist) and no generated playlist. A lineup with two or more items produces one generated system playlist whose entries play in the given order (each entry played in full before the next) referenced by one flood schedule item; only movies, shows, seasons, artists, collections, smart collections and multi collections are allowed there (rerun collections and playlists are single-item only). playbackOrder sets how items within each lineup entry are ordered. Template defaults are stamped at create time; advanced overrides win.", "operationId": "CreateChannelFromLineup", "requestBody": { "content": { @@ -5607,7 +5607,7 @@ "CreateChannelFromLineupResponseModel": { "required": [ "channelId", - "collectionId", + "playlistId", "programScheduleId", "playoutId" ], @@ -5617,8 +5617,11 @@ "type": "integer", "format": "int32" }, - "collectionId": { - "type": "integer", + "playlistId": { + "type": [ + "null", + "integer" + ], "format": "int32" }, "programScheduleId": { diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 5feccd51b..d72be9ca4 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -209,7 +209,7 @@ export interface components { }; "CreateChannelFromLineupResponseModel": { "channelId": number; - "collectionId": number; + "playlistId": null | number; "programScheduleId": number; "playoutId": number; };