Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
906 lines
37 KiB
C#
906 lines
37 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.Images;
|
|
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,
|
|
IRemoteLogoCacher remoteLogoCacher,
|
|
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: async prepared =>
|
|
{
|
|
Either<BaseError, PreparedCreate> resolved =
|
|
await ResolveExternalLogo(request, prepared, cancellationToken);
|
|
return await resolved.Match(
|
|
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
|
|
Right: p => PersistAndDispatch(dbContext, p, cancellationToken));
|
|
});
|
|
}
|
|
|
|
// The lineup logo artwork is built (in BuildChannel) with the raw request path. When that path is
|
|
// an external http(s) URL, download + cache it and swap the cache name onto the logo artwork before
|
|
// persisting (a cacher Left fails the whole create); a blank or already-local/cached path is left
|
|
// unchanged. (ersatztv#525)
|
|
private async Task<Either<BaseError, PreparedCreate>> ResolveExternalLogo(
|
|
CreateChannelFromLineup request,
|
|
PreparedCreate prepared,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
string path = request.Logo?.Path ?? string.Empty;
|
|
|
|
if (!Artwork.IsExternalUrl(path))
|
|
{
|
|
return prepared;
|
|
}
|
|
|
|
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
|
|
return cached.Map(name =>
|
|
{
|
|
foreach (Artwork logo in prepared.Channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
|
|
{
|
|
logo.Path = name;
|
|
}
|
|
|
|
return prepared;
|
|
});
|
|
}
|
|
|
|
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
|
|
TvContext dbContext,
|
|
PreparedCreate prepared,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
|
try
|
|
{
|
|
await ChannelGraphicsDefaults.Attach(dbContext, prepared.Channel, cancellationToken);
|
|
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.");
|
|
}
|
|
|
|
// "clear to none" (#135): a field named in advanced.Clear is forced to none even when the
|
|
// template sets one; both setting and clearing the same field is contradictory.
|
|
Either<BaseError, Unit> clearValidation = ValidateClear(advanced);
|
|
foreach (BaseError error in clearValidation.LeftToSeq())
|
|
{
|
|
return error;
|
|
}
|
|
|
|
ResolvedClearableOptions resolved = ResolveClearable(advanced, template);
|
|
|
|
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
|
|
int? fallbackFillerId = resolved.FallbackFillerId;
|
|
int? preRollFillerId = resolved.PreRollFillerId;
|
|
int? midRollFillerId = resolved.MidRollFillerId;
|
|
int? postRollFillerId = resolved.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,
|
|
ffmpegProfileId,
|
|
resolved,
|
|
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,
|
|
resolved,
|
|
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,
|
|
resolved,
|
|
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,
|
|
ResolvedClearableOptions resolved,
|
|
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 = resolved.WatermarkId,
|
|
FallbackFillerId = fallbackFillerId,
|
|
Artwork = artwork,
|
|
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
|
|
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
|
|
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
|
|
PreferredAudioTitle = resolved.PreferredAudioTitle,
|
|
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
|
|
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,
|
|
Origin = ChannelOrigin.AutoTuned
|
|
};
|
|
}
|
|
|
|
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,
|
|
ResolvedClearableOptions resolved,
|
|
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 = resolved.PreferredAudioLanguageCode,
|
|
PreferredAudioTitle = resolved.PreferredAudioTitle,
|
|
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
|
|
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,
|
|
int ffmpegProfileId,
|
|
ResolvedClearableOptions resolved,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
|
|
{
|
|
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
|
|
}
|
|
|
|
// Validate the post-clear effective ids: a cleared reference resolves to null and skips the
|
|
// existence check (there is nothing to point at).
|
|
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
|
|
dbContext,
|
|
resolved.WatermarkId,
|
|
resolved.FallbackFillerId,
|
|
cancellationToken);
|
|
foreach (BaseError error in channelReferences.LeftToSeq())
|
|
{
|
|
return error;
|
|
}
|
|
|
|
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
|
|
dbContext,
|
|
resolved.PreRollFillerId,
|
|
resolved.MidRollFillerId,
|
|
resolved.PostRollFillerId,
|
|
cancellationToken);
|
|
foreach (BaseError error in itemFillers.LeftToSeq())
|
|
{
|
|
return error;
|
|
}
|
|
|
|
return Unit.Default;
|
|
}
|
|
|
|
// A field named in advanced.Clear must not also carry a set value: that request is contradictory.
|
|
// A null/empty set value alongside a clear is fine (redundant, not conflicting). (#135)
|
|
private static Either<BaseError, Unit> ValidateClear(CreateChannelFromLineupAdvancedOptions advanced)
|
|
{
|
|
if (advanced.Clear is null || advanced.Clear.Count == 0)
|
|
{
|
|
return Unit.Default;
|
|
}
|
|
|
|
var cleared = advanced.Clear.ToHashSet();
|
|
|
|
(CreateChannelFromLineupClearField Field, bool HasSetValue)[] checks =
|
|
[
|
|
(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId.HasValue),
|
|
(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId.HasValue),
|
|
(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId.HasValue),
|
|
(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId.HasValue),
|
|
(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId.HasValue),
|
|
(CreateChannelFromLineupClearField.PreferredAudioLanguage,
|
|
!string.IsNullOrEmpty(advanced.PreferredAudioLanguageCode)),
|
|
(CreateChannelFromLineupClearField.PreferredAudioTitle,
|
|
!string.IsNullOrEmpty(advanced.PreferredAudioTitle)),
|
|
(CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
|
|
!string.IsNullOrEmpty(advanced.PreferredSubtitleLanguageCode))
|
|
];
|
|
|
|
foreach ((CreateChannelFromLineupClearField field, bool hasSetValue) in checks)
|
|
{
|
|
if (cleared.Contains(field) && hasSetValue)
|
|
{
|
|
return BaseError.New(
|
|
$"Advanced option '{field}' cannot be both set and cleared in the same request");
|
|
}
|
|
}
|
|
|
|
return Unit.Default;
|
|
}
|
|
|
|
// Compute the effective value of every clearable field once: cleared -> none, else the advanced
|
|
// override coalesced with the template value (the historical omitted=inherit contract). (#135)
|
|
private static ResolvedClearableOptions ResolveClearable(
|
|
CreateChannelFromLineupAdvancedOptions advanced,
|
|
ChannelTemplate template)
|
|
{
|
|
System.Collections.Generic.HashSet<CreateChannelFromLineupClearField> cleared = advanced.Clear is null
|
|
? []
|
|
: advanced.Clear.ToHashSet();
|
|
|
|
int? Id(CreateChannelFromLineupClearField field, int? adv, int? tmpl) =>
|
|
cleared.Contains(field) ? null : adv ?? tmpl;
|
|
|
|
string Str(CreateChannelFromLineupClearField field, string adv, string tmpl) =>
|
|
cleared.Contains(field) ? string.Empty : adv ?? tmpl ?? string.Empty;
|
|
|
|
return new ResolvedClearableOptions(
|
|
Id(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId, template.WatermarkId),
|
|
Id(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId, template.FallbackFillerId),
|
|
Id(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId, template.PreRollFillerId),
|
|
Id(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId, template.MidRollFillerId),
|
|
Id(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId, template.PostRollFillerId),
|
|
Str(
|
|
CreateChannelFromLineupClearField.PreferredAudioLanguage,
|
|
advanced.PreferredAudioLanguageCode,
|
|
template.PreferredAudioLanguageCode),
|
|
Str(
|
|
CreateChannelFromLineupClearField.PreferredAudioTitle,
|
|
advanced.PreferredAudioTitle,
|
|
template.PreferredAudioTitle),
|
|
Str(
|
|
CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
|
|
advanced.PreferredSubtitleLanguageCode,
|
|
template.PreferredSubtitleLanguageCode));
|
|
}
|
|
|
|
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);
|
|
|
|
// Effective values for the clearable advanced fields after applying advanced.Clear + template
|
|
// coalescing (#135). Strings coalesce to string.Empty (never null); ids stay nullable.
|
|
private sealed record ResolvedClearableOptions(
|
|
int? WatermarkId,
|
|
int? FallbackFillerId,
|
|
int? PreRollFillerId,
|
|
int? MidRollFillerId,
|
|
int? PostRollFillerId,
|
|
string PreferredAudioLanguageCode,
|
|
string PreferredAudioTitle,
|
|
string PreferredSubtitleLanguageCode);
|
|
}
|