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 workerChannel, IDbContextFactory dbContextFactory, ISearchTargets searchTargets, ILogger logger) : IRequestHandler> { // 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> Handle( CreateChannelFromLineup request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Either validation = await Validate(dbContext, request, cancellationToken); return await validation.Match( Left: error => Task.FromResult>(error), Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken)); } private async Task> PersistAndDispatch( TvContext dbContext, PreparedCreate prepared, CancellationToken cancellationToken) { await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); try { dbContext.Channels.Add(prepared.Channel); if (prepared.Playlist is not null) { dbContext.Playlists.Add(prepared.Playlist); } dbContext.ProgramSchedules.Add(prepared.ProgramSchedule); dbContext.Playouts.Add(prepared.Playout); await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } catch (DbUpdateException ex) { await transaction.RollbackAsync(cancellationToken); logger.LogError(ex, "Failed to persist channel created from lineup"); return BaseError.New("Unable to create channel from lineup"); } searchTargets.SearchTargetsChanged(); await workerChannel.WriteAsync( new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset), cancellationToken); // Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build. if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand) { await workerChannel.WriteAsync( new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false), cancellationToken); } await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken); return new CreateChannelFromLineupResponseModel( prepared.Channel.Id, prepared.Playlist?.Id, prepared.ProgramSchedule.Id, prepared.Playout.Id); } private static async Task> 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 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(); for (int i = 0; i < request.Lineup.Count; i++) { Either itemValidation = await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken); foreach (BaseError error in itemValidation.LeftToSeq()) { return error; } foreach (NormalizedLineupItem item in itemValidation.RightToSeq()) { normalized.Add(item); } } bool multiItem = normalized.Count >= 2; // MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid). if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) && playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder)) { return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'"); } if (multiItem) { // The generated playlist cannot express rerun collections or nested playlists // (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields). for (int i = 0; i < normalized.Count; i++) { if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist) { return BaseError.New( $"lineup[{normalized[i].Index}]: rerun collections and playlists are only " + "supported as a single-item lineup"); } } } Channel channel = BuildChannel( request, template, advanced, name, number, group, categories, ffmpegProfileId, fallbackFillerId); string scheduleName = await DeCollideName( GeneratedName(number, name, "Schedule"), (candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct), cancellationToken); ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced); Playlist playlist = null; ProgramScheduleItemFlood floodItem = BuildFloodBase( playbackOrder, advanced, template, fallbackFillerId, preRollFillerId, midRollFillerId, postRollFillerId); if (multiItem) { playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken); floodItem.CollectionType = CollectionType.Playlist; floodItem.Playlist = playlist; } else { NormalizedLineupItem only = normalized[0]; floodItem.CollectionType = only.CollectionType; floodItem.CollectionId = only.CollectionId; floodItem.MultiCollectionId = only.MultiCollectionId; floodItem.SmartCollectionId = only.SmartCollectionId; floodItem.RerunCollectionId = only.RerunCollectionId; floodItem.MediaItemId = only.MediaItemId; floodItem.PlaylistId = only.PlaylistId; } schedule.Items = [floodItem]; var playout = new Playout { Channel = channel, ProgramSchedule = schedule, ScheduleKind = PlayoutScheduleKind.Classic }; return new PreparedCreate(channel, playlist, schedule, playout); } private static async Task BuildPlaylist( TvContext dbContext, string channelNumber, string channelName, List 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(); 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 DeCollideName( string baseName, Func> 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> 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 channelReferences = await ValidateChannelReferences( dbContext, advanced.WatermarkId ?? template.WatermarkId, advanced.FallbackFillerId ?? template.FallbackFillerId, cancellationToken); foreach (BaseError error in channelReferences.LeftToSeq()) { return error; } Either 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> 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> 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 FillerExists( TvContext dbContext, int id, FillerKind fillerKind, CancellationToken cancellationToken) => dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken); private static async Task> 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> NormalizeMediaItem( DbSet 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> ExistsThen( DbSet set, int id, string label, NormalizedLineupItem normalized, CancellationToken cancellationToken) where TEntity : class { bool exists = await set.AsNoTracking() .AnyAsync(e => EF.Property(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); }