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;