Merge pull request 'feat(api): composite create-channel endpoint (#63)' (#134) from feat/63-composite-create into main
This commit was merged in pull request #134.
This commit is contained in:
@@ -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,754 @@
|
||||
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 Microsoft.Extensions.Logging;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class CreateChannelFromLineupHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
ILogger<CreateChannelFromLineupHandler> logger)
|
||||
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
|
||||
{
|
||||
// The single system playlist group that holds every generated channel-lineup playlist.
|
||||
// Matches the Trakt "Trakt Lists" precedent (DbInitializer + delete guards on IsSystem).
|
||||
private const string SystemPlaylistGroupName = "Channel Lineups";
|
||||
|
||||
public async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> Handle(
|
||||
CreateChannelFromLineup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Match(
|
||||
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
|
||||
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
|
||||
TvContext dbContext,
|
||||
PreparedCreate prepared,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
dbContext.Channels.Add(prepared.Channel);
|
||||
if (prepared.Playlist is not null)
|
||||
{
|
||||
dbContext.Playlists.Add(prepared.Playlist);
|
||||
}
|
||||
|
||||
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);
|
||||
logger.LogError(ex, "Failed to persist channel created from lineup");
|
||||
return BaseError.New("Unable to create channel from lineup");
|
||||
}
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await workerChannel.WriteAsync(
|
||||
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
|
||||
cancellationToken);
|
||||
|
||||
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
|
||||
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
await workerChannel.WriteAsync(
|
||||
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return new CreateChannelFromLineupResponseModel(
|
||||
prepared.Channel.Id,
|
||||
prepared.Playlist?.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.");
|
||||
}
|
||||
|
||||
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;
|
||||
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
|
||||
|
||||
// Mirror channels need special MirrorSourceChannelId plumbing (see CreateChannelHandler);
|
||||
// this endpoint only builds generated playouts.
|
||||
if (playoutSource is ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
return BaseError.New("Mirror playout source is not supported by this endpoint");
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
|
||||
dbContext,
|
||||
advanced,
|
||||
template,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in referenceValidation.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
// Normalize + validate every lineup entry once, so validation and build see the same data.
|
||||
var normalized = new List<NormalizedLineupItem>();
|
||||
for (int i = 0; i < request.Lineup.Count; i++)
|
||||
{
|
||||
Either<BaseError, NormalizedLineupItem> itemValidation =
|
||||
await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken);
|
||||
foreach (BaseError error in itemValidation.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (NormalizedLineupItem item in itemValidation.RightToSeq())
|
||||
{
|
||||
normalized.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
bool multiItem = normalized.Count >= 2;
|
||||
|
||||
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
|
||||
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
|
||||
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
|
||||
{
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
|
||||
}
|
||||
|
||||
if (multiItem)
|
||||
{
|
||||
// The generated playlist cannot express rerun collections or nested playlists
|
||||
// (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields).
|
||||
for (int i = 0; i < normalized.Count; i++)
|
||||
{
|
||||
if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"lineup[{normalized[i].Index}]: rerun collections and playlists are only " +
|
||||
"supported as a single-item lineup");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Channel channel = BuildChannel(
|
||||
request,
|
||||
template,
|
||||
advanced,
|
||||
name,
|
||||
number,
|
||||
group,
|
||||
categories,
|
||||
ffmpegProfileId,
|
||||
fallbackFillerId);
|
||||
|
||||
string scheduleName = await DeCollideName(
|
||||
GeneratedName(number, name, "Schedule"),
|
||||
(candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct),
|
||||
cancellationToken);
|
||||
|
||||
ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced);
|
||||
|
||||
Playlist playlist = null;
|
||||
ProgramScheduleItemFlood floodItem = BuildFloodBase(
|
||||
playbackOrder,
|
||||
advanced,
|
||||
template,
|
||||
fallbackFillerId,
|
||||
preRollFillerId,
|
||||
midRollFillerId,
|
||||
postRollFillerId);
|
||||
|
||||
if (multiItem)
|
||||
{
|
||||
playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken);
|
||||
floodItem.CollectionType = CollectionType.Playlist;
|
||||
floodItem.Playlist = playlist;
|
||||
}
|
||||
else
|
||||
{
|
||||
NormalizedLineupItem only = normalized[0];
|
||||
floodItem.CollectionType = only.CollectionType;
|
||||
floodItem.CollectionId = only.CollectionId;
|
||||
floodItem.MultiCollectionId = only.MultiCollectionId;
|
||||
floodItem.SmartCollectionId = only.SmartCollectionId;
|
||||
floodItem.RerunCollectionId = only.RerunCollectionId;
|
||||
floodItem.MediaItemId = only.MediaItemId;
|
||||
floodItem.PlaylistId = only.PlaylistId;
|
||||
}
|
||||
|
||||
schedule.Items = [floodItem];
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ProgramSchedule = schedule,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
};
|
||||
|
||||
return new PreparedCreate(channel, playlist, schedule, playout);
|
||||
}
|
||||
|
||||
private static async Task<Playlist> BuildPlaylist(
|
||||
TvContext dbContext,
|
||||
string channelNumber,
|
||||
string channelName,
|
||||
List<NormalizedLineupItem> lineup,
|
||||
PlaybackOrder playbackOrder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Reuse the single system playlist group, creating it on first use.
|
||||
PlaylistGroup playlistGroup = await dbContext.PlaylistGroups
|
||||
.FirstOrDefaultAsync(pg => pg.IsSystem && pg.Name == SystemPlaylistGroupName, cancellationToken);
|
||||
playlistGroup ??= new PlaylistGroup { Name = SystemPlaylistGroupName, IsSystem = true };
|
||||
|
||||
string playlistName = await DeCollideName(
|
||||
GeneratedName(channelNumber, channelName, "Lineup"),
|
||||
(candidate, ct) => dbContext.Playlists.AnyAsync(
|
||||
p => p.PlaylistGroupId == playlistGroup.Id && p.Name == candidate,
|
||||
ct),
|
||||
cancellationToken);
|
||||
|
||||
var playlist = new Playlist
|
||||
{
|
||||
Name = playlistName,
|
||||
IsSystem = true,
|
||||
PlaylistGroup = playlistGroup,
|
||||
PlaylistGroupId = playlistGroup.Id,
|
||||
Items = []
|
||||
};
|
||||
|
||||
int index = 1;
|
||||
foreach (NormalizedLineupItem item in lineup)
|
||||
{
|
||||
playlist.Items.Add(new PlaylistItem
|
||||
{
|
||||
Playlist = playlist,
|
||||
Index = index++,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
SmartCollectionId = item.SmartCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaybackOrder = playbackOrder,
|
||||
|
||||
// Play every item in each entry before advancing so lineup order is honored
|
||||
// (PlaylistEnumerator round-robins one-per-entry unless PlayAll/Count).
|
||||
PlayAll = true,
|
||||
IncludeInProgramGuide = true
|
||||
});
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
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 ProgramSchedule BuildProgramSchedule(
|
||||
string scheduleName,
|
||||
ChannelTemplate template,
|
||||
CreateChannelFromLineupAdvancedOptions advanced) =>
|
||||
new()
|
||||
{
|
||||
Name = scheduleName,
|
||||
KeepMultiPartEpisodesTogether = true,
|
||||
TreatCollectionsAsShows = true,
|
||||
ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems,
|
||||
RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint,
|
||||
FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior,
|
||||
Items = []
|
||||
};
|
||||
|
||||
private static ProgramScheduleItemFlood BuildFloodBase(
|
||||
PlaybackOrder playbackOrder,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template,
|
||||
int? fallbackFillerId,
|
||||
int? preRollFillerId,
|
||||
int? midRollFillerId,
|
||||
int? postRollFillerId) =>
|
||||
new()
|
||||
{
|
||||
Index = 1,
|
||||
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
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// De-collide a generated (already <= 50 char) name against a unique index by appending " 2", " 3", ...
|
||||
// rather than leaking a raw UNIQUE-constraint failure.
|
||||
private static async Task<string> DeCollideName(
|
||||
string baseName,
|
||||
Func<string, CancellationToken, Task<bool>> exists,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await exists(baseName, cancellationToken))
|
||||
{
|
||||
return baseName;
|
||||
}
|
||||
|
||||
for (int n = 2; ; n++)
|
||||
{
|
||||
string suffix = $" {n}";
|
||||
string candidate = baseName.Length + suffix.Length > 50
|
||||
? baseName[..(50 - suffix.Length)].TrimEnd() + suffix
|
||||
: baseName + suffix;
|
||||
|
||||
if (!await exists(candidate, cancellationToken))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, Unit>> ValidateReferences(
|
||||
TvContext dbContext,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template,
|
||||
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;
|
||||
}
|
||||
|
||||
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, NormalizedLineupItem>> NormalizeLineupItem(
|
||||
TvContext dbContext,
|
||||
CreateChannelFromLineupItem item,
|
||||
int index,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
switch (item.MediaType)
|
||||
{
|
||||
case LibraryBrowseMediaType.Movie:
|
||||
return await NormalizeMediaItem(
|
||||
dbContext.Movies, item, CollectionType.Movie, index, "Movie", cancellationToken);
|
||||
case LibraryBrowseMediaType.TelevisionShow:
|
||||
return await NormalizeMediaItem(
|
||||
dbContext.Shows, item, CollectionType.TelevisionShow, index, "TelevisionShow", cancellationToken);
|
||||
case LibraryBrowseMediaType.TelevisionSeason:
|
||||
return await NormalizeMediaItem(
|
||||
dbContext.Seasons, item, CollectionType.TelevisionSeason, index, "TelevisionSeason", cancellationToken);
|
||||
case LibraryBrowseMediaType.Artist:
|
||||
return await NormalizeMediaItem(
|
||||
dbContext.Artists, item, CollectionType.Artist, index, "Artist", cancellationToken);
|
||||
case LibraryBrowseMediaType.Collection:
|
||||
if (item.CollectionType != CollectionType.Collection)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.CollectionId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "collectionId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.Collections,
|
||||
item.CollectionId.Value,
|
||||
$"lineup[{index}] Collection",
|
||||
new NormalizedLineupItem(index, CollectionType.Collection, CollectionId: item.CollectionId),
|
||||
cancellationToken);
|
||||
case LibraryBrowseMediaType.SmartCollection:
|
||||
if (item.CollectionType != CollectionType.SmartCollection)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.SmartCollectionId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "smartCollectionId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.SmartCollections,
|
||||
item.SmartCollectionId.Value,
|
||||
$"lineup[{index}] SmartCollection",
|
||||
new NormalizedLineupItem(index, CollectionType.SmartCollection, SmartCollectionId: item.SmartCollectionId),
|
||||
cancellationToken);
|
||||
case LibraryBrowseMediaType.MultiCollection:
|
||||
if (item.CollectionType != CollectionType.MultiCollection)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.MultiCollectionId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "multiCollectionId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.MultiCollections,
|
||||
item.MultiCollectionId.Value,
|
||||
$"lineup[{index}] MultiCollection",
|
||||
new NormalizedLineupItem(index, CollectionType.MultiCollection, MultiCollectionId: item.MultiCollectionId),
|
||||
cancellationToken);
|
||||
case LibraryBrowseMediaType.RerunCollection:
|
||||
if (item.CollectionType != CollectionType.RerunFirstRun)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.RerunCollectionId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "rerunCollectionId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.RerunCollections,
|
||||
item.RerunCollectionId.Value,
|
||||
$"lineup[{index}] RerunCollection",
|
||||
new NormalizedLineupItem(index, CollectionType.RerunFirstRun, RerunCollectionId: item.RerunCollectionId),
|
||||
cancellationToken);
|
||||
case LibraryBrowseMediaType.Playlist:
|
||||
if (item.CollectionType != CollectionType.Playlist)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.PlaylistId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "playlistId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.Playlists,
|
||||
item.PlaylistId.Value,
|
||||
$"lineup[{index}] Playlist",
|
||||
new NormalizedLineupItem(index, CollectionType.Playlist, PlaylistId: item.PlaylistId),
|
||||
cancellationToken);
|
||||
default:
|
||||
return BaseError.New($"lineup[{index}] has an unsupported media type '{item.MediaType}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeMediaItem<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
CreateChannelFromLineupItem item,
|
||||
CollectionType expectedType,
|
||||
int index,
|
||||
string label,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (item.CollectionType != expectedType)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.MediaItemId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "mediaItemId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
set,
|
||||
item.MediaItemId.Value,
|
||||
$"lineup[{index}] {label}",
|
||||
new NormalizedLineupItem(index, expectedType, MediaItemId: item.MediaItemId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static BaseError Mismatch(int index, CreateChannelFromLineupItem item) =>
|
||||
BaseError.New(
|
||||
$"lineup[{index}]: media type '{item.MediaType}' does not match collection type '{item.CollectionType}'.");
|
||||
|
||||
private static BaseError WrongId(int index, LibraryBrowseMediaType mediaType, string expectedField) =>
|
||||
BaseError.New($"lineup[{index}]: media type '{mediaType}' requires a {expectedField}.");
|
||||
|
||||
private static async Task<Either<BaseError, NormalizedLineupItem>> ExistsThen<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
int id,
|
||||
string label,
|
||||
NormalizedLineupItem normalized,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
bool exists = await set.AsNoTracking()
|
||||
.AnyAsync(e => EF.Property<int>(e, "Id") == id, cancellationToken);
|
||||
return exists
|
||||
? normalized
|
||||
: new NotFoundError($"{label} {id} does not exist.");
|
||||
}
|
||||
|
||||
private sealed record NormalizedLineupItem(
|
||||
int Index,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId = null,
|
||||
int? MultiCollectionId = null,
|
||||
int? SmartCollectionId = null,
|
||||
int? RerunCollectionId = null,
|
||||
int? MediaItemId = null,
|
||||
int? PlaylistId = null);
|
||||
|
||||
private sealed record PreparedCreate(
|
||||
Channel Channel,
|
||||
Playlist Playlist,
|
||||
ProgramSchedule ProgramSchedule,
|
||||
Playout Playout);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
public record CreateChannelFromLineupResponseModel(
|
||||
int ChannelId,
|
||||
int? PlaylistId,
|
||||
int ProgramScheduleId,
|
||||
int PlayoutId);
|
||||
@@ -0,0 +1,751 @@
|
||||
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 Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
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!;
|
||||
|
||||
[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_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();
|
||||
|
||||
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 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_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_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,
|
||||
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);
|
||||
}
|
||||
@@ -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,32 @@ 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, program schedule, a classic playout, and (for multi-item lineups) a " +
|
||||
"generated system playlist. A single-item lineup produces one flood schedule item that references the " +
|
||||
"target directly (movie, show, season, artist, collection, smart/multi collection, rerun collection, or " +
|
||||
"playlist) and no generated playlist. A lineup with two or more items produces one generated system " +
|
||||
"playlist whose entries play in the given order (each entry played in full before the next) referenced by " +
|
||||
"one flood schedule item; only movies, shows, seasons, artists, collections, smart collections and multi " +
|
||||
"collections are allowed there (rerun collections and playlists are single-item only). playbackOrder sets " +
|
||||
"how items within each lineup entry are ordered. 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,117 @@
|
||||
#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;
|
||||
|
||||
/// <summary>
|
||||
/// Composite request to create a channel, its generated schedule/playlist and a classic playout in one call.
|
||||
/// Template defaults are stamped at create time; any value set in <see cref="Advanced" /> overrides the template.
|
||||
/// A single-item lineup references its target directly; a multi-item lineup is played in order via a generated
|
||||
/// system playlist.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -531,6 +531,103 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/from-lineup": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Create a channel from a library lineup",
|
||||
"description": "Atomically creates the channel, program schedule, a classic playout, and (for multi-item lineups) a generated system playlist. A single-item lineup produces one flood schedule item that references the target directly (movie, show, season, artist, collection, smart/multi collection, rerun collection, or playlist) and no generated playlist. A lineup with two or more items produces one generated system playlist whose entries play in the given order (each entry played in full before the next) referenced by one flood schedule item; only movies, shows, seasons, artists, collections, smart collections and multi collections are allowed there (rerun collections and playlists are single-item only). playbackOrder sets how items within each lineup entry are ordered. 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,356 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"playlistId",
|
||||
"programScheduleId",
|
||||
"playoutId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channelId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"playlistId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"programScheduleId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"playoutId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateChannelRequest": {
|
||||
"required": [
|
||||
"name",
|
||||
|
||||
Vendored
+54
@@ -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;
|
||||
"playlistId": null | number;
|
||||
"programScheduleId": number;
|
||||
"playoutId": number;
|
||||
};
|
||||
"CreateChannelRequest": {
|
||||
"name": null | string;
|
||||
|
||||
Reference in New Issue
Block a user