Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
1032 lines
41 KiB
C#
1032 lines
41 KiB
C#
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<IBackgroundServiceRequest> _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<IBackgroundServiceRequest>();
|
|
_db = await InMemoryTvContext.CreateAsync();
|
|
_searchTargets = Substitute.For<ISearchTargets>();
|
|
_remoteLogoCacher = Substitute.For<IRemoteLogoCacher>();
|
|
}
|
|
|
|
[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<BaseError, CreateChannelFromLineupResponseModel> 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<ProgramScheduleItemFlood>();
|
|
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>();
|
|
buildPlayout.PlayoutId.ShouldBe(playout.Id);
|
|
buildPlayout.Mode.ShouldBe(PlayoutBuildMode.Reset);
|
|
|
|
_background.Reader.TryRead(out IBackgroundServiceRequest? refreshRequest).ShouldBeTrue();
|
|
refreshRequest.ShouldBeOfType<RefreshChannelList>();
|
|
_searchTargets.Received(1).SearchTargetsChanged();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Should_Create_Single_Collection_Item_Directly()
|
|
{
|
|
await SeedTemplateDependencies();
|
|
await SeedTemplate();
|
|
await SeedCollection(7);
|
|
|
|
Either<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> 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<DomainPlaylistItem> 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<ProgramScheduleItemFlood>();
|
|
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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(
|
|
MakeRequest(lineup: [MovieItem(42), RerunItem(11)]),
|
|
CancellationToken.None);
|
|
|
|
BaseError error = LeftOf(result);
|
|
error.ShouldNotBeOfType<NotFoundError>();
|
|
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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(
|
|
MakeRequest(lineup: [MovieItem(42), PlaylistEntry(9)]),
|
|
CancellationToken.None);
|
|
|
|
BaseError error = LeftOf(result);
|
|
error.ShouldNotBeOfType<NotFoundError>();
|
|
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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
|
|
|
|
BaseError error = LeftOf(result);
|
|
error.ShouldNotBeOfType<NotFoundError>();
|
|
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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(MakeRequest(lineup: [item]), CancellationToken.None);
|
|
|
|
BaseError error = LeftOf(result);
|
|
error.ShouldNotBeOfType<NotFoundError>();
|
|
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<BaseError, CreateChannelFromLineupResponseModel> 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, found by adversarial review of 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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
|
|
|
|
CreateChannelFromLineupResponseModel response = RightOf(result);
|
|
|
|
_background.Reader.TryRead(out IBackgroundServiceRequest? first).ShouldBeTrue();
|
|
first.ShouldBeOfType<BuildPlayout>();
|
|
|
|
_background.Reader.TryRead(out IBackgroundServiceRequest? second).ShouldBeTrue();
|
|
TimeShiftOnDemandPlayout timeShift = second.ShouldBeOfType<TimeShiftOnDemandPlayout>();
|
|
timeShift.PlayoutId.ShouldBe(response.PlayoutId);
|
|
timeShift.Force.ShouldBeFalse();
|
|
|
|
_background.Reader.TryRead(out IBackgroundServiceRequest? third).ShouldBeTrue();
|
|
third.ShouldBeOfType<RefreshChannelList>();
|
|
}
|
|
|
|
[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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(
|
|
MakeRequest(lineup: [MovieItem(42), ShowItem(43)]),
|
|
CancellationToken.None);
|
|
|
|
BaseError error = LeftOf(result);
|
|
error.ShouldNotBeOfType<NotFoundError>();
|
|
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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(MakeRequest(templateId: 999), CancellationToken.None);
|
|
|
|
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
}
|
|
|
|
[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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(MakeRequest(number: " 12 "), CancellationToken.None);
|
|
|
|
BaseError error = LeftOf(result);
|
|
error.ShouldNotBeOfType<NotFoundError>();
|
|
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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(
|
|
MakeRequest(isEnabled: false, showInEpg: true),
|
|
CancellationToken.None);
|
|
|
|
BaseError error = LeftOf(result);
|
|
error.ShouldNotBeOfType<NotFoundError>();
|
|
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<BaseError, CreateChannelFromLineupResponseModel> 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<Uri>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, string>("cachedhash"));
|
|
|
|
var logo = new ArtworkContentTypeModel("https://example.com/logo.png", string.Empty);
|
|
|
|
Either<BaseError, CreateChannelFromLineupResponseModel> 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<Uri>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, string>(BaseError.New("Could not download logo: host timed out")));
|
|
|
|
var logo = new ArtworkContentTypeModel("https://example.com/logo.png", string.Empty);
|
|
|
|
Either<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(MakeRequest(logo: logo), CancellationToken.None);
|
|
|
|
result.IsRight.ShouldBeTrue();
|
|
await _remoteLogoCacher.DidNotReceive().CacheFromUrl(Arg.Any<Uri>(), Arg.Any<CancellationToken>());
|
|
|
|
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<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
|
|
|
|
NotFoundError error = LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
error.Value.ShouldContain("lineup[0]");
|
|
error.Value.ShouldContain("Movie 42");
|
|
}
|
|
|
|
private async Task AssertNotFound(CreateChannelFromLineupAdvancedOptions advanced, string expectedFragment)
|
|
{
|
|
Either<BaseError, CreateChannelFromLineupResponseModel> result =
|
|
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
|
|
|
|
NotFoundError error = LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
error.Value.ShouldContain(expectedFragment);
|
|
}
|
|
|
|
private CreateChannelFromLineupHandler MakeHandler() =>
|
|
new(
|
|
_background.Writer,
|
|
_db.Factory,
|
|
_searchTargets,
|
|
_remoteLogoCacher,
|
|
NullLogger<CreateChannelFromLineupHandler>.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<CreateChannelFromLineupItem> 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<TR>(Either<BaseError, TR> either) =>
|
|
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
|
|
|
private static TR RightOf<TR>(Either<BaseError, TR> 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<BaseError, CreateChannelFromLineupResponseModel> 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<BaseError, CreateChannelFromLineupResponseModel> 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<int> 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;
|
|
}
|
|
}
|