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.Graphics; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Scheduling; using ErsatzTV.FFmpeg.State; 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 static LanguageExt.Prelude; using DomainChannel = ErsatzTV.Core.Domain.Channel; using DomainPlaylistItem = ErsatzTV.Core.Domain.PlaylistItem; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class CreateChannelFromLineupHandlerTests { private Channel _background = null!; private InMemoryTvContext _db = null!; private ISearchTargets _searchTargets = null!; private IRemoteLogoCacher _remoteLogoCacher = null!; [SetUp] public async Task SetUp() { _background = System.Threading.Channels.Channel.CreateUnbounded(); _db = await InMemoryTvContext.CreateAsync(); _searchTargets = Substitute.For(); _remoteLogoCacher = Substitute.For(); } [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task Should_Create_Single_Media_Item_Directly_Without_Playlist() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); Either result = await MakeHandler().Handle(MakeRequest(), CancellationToken.None); CreateChannelFromLineupResponseModel response = RightOf(result); response.ChannelId.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"); channel.Group.ShouldBe("Kids"); channel.FFmpegProfileId.ShouldBe(1); channel.FallbackFillerId.ShouldBe(5); channel.StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingSegmenter); channel.ShowInEpg.ShouldBeTrue(); channel.Origin.ShouldBe(ChannelOrigin.AutoTuned); 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.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); 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_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 Clear_Should_Force_Template_Inherited_Values_To_None() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); // Give the template a watermark + preferred audio language so "clear" has something to drop // (the base template has fillers 2-5 but no watermark / audio language). await using (TvContext context = _db.CreateContext()) { context.ChannelWatermarks.Add(new ChannelWatermark { Id = 21, Name = "wm", Image = "wm.png" }); ChannelTemplate template = await context.ChannelTemplates.SingleAsync(); template.WatermarkId = 21; template.PreferredAudioLanguageCode = "eng"; await context.SaveChangesAsync(); } var advanced = new CreateChannelFromLineupAdvancedOptions( PlaybackOrder: PlaybackOrder.Shuffle, Clear: [ CreateChannelFromLineupClearField.Watermark, CreateChannelFromLineupClearField.FallbackFiller, CreateChannelFromLineupClearField.PreRollFiller, CreateChannelFromLineupClearField.MidRollFiller, CreateChannelFromLineupClearField.PostRollFiller, CreateChannelFromLineupClearField.PreferredAudioLanguage ]); Either result = await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None); RightOf(result); await using TvContext assert = _db.CreateContext(); DomainChannel channel = await assert.Channels.SingleAsync(); // Cleared -> none, even though the template supplies a value. channel.WatermarkId.ShouldBeNull(); channel.FallbackFillerId.ShouldBeNull(); channel.PreferredAudioLanguageCode.ShouldBe(string.Empty); ProgramScheduleItem item = await assert.ProgramScheduleItems.SingleAsync(); item.PreRollFillerId.ShouldBeNull(); item.MidRollFillerId.ShouldBeNull(); item.PostRollFillerId.ShouldBeNull(); item.FallbackFillerId.ShouldBeNull(); item.PreferredAudioLanguageCode.ShouldBe(string.Empty); } [Test] public async Task Clear_And_Set_Same_Field_Should_Be_Validation_Error() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); await using (TvContext context = _db.CreateContext()) { context.FillerPresets.Add(MakeFiller(6, FillerKind.Fallback)); await context.SaveChangesAsync(); } var advanced = new CreateChannelFromLineupAdvancedOptions( PlaybackOrder: PlaybackOrder.Shuffle, FallbackFillerId: 6, Clear: [CreateChannelFromLineupClearField.FallbackFiller]); Either result = await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None); BaseError error = LeftOf(result); error.ShouldNotBeOfType(); error.Value.ShouldContain("cannot be both set and cleared"); } [Test] public async Task Clear_And_Set_String_Field_Conflicts_But_Empty_String_Is_Redundant() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); // A real value + clear on the same string field is contradictory. var conflicting = new CreateChannelFromLineupAdvancedOptions( PlaybackOrder: PlaybackOrder.Shuffle, PreferredAudioLanguageCode: "eng", Clear: [CreateChannelFromLineupClearField.PreferredAudioLanguage]); BaseError error = LeftOf( await MakeHandler().Handle(MakeRequest(advanced: conflicting), CancellationToken.None)); error.Value.ShouldContain("cannot be both set and cleared"); // An empty string + clear is redundant, not conflicting -> the create still succeeds. (This // runs second: the conflicting request above returned Left without persisting, so number "12" // is still free.) var redundant = new CreateChannelFromLineupAdvancedOptions( PlaybackOrder: PlaybackOrder.Shuffle, PreferredAudioLanguageCode: "", Clear: [CreateChannelFromLineupClearField.PreferredAudioLanguage]); RightOf(await MakeHandler().Handle(MakeRequest(advanced: redundant), CancellationToken.None)); } [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_WeightedShuffle_For_A_Multi_Item_Lineup() { // regression (#70, PR #402): a 2+ entry lineup is persisted as a // Playlist, and PlaylistEnumerator has no default arm -- an order it doesn't know leaves the // enumerator null and the items vanish from the playlist with nothing reported. This handler is the // THIRD writer of PlaylistItem.PlaybackOrder and was missed when the other two were gated. await SeedTemplateDependencies(); await SeedTemplate(); await SeedCollection(21); await SeedCollection(22); var advanced = new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: PlaybackOrder.WeightedShuffle); Either result = await MakeHandler().Handle( MakeRequest(advanced: advanced, lineup: [CollectionItem(21), CollectionItem(22)]), CancellationToken.None); LeftOf(result).Value.ShouldContain("not supported for a multi-item lineup"); } [Test] public async Task Should_Allow_WeightedShuffle_For_A_MultiCollection_Lineup() { // the mirror of PlayoutModeMustBeValid: a multi collection is WeightedShuffle's intended home, and a // single-entry lineup goes to a flood item on the Classic engine, which handles the order await SeedTemplateDependencies(); await SeedTemplate(); await SeedMultiCollection(16); var advanced = new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: PlaybackOrder.WeightedShuffle); Either result = await MakeHandler().Handle( MakeRequest(advanced: advanced, lineup: [MultiCollectionItem(16)]), CancellationToken.None); result.IsRight.ShouldBeTrue(); } [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() { 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_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_Download_External_Url_Logo_And_Store_Cache_Name() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); _remoteLogoCacher.CacheFromUrl(Arg.Any(), Arg.Any()) .Returns(Right("cachedhash")); var logo = new ArtworkContentTypeModel("https://example.com/logo.png", string.Empty); Either result = await MakeHandler().Handle(MakeRequest(logo: logo), CancellationToken.None); result.IsRight.ShouldBeTrue(); await using TvContext context = _db.CreateContext(); Artwork artwork = (await context.Channels.Include(c => c.Artwork).SingleAsync()) .Artwork.Single(a => a.ArtworkKind == ArtworkKind.Logo); artwork.Path.ShouldBe("cachedhash"); artwork.IsExternalUrl().ShouldBeFalse(); } [Test] public async Task Should_Fail_The_Create_When_The_Logo_Download_Fails() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); _remoteLogoCacher.CacheFromUrl(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("Could not download logo: host timed out"))); var logo = new ArtworkContentTypeModel("https://example.com/logo.png", string.Empty); Either result = await MakeHandler().Handle(MakeRequest(logo: logo), CancellationToken.None); LeftOf(result).Value.ShouldContain("Could not download logo"); // nothing is persisted when the download fails (resolution runs before PersistAndDispatch) await using TvContext context = _db.CreateContext(); (await context.Channels.CountAsync()).ShouldBe(0); (await context.Playouts.CountAsync()).ShouldBe(0); } [Test] public async Task Should_Not_Call_The_Cacher_For_An_Uploaded_Logo_Path() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); var logo = new ArtworkContentTypeModel("iptv/logos/deadbeef", string.Empty); Either result = await MakeHandler().Handle(MakeRequest(logo: logo), CancellationToken.None); result.IsRight.ShouldBeTrue(); await _remoteLogoCacher.DidNotReceive().CacheFromUrl(Arg.Any(), Arg.Any()); await using TvContext context = _db.CreateContext(); Artwork artwork = (await context.Channels.Include(c => c.Artwork).SingleAsync()) .Artwork.Single(a => a.ArtworkKind == ArtworkKind.Logo); artwork.Path.ShouldBe("deadbeef"); } [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"); } private async Task AssertNotFound(CreateChannelFromLineupAdvancedOptions advanced, string expectedFragment) { Either result = await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None); NotFoundError error = LeftOf(result).ShouldBeOfType(); error.Value.ShouldContain(expectedFragment); } private CreateChannelFromLineupHandler MakeHandler() => new( _background.Writer, _db.Factory, _searchTargets, _remoteLogoCacher, NullLogger.Instance); 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 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() { Id = id, Name = $"filler-{id}", FillerKind = kind, FillerMode = FillerMode.Count, Count = 1, 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", string name = "Movies", int templateId = 10, bool isEnabled = true, bool showInEpg = true, ArtworkContentTypeModel logo = null, CreateChannelFromLineupAdvancedOptions advanced = null, List lineup = null) => new( name, number, "Kids", string.Empty, logo ?? ArtworkContentTypeModel.None, isEnabled, showInEpg, templateId, 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")); private static TR RightOf(Either either) => either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e.Value}"), Right: r => r); // #732: this is the SPA's primary "Add Channel" flow and the one Auto-Tune bulk-creates through. // It was the channel-creation site the default attach originally missed, so a channel made here // would silently never get the overlay once the one-time backfill marker had landed. [Test] public async Task Should_Attach_The_Built_In_On_Now_Next_Element() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); int elementId = await SeedBuiltInGraphicsElement(); Either result = await MakeHandler().Handle(MakeRequest(), CancellationToken.None); CreateChannelFromLineupResponseModel response = RightOf(result); await using TvContext context = _db.CreateContext(); DomainChannel channel = await context.Channels .Include(c => c.ChannelGraphicsElements) .SingleAsync(c => c.Id == response.ChannelId); channel.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe([elementId]); } [Test] public async Task Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel() { await SeedTemplateDependencies(); await SeedTemplate(); await SeedMovie(42); await SeedBuiltInGraphicsElement(); Either result = await MakeHandler().Handle( MakeRequest(advanced: new CreateChannelFromLineupAdvancedOptions( PlaybackOrder.Shuffle, StreamingMode: StreamingMode.HttpLiveStreamingDirect)), CancellationToken.None); CreateChannelFromLineupResponseModel response = RightOf(result); await using TvContext context = _db.CreateContext(); DomainChannel channel = await context.Channels .Include(c => c.ChannelGraphicsElements) .SingleAsync(c => c.Id == response.ChannelId); channel.ChannelGraphicsElements.ShouldBeEmpty(); } private async Task SeedBuiltInGraphicsElement() { await using TvContext context = _db.CreateContext(); var element = new GraphicsElement { Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}", Kind = GraphicsElementKind.Text }; context.GraphicsElements.Add(element); await context.SaveChangesAsync(); return element.Id; } }