CI's Formatting job failed: 19 touched files carried a BOM, which .editorconfig forbids (charset=utf-8). Pure encoding change — one byte per file, no semantic diff (verified: every hunk is `-namespace` -> `+namespace`). Self-inflicted. The patches that edited these legacy files wrote them back as utf-8-sig to "preserve the existing style", but the #311 fix-as-you-touch gate requires a file to be normalized when you touch it — that is the whole point of scoping the gate to changed files instead of reformatting the ~2500 legacy BOM files at once. dotnet format leaves the EF-generated Designer/snapshot files alone as generated code, and its verify skips them the same way, so they stay as ef emitted them. Two corrections to what I believed going in: - `dotnet format --include` does NOT no-op here. It reported `error CHARSET` for each file and exit 2, reproducing CI exactly, and fixed them in place. The note claiming otherwise is wrong for this invocation. - My first BOM check reported all files clean. The od pattern was wrong; reading the first three bytes directly found 19. A detector that can only say "ok" is worse than no detector. Core.Tests 565, ErsatzTV.Tests 1673, Architecture.Tests 5 — all passed. API artifacts still in sync. Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
770 lines
31 KiB
C#
770 lines
31 KiB
C#
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();
|
|
|
|
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
|
// can't abort it after the commit landed (#254)
|
|
await workerChannel.WriteAsync(
|
|
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
|
|
CancellationToken.None);
|
|
|
|
// 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.None);
|
|
}
|
|
|
|
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
|
|
|
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 / WeightedShuffle
|
|
// (mirrors PlayoutModeMustBeValid -- keep the two lists in step).
|
|
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
|
|
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder
|
|
or PlaybackOrder.WeightedShuffle))
|
|
{
|
|
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
|
|
}
|
|
|
|
// A lineup of 2+ entries is persisted as a Playlist, and PlaylistEnumerator has no default arm: an
|
|
// order it doesn't know leaves the enumerator null and the items are dropped from the playlist with
|
|
// nothing reported. This is the second (and less obvious) persisting writer of
|
|
// PlaylistItem.PlaybackOrder, alongside ReplacePlaylistItems (#70; the silent fallbacks are #403).
|
|
if (multiItem && playbackOrder is PlaybackOrder.WeightedShuffle)
|
|
{
|
|
return BaseError.New(
|
|
$"Playback order '{playbackOrder}' is not supported for a multi-item lineup; it is available on classic schedule items");
|
|
}
|
|
|
|
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);
|
|
}
|