Files
ersatztv/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs
T
timothyandtimothy ba6a4b08aa
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
feat(732): On Now / Next gets a background box, and is on by default (#843)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 19:28:25 +00:00

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;
}
}