Adversarial review of PR #402 returned BLOCKED. It could not break the WRR math or the stateless-restore claim (it probed restore across wraps at indices 12/13/20/37 — all held, and the clamp preserves a 1000:1 ratio exactly). What it broke was the perimeter. B1 — the validation gate had a hole, so the silent-drop bug shipped. CreateChannelFromLineup is a THIRD writer of PlaylistItem.PlaybackOrder; its own guard only covered MultiCollection entries, so a 2+ entry lineup of plain collections persisted WeightedShuffle straight through to PlaylistEnumerator's null-drop. My decisions.md claim that "the silent sites never see it" was false as written — corrected in place, with the lesson recorded: grep every writer of the field, the non-obvious composite handler is the one that gets missed. The Add*ToPlaylist handlers are safe only because they hardcode their order. B2 — Weight had no validation at all, and create/update disagreed on the same input. EF's HasDefaultValue(1) substitutes 1 for a 0 on INSERT (0 reads as "not set") but an UPDATE writes the 0 through — and a 0-weight source was filtered out of the rotation, deleting it from the channel silently. Exactly the failure this order is careful to avoid everywhere else. Now bounded 1..1000 by a shared MultiCollectionItemWeight used by both paths so they cannot drift, and clamped again in the enumerator for rows that predate the gate. B3 — Sum(weights) is checked arithmetic, so two int.MaxValue weights threw OverflowException from inside a playout build. Reachable through the API precisely because of B2. The ceiling fixes both; the sum also widens to long. M1 the lineup mirror now allows WeightedShuffle for multi collections, matching the PlayoutModeMustBeValid change it claims to mirror. M3 ScheduleAsGroup is documented as deliberately unread by this order. L1 MinimumDuration is computed over every source instead of the current rotation — under the clamp a rotation is a strict subset and is rebuilt each wrap, so caching over it went stale. L2 the retry guard keys off the rotation, not the raw collection count. N1 the tautological default test is gone: it built entities in C#, so it asserted the property initializer, not the migration — it could not have failed. Replaced with clamp, overflow, and cross-wrap restore cases (the property the review proved but found unpinned). H1 the two follow-ups the PR body claimed were "filed" did not exist. Now filed: #403 (silent dispatch-fallback hardening) and #404 (SPA weight UI, blocked-by #388). Core.Tests 565 passed, ErsatzTV.Tests 1643 passed, 0 failed. 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 third writer of PlaylistItem.PlaybackOrder, alongside
|
|
// ReplacePlaylistItems and ReplaceBlockItems (#70; the silent fallbacks themselves 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);
|
|
}
|