feat(api): composite create-channel endpoint (#63)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

This commit is contained in:
2026-07-06 20:27:02 +02:00
parent 16674ba80e
commit 6857a0d191
11 changed files with 1594 additions and 0 deletions
@@ -0,0 +1,56 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Channels;
public record CreateChannelFromLineup(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptions Advanced,
List<CreateChannelFromLineupItem> Lineup) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>>;
public record CreateChannelFromLineupAdvancedOptions(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string StreamSelector = null,
string PreferredAudioLanguageCode = null,
string PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
@@ -0,0 +1,535 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class CreateChannelFromLineupHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets)
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
{
public async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> Handle(
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
PreparedCreate prepared;
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
foreach (BaseError error in validation.LeftToSeq())
{
return error;
}
prepared = validation.IfLeft(() => throw new InvalidOperationException("Validation failed without error"));
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
dbContext.Channels.Add(prepared.Channel);
dbContext.Collections.Add(prepared.Collection);
dbContext.ProgramSchedules.Add(prepared.ProgramSchedule);
dbContext.Playouts.Add(prepared.Playout);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(cancellationToken);
return BaseError.New($"Unable to create channel from lineup: {ex.GetBaseException().Message}");
}
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset), cancellationToken);
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreateChannelFromLineupResponseModel(
prepared.Channel.Id,
prepared.Collection.Id,
prepared.ProgramSchedule.Id,
prepared.Playout.Id);
}
private static async Task<Either<BaseError, PreparedCreate>> Validate(
TvContext dbContext,
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
string name = (request.Name ?? string.Empty).Trim();
string number = (request.Number ?? string.Empty).Trim();
string group = (request.Group ?? string.Empty).Trim();
string categories = (request.Categories ?? string.Empty).Trim();
CreateChannelFromLineupAdvancedOptions advanced = request.Advanced ?? new CreateChannelFromLineupAdvancedOptions();
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Channel name is required");
}
if (name.Length > 50)
{
return BaseError.New("Channel name must be 50 characters or fewer");
}
if (string.IsNullOrWhiteSpace(group))
{
return BaseError.New("Channel group is required");
}
if (!Regex.IsMatch(number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
if (await dbContext.Channels.AnyAsync(c => c.Number == number, cancellationToken))
{
return BaseError.New("Channel number must be unique");
}
if (!request.IsEnabled && request.ShowInEpg)
{
return BaseError.New("Disabled channels cannot be shown in EPG");
}
if (!string.IsNullOrWhiteSpace(request.Logo?.Path) &&
Uri.TryCreate(request.Logo.Path, UriKind.Absolute, out _) &&
!Artwork.IsExternalUrl(request.Logo.Path))
{
return BaseError.New("External logo url is invalid");
}
if (request.Lineup is null || request.Lineup.Count == 0)
{
return BaseError.New("Lineup must contain at least one item");
}
ChannelTemplate template = await dbContext.ChannelTemplates
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TemplateId, cancellationToken);
if (template is null)
{
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
template,
advanced,
request.Lineup,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
return error;
}
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
Channel channel = BuildChannel(
request,
template,
advanced,
name,
number,
group,
categories,
ffmpegProfileId,
fallbackFillerId);
Collection collection = BuildCollection(number, name, request.Lineup);
ProgramSchedule schedule = BuildProgramSchedule(number, name, template, advanced);
schedule.Items = BuildScheduleItems(
collection,
request.Lineup,
playbackOrder,
template,
advanced,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
postRollFillerId);
var playout = new Playout
{
Channel = channel,
ProgramSchedule = schedule,
ScheduleKind = PlayoutScheduleKind.Classic
};
return new PreparedCreate(channel, collection, schedule, playout);
}
private static Channel BuildChannel(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
string name,
string number,
string group,
string categories,
int ffmpegProfileId,
int? fallbackFillerId)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
artwork.Add(new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
return new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = group,
Categories = categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = null,
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
advanced.MusicVideoCreditsTemplate ?? template.MusicVideoCreditsTemplate ?? string.Empty,
SongVideoMode = advanced.SongVideoMode ?? template.SongVideoMode,
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
}
private static Collection BuildCollection(string channelNumber, string channelName, List<CreateChannelFromLineupItem> lineup)
{
var collection = new Collection
{
Name = GeneratedName(channelNumber, channelName, "Lineup"),
UseCustomPlaybackOrder = true,
CollectionItems = []
};
int index = 1;
foreach (CreateChannelFromLineupItem item in lineup.Where(IsMediaLineupItem))
{
collection.CollectionItems.Add(new CollectionItem
{
Collection = collection,
MediaItemId = item.MediaItemId.GetValueOrDefault(),
CustomIndex = index++
});
}
return collection;
}
private static ProgramSchedule BuildProgramSchedule(
string channelNumber,
string channelName,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced) =>
new()
{
Name = GeneratedName(channelNumber, channelName, "Schedule"),
KeepMultiPartEpisodesTogether = true,
TreatCollectionsAsShows = true,
ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems,
RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint,
FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior,
Items = []
};
private static string GeneratedName(string channelNumber, string channelName, string suffix)
{
string prefix = $"{channelNumber} {channelName}".Trim();
int maxPrefixLength = Math.Max(0, 50 - suffix.Length - 1);
if (prefix.Length > maxPrefixLength)
{
prefix = prefix[..maxPrefixLength].TrimEnd();
}
return $"{prefix} {suffix}".Trim();
}
private static List<ProgramScheduleItem> BuildScheduleItems(
Collection collection,
List<CreateChannelFromLineupItem> lineup,
PlaybackOrder playbackOrder,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId)
{
var result = new List<ProgramScheduleItem>();
if (collection.CollectionItems.Count != 0)
{
result.Add(new ProgramScheduleItemFlood
{
Index = result.Count + 1,
Collection = collection,
CollectionType = CollectionType.Collection,
PlaybackOrder = playbackOrder,
GuideMode = GuideMode.Normal,
CustomTitle = string.Empty,
SearchTitle = string.Empty,
SearchQuery = string.Empty,
PreRollFillerId = preRollFillerId,
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
});
}
foreach (CreateChannelFromLineupItem item in lineup.Where(i => !IsMediaLineupItem(i)))
{
result.Add(new ProgramScheduleItemFlood
{
Index = result.Count + 1,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
RerunCollectionId = item.RerunCollectionId,
PlaylistId = item.PlaylistId,
PlaybackOrder = playbackOrder,
GuideMode = GuideMode.Normal,
CustomTitle = string.Empty,
SearchTitle = string.Empty,
SearchQuery = string.Empty,
PreRollFillerId = preRollFillerId,
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
});
}
return result;
}
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
List<CreateChannelFromLineupItem> lineup,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
return error;
}
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
return error;
}
for (int i = 0; i < lineup.Count; i++)
{
Either<BaseError, Unit> itemValidation = await ValidateLineupItem(dbContext, lineup[i], i, cancellationToken);
foreach (BaseError error in itemValidation.LeftToSeq())
{
return error;
}
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
int? fallbackFillerId,
CancellationToken cancellationToken)
{
if (watermarkId.HasValue &&
!await dbContext.ChannelWatermarks.AnyAsync(w => w.Id == watermarkId.Value, cancellationToken))
{
return new NotFoundError($"Watermark {watermarkId.Value} does not exist.");
}
if (fallbackFillerId.HasValue && !await dbContext.FillerPresets.AnyAsync(
fp => fp.Id == fallbackFillerId.Value && fp.FillerKind == FillerKind.Fallback,
cancellationToken))
{
return new NotFoundError($"Fallback filler {fallbackFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateItemFillers(
TvContext dbContext,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId,
CancellationToken cancellationToken)
{
if (preRollFillerId.HasValue && !await FillerExists(dbContext, preRollFillerId.Value, FillerKind.PreRoll, cancellationToken))
{
return new NotFoundError($"Pre-roll filler {preRollFillerId.Value} does not exist.");
}
if (midRollFillerId.HasValue && !await FillerExists(dbContext, midRollFillerId.Value, FillerKind.MidRoll, cancellationToken))
{
return new NotFoundError($"Mid-roll filler {midRollFillerId.Value} does not exist.");
}
if (postRollFillerId.HasValue && !await FillerExists(dbContext, postRollFillerId.Value, FillerKind.PostRoll, cancellationToken))
{
return new NotFoundError($"Post-roll filler {postRollFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static Task<bool> FillerExists(
TvContext dbContext,
int id,
FillerKind fillerKind,
CancellationToken cancellationToken) =>
dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken);
private static async Task<Either<BaseError, Unit>> ValidateLineupItem(
TvContext dbContext,
CreateChannelFromLineupItem item,
int index,
CancellationToken cancellationToken)
{
if (item.MediaType is LibraryBrowseMediaType.RerunCollection)
{
item = item with { CollectionType = CollectionType.RerunFirstRun };
}
int providedIds = new int?[]
{
item.CollectionId,
item.MultiCollectionId,
item.SmartCollectionId,
item.RerunCollectionId,
item.MediaItemId,
item.PlaylistId
}.Count(id => id.HasValue);
if (providedIds != 1)
{
return BaseError.New($"lineup[{index}] must provide exactly one typed id.");
}
return item.CollectionType switch
{
CollectionType.Movie when item.MediaType is LibraryBrowseMediaType.Movie && item.MediaItemId.HasValue =>
await Exists(dbContext.Movies, item.MediaItemId.Value, $"lineup[{index}] Movie", cancellationToken),
CollectionType.TelevisionShow when item.MediaType is LibraryBrowseMediaType.TelevisionShow && item.MediaItemId.HasValue =>
await Exists(dbContext.Shows, item.MediaItemId.Value, $"lineup[{index}] TelevisionShow", cancellationToken),
CollectionType.TelevisionSeason when item.MediaType is LibraryBrowseMediaType.TelevisionSeason && item.MediaItemId.HasValue =>
await Exists(dbContext.Seasons, item.MediaItemId.Value, $"lineup[{index}] TelevisionSeason", cancellationToken),
CollectionType.Artist when item.MediaType is LibraryBrowseMediaType.Artist && item.MediaItemId.HasValue =>
await Exists(dbContext.Artists, item.MediaItemId.Value, $"lineup[{index}] Artist", cancellationToken),
CollectionType.Collection when item.MediaType is LibraryBrowseMediaType.Collection && item.CollectionId.HasValue =>
await Exists(dbContext.Collections, item.CollectionId.Value, $"lineup[{index}] Collection", cancellationToken),
CollectionType.SmartCollection when item.MediaType is LibraryBrowseMediaType.SmartCollection && item.SmartCollectionId.HasValue =>
await Exists(dbContext.SmartCollections, item.SmartCollectionId.Value, $"lineup[{index}] SmartCollection", cancellationToken),
CollectionType.MultiCollection when item.MediaType is LibraryBrowseMediaType.MultiCollection && item.MultiCollectionId.HasValue =>
await Exists(dbContext.MultiCollections, item.MultiCollectionId.Value, $"lineup[{index}] MultiCollection", cancellationToken),
CollectionType.RerunFirstRun when item.MediaType is LibraryBrowseMediaType.RerunCollection && item.RerunCollectionId.HasValue =>
await Exists(dbContext.RerunCollections, item.RerunCollectionId.Value, $"lineup[{index}] RerunCollection", cancellationToken),
CollectionType.Playlist when item.MediaType is LibraryBrowseMediaType.Playlist && item.PlaylistId.HasValue =>
await Exists(dbContext.Playlists, item.PlaylistId.Value, $"lineup[{index}] Playlist", cancellationToken),
_ => BaseError.New($"lineup[{index}] has an unsupported or mismatched media type, collection type, and id.")
};
}
private static async Task<Either<BaseError, Unit>> Exists<TEntity>(
DbSet<TEntity> set,
int id,
string label,
CancellationToken cancellationToken)
where TEntity : class
{
TEntity entity = await set.FindAsync([id], cancellationToken);
return entity is null ? new NotFoundError($"{label} {id} does not exist.") : Unit.Default;
}
private static bool IsMediaLineupItem(CreateChannelFromLineupItem item) =>
item.CollectionType is CollectionType.Movie or CollectionType.TelevisionShow or CollectionType.TelevisionSeason
or CollectionType.Artist;
private sealed record PreparedCreate(
Channel Channel,
Collection Collection,
ProgramSchedule ProgramSchedule,
Playout Playout);
}
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
public record CreateChannelFromLineupResponseModel(
int ChannelId,
int CollectionId,
int ProgramScheduleId,
int PlayoutId);
@@ -0,0 +1,289 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class CreateChannelFromLineupHandlerTests
{
private Channel<IBackgroundServiceRequest> _background = null!;
private InMemoryTvContext _db = null!;
private ISearchTargets _searchTargets = null!;
[SetUp]
public async Task SetUp()
{
_background = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_db = await InMemoryTvContext.CreateAsync();
_searchTargets = Substitute.For<ISearchTargets>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Should_Create_Channel_Collection_Schedule_Items_And_Playout()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
response.ChannelId.ShouldBeGreaterThan(0);
response.CollectionId.ShouldBeGreaterThan(0);
response.ProgramScheduleId.ShouldBeGreaterThan(0);
response.PlayoutId.ShouldBeGreaterThan(0);
await using TvContext context = _db.CreateContext();
DomainChannel channel = await context.Channels.SingleAsync();
channel.Name.ShouldBe("Movies");
channel.Number.ShouldBe("12");
channel.Group.ShouldBe("Kids");
channel.FFmpegProfileId.ShouldBe(1);
channel.FallbackFillerId.ShouldBe(5);
channel.StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingSegmenter);
channel.ShowInEpg.ShouldBeTrue();
Collection collection = await context.Collections
.Include(c => c.CollectionItems)
.SingleAsync();
collection.Name.ShouldBe("12 Movies Lineup");
collection.UseCustomPlaybackOrder.ShouldBeTrue();
collection.CollectionItems.Single().MediaItemId.ShouldBe(42);
collection.CollectionItems.Single().CustomIndex.ShouldBe(1);
ProgramSchedule schedule = await context.ProgramSchedules
.Include(ps => ps.Items)
.SingleAsync();
schedule.Name.ShouldBe("12 Movies Schedule");
schedule.ShuffleScheduleItems.ShouldBeTrue();
schedule.RandomStartPoint.ShouldBeTrue();
schedule.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Strict);
ProgramScheduleItem item = schedule.Items.Single();
item.CollectionType.ShouldBe(CollectionType.Collection);
item.CollectionId.ShouldBe(collection.Id);
item.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle);
item.PreRollFillerId.ShouldBe(2);
item.MidRollFillerId.ShouldBe(3);
item.PostRollFillerId.ShouldBe(4);
item.FallbackFillerId.ShouldBe(5);
Playout playout = await context.Playouts.SingleAsync();
playout.ChannelId.ShouldBe(channel.Id);
playout.ProgramScheduleId.ShouldBe(schedule.Id);
playout.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
_background.Reader.TryRead(out IBackgroundServiceRequest? buildRequest).ShouldBeTrue();
BuildPlayout buildPlayout = buildRequest.ShouldBeOfType<BuildPlayout>();
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_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_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");
}
[Test]
public async Task Should_Roll_Back_When_Save_Fails()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await using (TvContext context = _db.CreateContext())
{
context.ProgramSchedules.Add(new ProgramSchedule
{
Name = "12 Movies Schedule",
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible
});
await context.SaveChangesAsync();
}
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
LeftOf(result).ShouldNotBeOfType<NotFoundError>();
await using TvContext assertContext = _db.CreateContext();
(await assertContext.Channels.CountAsync()).ShouldBe(0);
(await assertContext.Collections.CountAsync()).ShouldBe(0);
(await assertContext.Playouts.CountAsync()).ShouldBe(0);
_background.Reader.TryRead(out _).ShouldBeFalse();
}
private CreateChannelFromLineupHandler MakeHandler() =>
new(_background.Writer, _db.Factory, _searchTargets);
private async Task SeedTemplateDependencies()
{
await using TvContext context = _db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile { Id = 1, Name = "profile" });
context.FillerPresets.AddRange(
MakeFiller(2, FillerKind.PreRoll),
MakeFiller(3, FillerKind.MidRoll),
MakeFiller(4, FillerKind.PostRoll),
MakeFiller(5, FillerKind.Fallback));
await context.SaveChangesAsync();
}
private async Task SeedTemplate()
{
await using TvContext context = _db.CreateContext();
context.ChannelTemplates.Add(new ChannelTemplate
{
Id = 10,
Name = "Template",
Description = string.Empty,
FFmpegProfileId = 1,
FallbackFillerId = 5,
PreRollFillerId = 2,
MidRollFillerId = 3,
PostRollFillerId = 4,
StreamSelectorMode = ChannelStreamSelectorMode.Default,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
PreferredSubtitleLanguageCode = string.Empty,
SubtitleMode = ChannelSubtitleMode.None,
MusicVideoCreditsMode = ChannelMusicVideoCreditsMode.None,
MusicVideoCreditsTemplate = string.Empty,
SongVideoMode = ChannelSongVideoMode.Default,
TranscodeMode = ChannelTranscodeMode.OnDemand,
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
ShuffleScheduleItems = true,
RandomStartPoint = true,
FixedStartTimeBehavior = FixedStartTimeBehavior.Strict
});
await context.SaveChangesAsync();
}
private async Task SeedMovie(int id)
{
await using TvContext context = _db.CreateContext();
context.Movies.Add(new Movie { Id = id });
await context.SaveChangesAsync();
}
private static FillerPreset MakeFiller(int id, FillerKind kind) =>
new()
{
Id = id,
Name = $"filler-{id}",
FillerKind = kind,
FillerMode = FillerMode.Count,
Count = 1,
CollectionType = CollectionType.Collection
};
private static CreateChannelFromLineup MakeRequest(
string number = "12",
int templateId = 10) =>
new(
"Movies",
number,
"Kids",
string.Empty,
ArtworkContentTypeModel.None,
true,
true,
templateId,
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder.Shuffle),
[new CreateChannelFromLineupItem(
LibraryBrowseMediaType.Movie,
CollectionType.Movie,
null,
null,
null,
null,
42,
null)]);
private static BaseError LeftOf<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);
}
@@ -13,6 +13,8 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
@@ -7,6 +7,7 @@ using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
@@ -98,6 +99,54 @@ public class ChannelControllerTests
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task CreateFromLineup_Should_Return_201_With_Location_And_Body()
{
var response = new CreateChannelFromLineupResponseModel(5, 6, 7, 8);
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelFromLineupResponseModel>(response));
IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/channels/5");
created.Value.ShouldBe(response);
}
[Test]
public async Task CreateFromLineup_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelFromLineupResponseModel>(
new CreateChannelFromLineupResponseModel(5, 6, 7, 8)));
await _controller.CreateFromLineup(
MakeLineupRequest(number: "12", name: "Movies", templateId: 9),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateChannelFromLineup>(c =>
c.Number == "12" &&
c.Name == "Movies" &&
c.TemplateId == 9 &&
c.Lineup.Count == 1 &&
c.Lineup[0].CollectionType == CollectionType.Movie &&
c.Lineup[0].MediaItemId == 42),
Arg.Any<CancellationToken>());
}
[Test]
public async Task CreateFromLineup_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateChannelFromLineupResponseModel>(new NotFoundError("missing")));
IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
@@ -413,6 +462,30 @@ public class ChannelControllerTests
true,
false);
private static CreateChannelFromLineupRequest MakeLineupRequest(
string number = "5",
string name = "Test",
int templateId = 1) =>
new(
name,
number,
"ErsatzTV",
string.Empty,
ArtworkContentTypeModel.None,
true,
true,
templateId,
null,
[new CreateChannelFromLineupItemRequest(
LibraryBrowseMediaType.Movie,
CollectionType.Movie,
null,
null,
null,
null,
42,
null)]);
private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") =>
new(
name,
@@ -102,6 +102,8 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/{id}", "get", "404")]
[TestCase("/api/channels", "post", "404")]
[TestCase("/api/channels", "post", "422")]
[TestCase("/api/channels/from-lineup", "post", "404")]
[TestCase("/api/channels/from-lineup", "post", "422")]
[TestCase("/api/channels/{id}", "put", "404")]
[TestCase("/api/channels/{id}", "put", "422")]
[TestCase("/api/channels/{id}", "delete", "404")]
@@ -79,6 +79,25 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
});
}
[HttpPost("/api/channels/from-lineup", Name = "CreateChannelFromLineup")]
[Tags("Channels")]
[EndpointSummary("Create a channel from a library lineup")]
[EndpointDescription(
"Atomically creates the channel, generated lineup collection, program schedule, schedule items, " +
"and classic playout. Template defaults are stamped at create time; advanced overrides win.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(CreateChannelFromLineupResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateFromLineup(
[Required] [FromBody] CreateChannelFromLineupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(response => $"/api/channels/{response.ChannelId}", response => response);
}
[HttpPut("/api/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Update a channel")]
@@ -0,0 +1,111 @@
#nullable enable
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateChannelFromLineupRequest(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptionsRequest? Advanced,
List<CreateChannelFromLineupItemRequest> Lineup)
{
public CreateChannelFromLineup ToCommand() =>
new(
Name,
Number,
Group,
Categories,
Logo,
IsEnabled,
ShowInEpg,
TemplateId,
Advanced?.ToCommand() ?? new CreateChannelFromLineupAdvancedOptions(),
Lineup.Map(i => i.ToCommand()).ToList());
}
public record CreateChannelFromLineupAdvancedOptionsRequest(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string? StreamSelector = null,
string? PreferredAudioLanguageCode = null,
string? PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string? PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string? MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null)
{
public CreateChannelFromLineupAdvancedOptions ToCommand() =>
new(
PlaybackOrder,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
public record CreateChannelFromLineupItemRequest(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId)
{
public CreateChannelFromLineupItem ToCommand() =>
new(
MediaType,
CollectionType,
CollectionId,
MultiCollectionId,
SmartCollectionId,
RerunCollectionId,
MediaItemId,
PlaylistId);
}
+444
View File
@@ -531,6 +531,103 @@
}
}
},
"/api/channels/from-lineup": {
"post": {
"tags": [
"Channels"
],
"summary": "Create a channel from a library lineup",
"description": "Atomically creates the channel, generated lineup collection, program schedule, schedule items, and classic playout. Template defaults are stamped at create time; advanced overrides win.",
"operationId": "CreateChannelFromLineup",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/CreateChannelFromLineupRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateChannelFromLineupRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/CreateChannelFromLineupRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/CreateChannelFromLineupRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Created",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/CreateChannelFromLineupResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateChannelFromLineupResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/CreateChannelFromLineupResponseModel"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/channels/bulk/renumber": {
"post": {
"tags": [
@@ -5187,6 +5284,353 @@
}
}
},
"CreateChannelFromLineupAdvancedOptionsRequest": {
"type": "object",
"properties": {
"playbackOrder": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/PlaybackOrder"
}
]
},
"fFmpegProfileId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"watermarkId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"fallbackFillerId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"preRollFillerId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"midRollFillerId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"postRollFillerId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"streamSelectorMode": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelStreamSelectorMode"
}
]
},
"streamSelector": {
"type": [
"null",
"string"
]
},
"preferredAudioLanguageCode": {
"type": [
"null",
"string"
]
},
"preferredAudioTitle": {
"type": [
"null",
"string"
]
},
"playoutSource": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelPlayoutSource"
}
]
},
"playoutMode": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelPlayoutMode"
}
]
},
"streamingMode": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/StreamingMode"
}
]
},
"preferredSubtitleLanguageCode": {
"type": [
"null",
"string"
]
},
"subtitleMode": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelSubtitleMode"
}
]
},
"musicVideoCreditsMode": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelMusicVideoCreditsMode"
}
]
},
"musicVideoCreditsTemplate": {
"type": [
"null",
"string"
]
},
"songVideoMode": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelSongVideoMode"
}
]
},
"transcodeMode": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelTranscodeMode"
}
]
},
"idleBehavior": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelIdleBehavior"
}
]
},
"shuffleScheduleItems": {
"type": [
"null",
"boolean"
]
},
"randomStartPoint": {
"type": [
"null",
"boolean"
]
},
"fixedStartTimeBehavior": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/FixedStartTimeBehavior"
}
]
}
}
},
"CreateChannelFromLineupItemRequest": {
"required": [
"mediaType",
"collectionType",
"collectionId",
"multiCollectionId",
"smartCollectionId",
"rerunCollectionId",
"mediaItemId",
"playlistId"
],
"type": "object",
"properties": {
"mediaType": {
"$ref": "#/components/schemas/LibraryBrowseMediaType"
},
"collectionType": {
"$ref": "#/components/schemas/CollectionType"
},
"collectionId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"multiCollectionId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"smartCollectionId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"rerunCollectionId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"mediaItemId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"playlistId": {
"type": [
"null",
"integer"
],
"format": "int32"
}
}
},
"CreateChannelFromLineupRequest": {
"required": [
"name",
"number",
"group",
"categories",
"logo",
"isEnabled",
"showInEpg",
"templateId",
"advanced",
"lineup"
],
"type": "object",
"properties": {
"name": {
"type": "string"
},
"number": {
"type": "string"
},
"group": {
"type": "string"
},
"categories": {
"type": "string"
},
"logo": {
"$ref": "#/components/schemas/ArtworkContentTypeModel"
},
"isEnabled": {
"type": "boolean"
},
"showInEpg": {
"type": "boolean"
},
"templateId": {
"type": "integer",
"format": "int32"
},
"advanced": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/CreateChannelFromLineupAdvancedOptionsRequest"
}
]
},
"lineup": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CreateChannelFromLineupItemRequest"
}
}
}
},
"CreateChannelFromLineupResponseModel": {
"required": [
"channelId",
"collectionId",
"programScheduleId",
"playoutId"
],
"type": "object",
"properties": {
"channelId": {
"type": "integer",
"format": "int32"
},
"collectionId": {
"type": "integer",
"format": "int32"
},
"programScheduleId": {
"type": "integer",
"format": "int32"
},
"playoutId": {
"type": "integer",
"format": "int32"
}
}
},
"CreateChannelRequest": {
"required": [
"name",
+54
View File
@@ -158,6 +158,60 @@ export interface components {
"CombinedVersion": {
"apiVersion": number;
"appVersion": null | string;
};
"CreateChannelFromLineupAdvancedOptionsRequest": {
"playbackOrder"?: null | components["schemas"]["PlaybackOrder"];
"fFmpegProfileId"?: null | number;
"watermarkId"?: null | number;
"fallbackFillerId"?: null | number;
"preRollFillerId"?: null | number;
"midRollFillerId"?: null | number;
"postRollFillerId"?: null | number;
"streamSelectorMode"?: null | components["schemas"]["ChannelStreamSelectorMode"];
"streamSelector"?: null | string;
"preferredAudioLanguageCode"?: null | string;
"preferredAudioTitle"?: null | string;
"playoutSource"?: null | components["schemas"]["ChannelPlayoutSource"];
"playoutMode"?: null | components["schemas"]["ChannelPlayoutMode"];
"streamingMode"?: null | components["schemas"]["StreamingMode"];
"preferredSubtitleLanguageCode"?: null | string;
"subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"];
"musicVideoCreditsMode"?: null | components["schemas"]["ChannelMusicVideoCreditsMode"];
"musicVideoCreditsTemplate"?: null | string;
"songVideoMode"?: null | components["schemas"]["ChannelSongVideoMode"];
"transcodeMode"?: null | components["schemas"]["ChannelTranscodeMode"];
"idleBehavior"?: null | components["schemas"]["ChannelIdleBehavior"];
"shuffleScheduleItems"?: null | boolean;
"randomStartPoint"?: null | boolean;
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
};
"CreateChannelFromLineupItemRequest": {
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"rerunCollectionId": null | number;
"mediaItemId": null | number;
"playlistId": null | number;
};
"CreateChannelFromLineupRequest": {
"name": string;
"number": string;
"group": string;
"categories": string;
"logo": components["schemas"]["ArtworkContentTypeModel"];
"isEnabled": boolean;
"showInEpg": boolean;
"templateId": number;
"advanced": null | components["schemas"]["CreateChannelFromLineupAdvancedOptionsRequest"];
"lineup": Array<components["schemas"]["CreateChannelFromLineupItemRequest"]>;
};
"CreateChannelFromLineupResponseModel": {
"channelId": number;
"collectionId": number;
"programScheduleId": number;
"playoutId": number;
};
"CreateChannelRequest": {
"name": null | string;