using ErsatzTV.Application.Artworks; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Data; using LanguageExt; using MediatR; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Channels; public class CreateAutoTunedChannelsHandler( ISender mediator, IDbContextFactory dbContextFactory, ISearchTargets searchTargets, ISmartCollectionCache smartCollectionCache) : IRequestHandler { private const string NumberTakenError = "Channel number must be unique"; private const string DefaultGroup = "Auto-Tuned"; // The members enumeration caps its own search at 10k leaf items, so a channel's distinct source count is // already bounded (dozens/hundreds). One large page pulls them all. private const int MaxSources = 10_000; public async Task Handle( CreateAutoTunedChannels request, CancellationToken cancellationToken) { string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim(); var outcomes = new List(); foreach (AutoTuneChannelSelection selection in request.Channels ?? []) { outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken)); } return new AutoTuneResult(outcomes); } private async Task CreateOne( int templateId, string group, AutoTuneChannelSelection selection, CancellationToken cancellationToken) { string name = (selection.Name ?? string.Empty).Trim(); if (name.Length is 0 or > 50) { return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name"); } // Per-channel template override falls back to the batch template. int effectiveTemplateId = selection.TemplateId ?? templateId; // Per-channel uploaded channel image; None = generate the on-the-fly fallback logo at serve time. ArtworkContentTypeModel logo = selection.Logo ?? ArtworkContentTypeModel.None; // Per-source rotation weights / query corrections (#425) turn the channel from one fair-share // SmartCollection into a MultiCollection of per-source SmartCollections carrying the weights. Only // when the caller actually customized a source (a non-default weight, an exclusion, or an added // out-of-axis source) — otherwise the single-SmartCollection fair-share shape is kept (cheaper, and // identical output for TV since the fake-collection path already groups per show). WeightedPlan plan = await BuildWeightedPlan(selection, cancellationToken); if (plan is not null) { return await CreateWeightedChannel( effectiveTemplateId, group, name, logo, selection, plan, cancellationToken); } return await CreateSingleSmartCollectionChannel( effectiveTemplateId, group, name, logo, selection, cancellationToken); } private async Task CreateSingleSmartCollectionChannel( int effectiveTemplateId, string group, string name, ArtworkContentTypeModel logo, AutoTuneChannelSelection selection, CancellationToken cancellationToken) { string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value); // The axis default (SeasonEpisode for a single show, Shuffle for a genre) is the playback order // unless the DetailPanel set an explicit per-channel override. Any other Advanced field the caller // set is layered on top of the template by CreateChannelFromLineup's `advanced.X ?? template.X` // stamp-at-create contract, so we only have to fill in the axis-derived PlaybackOrder default here. PlaybackOrder axisOrder = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis); CreateChannelFromLineupAdvancedOptions advanced = (selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with { PlaybackOrder = selection.Advanced?.PlaybackOrder ?? axisOrder }; // 1. Create the smart collection that drives this channel. Either scResult = await mediator.Send(new CreateSmartCollection(query, name), cancellationToken); SmartCollectionViewModel smartCollection = null; foreach (BaseError error in scResult.LeftToSeq()) { return new AutoTuneChannelOutcome( name, AutoTuneOutcomeStatus.Failed, null, $"Smart collection: {error.Value}"); } foreach (SmartCollectionViewModel vm in scResult.RightToSeq()) { smartCollection = vm; } // 2. Create the channel from a single-item lineup referencing the smart collection. var command = new CreateChannelFromLineup( name, selection.Number, group, string.Empty, logo, IsEnabled: true, ShowInEpg: true, effectiveTemplateId, advanced, [ new CreateChannelFromLineupItem( LibraryBrowseMediaType.SmartCollection, CollectionType.SmartCollection, CollectionId: null, MultiCollectionId: null, SmartCollectionId: smartCollection.Id, RerunCollectionId: null, MediaItemId: null, PlaylistId: null) ]); Either channelResult = await mediator.Send(command, cancellationToken); foreach (BaseError error in channelResult.LeftToSeq()) { // Roll back the smart collection we just created so a retry of this // axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort; // the primary outcome below is still Skipped/Failed regardless of the delete result. // Swallow any exception (not just an Either.Left) so a transient infra failure // during rollback never aborts this channel's outcome or the batch; the // orphaned SmartCollection is an acceptable degraded outcome. try { await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken); } catch (Exception) { // intentionally ignored; see comment above } AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal) ? AutoTuneOutcomeStatus.Skipped : AutoTuneOutcomeStatus.Failed; return new AutoTuneChannelOutcome(name, status, null, error.Value); } int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId); return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null); } // A resolved weighting plan: the per-source member queries + their weights, and the catch-all remainder. // Null when the caller did not actually customize anything (fall back to the single-SmartCollection path). private sealed record WeightedPlan(List Members, WeightedMember Remainder); private sealed record WeightedMember(string Query, int Weight); // Resolve the caller's per-source overrides against the channel's live base source set. Returns null when // no source was customized (all weights 1, nothing excluded, nothing added) so the caller keeps the // single-SmartCollection fair-share shape. private async Task BuildWeightedPlan( AutoTuneChannelSelection selection, CancellationToken cancellationToken) { List sources = selection.Sources ?? []; if (sources.Count == 0) { return null; } // Enumerate the axis's distinct base sources (parent shows for TV, movies for the movie axis) exactly // as the DetailPanel members list does, so weight resolution matches what the user saw. PagedLibraryBrowseItemsResponseModel members = await mediator.Send( new GetAutoTuneChannelMembers(selection.Axis, selection.Value, 0, MaxSources), cancellationToken); var baseIds = members.Page.Select(i => i.Id).ToHashSet(); // Any override touching a non-default weight, an exclusion, or an id outside the base set means the // channel really is customized; otherwise the plan would be identical to fair-share. bool customized = sources.Any(s => s.Weight != 1 || s.Excluded || !baseIds.Contains(s.SourceId)); if (!customized) { return null; } Dictionary overridesById = sources .GroupBy(s => s.SourceId) .ToDictionary(g => g.Key, g => g.Last()); return selection.Axis switch { AutoTuneAxis.MovieGenre => BuildMoviePlan(selection, members, overridesById), _ => await BuildTvPlan(selection, members, overridesById, cancellationToken) }; } // TV: every base show becomes its own weighted SmartCollection (discriminator-only `show_title`) so // un-weighted shows keep per-show fair-share — a single merged remainder would regress them to // item-proportional (a 200-episode show would swamp a 20-episode one). The remainder is the live // catch-all for shows/episodes added after tune-in, at weight 1. private async Task BuildTvPlan( AutoTuneChannelSelection selection, PagedLibraryBrowseItemsResponseModel members, Dictionary overridesById, CancellationToken cancellationToken) { var weightedMembers = new List(); var subtracted = new List(); // Base shows (title is the discriminator; the members list already carries it). var baseIds = members.Page.Select(i => i.Id).ToHashSet(); foreach (LibraryBrowseItemResponseModel item in members.Page) { AutoTuneSourceWeight ov = overridesById.GetValueOrDefault(item.Id); if (ov is { Excluded: true }) { subtracted.Add(item.Title); continue; } weightedMembers.Add(new WeightedMember( AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, item.Title), NormalizeWeight(ov?.Weight ?? 1))); subtracted.Add(item.Title); } // Added (out-of-axis) shows: resolve the title from metadata since the members list won't include them. List addedIds = overridesById.Keys.Where(id => !baseIds.Contains(id)).ToList(); if (addedIds.Count > 0) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Dictionary titles = (await dbContext.ShowMetadata .AsNoTracking() .Where(sm => addedIds.Contains(sm.ShowId)) .Select(sm => new { sm.ShowId, sm.Title }) .ToListAsync(cancellationToken)) .GroupBy(x => x.ShowId) .ToDictionary(g => g.Key, g => g.First().Title); foreach (int id in addedIds) { AutoTuneSourceWeight ov = overridesById[id]; if (ov.Excluded || !titles.TryGetValue(id, out string title) || string.IsNullOrWhiteSpace(title)) { continue; } weightedMembers.Add(new WeightedMember( AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, title), NormalizeWeight(ov.Weight))); subtracted.Add(title); } } var remainder = new WeightedMember( AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted), 1); return new WeightedPlan(weightedMembers, remainder); } // Movies: materialize only the touched movies (a non-default weight, or an added out-of-axis movie) as // individual `id:{n}` SmartCollections; every un-touched base movie stays in ONE remainder whose weight is // its member count. Because the fake-collection path already pools all movies uniformly, a count-weighted // remainder is exactly equivalent to materializing each movie individually — without hundreds of rows. private static WeightedPlan BuildMoviePlan( AutoTuneChannelSelection selection, PagedLibraryBrowseItemsResponseModel members, Dictionary overridesById) { var weightedMembers = new List(); var subtracted = new List(); var baseIds = members.Page.Select(i => i.Id).ToHashSet(); var subtractedBase = 0; foreach ((int id, AutoTuneSourceWeight ov) in overridesById) { bool inBase = baseIds.Contains(id); string idClause = id.ToString(System.Globalization.CultureInfo.InvariantCulture); if (ov.Excluded) { subtracted.Add(idClause); if (inBase) { subtractedBase++; } continue; } // Materialize weighted base movies and every added (out-of-axis) movie; a base movie left at // weight 1 is cheaper to leave in the remainder (same airtime either way). if (ov.Weight != 1 || !inBase) { weightedMembers.Add(new WeightedMember( AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, idClause), NormalizeWeight(ov.Weight))); subtracted.Add(idClause); if (inBase) { subtractedBase++; } } } // Remainder weight = the un-touched base movie count, so a weighted movie airs N× *each* remainder // movie (the fake path already pools movies uniformly, so this is equivalent to materializing each). // Clamped to MultiCollectionItemWeight.Maximum (1000): a genre with >1000 un-touched movies can't // express the exact ratio (the weighted movie then airs slightly more than intended) — the same // 1..1000 bound #70's weight column imposes everywhere. Realistic only at very large scale. int remainderCount = baseIds.Count - subtractedBase; var remainder = new WeightedMember( AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted), NormalizeWeight(remainderCount)); return new WeightedPlan(weightedMembers, remainder); } private static int NormalizeWeight(int weight) => Math.Clamp(weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum); private async Task CreateWeightedChannel( int effectiveTemplateId, string group, string name, ArtworkContentTypeModel logo, AutoTuneChannelSelection selection, WeightedPlan plan, CancellationToken cancellationToken) { // WeightedShuffle is the whole point; it overrides any axis default / caller Advanced.PlaybackOrder. CreateChannelFromLineupAdvancedOptions advanced = (selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with { PlaybackOrder = PlaybackOrder.WeightedShuffle }; // Short unique token: the channel id isn't known until CreateChannelFromLineup runs, and both // SmartCollection.Name and MultiCollection.Name are unique varchar(50). string token = Guid.NewGuid().ToString("N")[..8]; int multiCollectionId; List smartCollectionIds; await using (TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken)) { var multiCollection = new MultiCollection { Name = $"at-mc:{token}", MultiCollectionItems = [], MultiCollectionSmartItems = [] }; var index = 0; foreach (WeightedMember member in plan.Members.Append(plan.Remainder)) { var smartCollection = new SmartCollection { Name = index == plan.Members.Count ? $"at:{token}:rem" : $"at:{token}:{index}", Query = member.Query }; dbContext.SmartCollections.Add(smartCollection); multiCollection.MultiCollectionSmartItems.Add(new MultiCollectionSmartItem { MultiCollection = multiCollection, SmartCollection = smartCollection, ScheduleAsGroup = false, PlaybackOrder = PlaybackOrder.Shuffle, Weight = member.Weight }); index++; } dbContext.MultiCollections.Add(multiCollection); try { await dbContext.SaveChangesAsync(cancellationToken); } catch (Exception ex) { return new AutoTuneChannelOutcome( name, AutoTuneOutcomeStatus.Failed, null, $"Weighted collections: {ex.Message}"); } multiCollectionId = multiCollection.Id; smartCollectionIds = multiCollection.MultiCollectionSmartItems .Select(i => i.SmartCollectionId) .ToList(); // New smart collections became visible; refresh targets + cache like CreateSmartCollectionHandler // (post-commit, CancellationToken.None so a late cancel can't abort it after the commit landed). searchTargets.SearchTargetsChanged(); await smartCollectionCache.Refresh(CancellationToken.None); } var command = new CreateChannelFromLineup( name, selection.Number, group, string.Empty, logo, IsEnabled: true, ShowInEpg: true, effectiveTemplateId, advanced, [ new CreateChannelFromLineupItem( LibraryBrowseMediaType.MultiCollection, CollectionType.MultiCollection, CollectionId: null, MultiCollectionId: multiCollectionId, SmartCollectionId: null, RerunCollectionId: null, MediaItemId: null, PlaylistId: null) ]); Either channelResult = await mediator.Send(command, cancellationToken); foreach (BaseError error in channelResult.LeftToSeq()) { // Roll back the multi collection + its member smart collections so a retry doesn't collide on // name uniqueness. Best-effort; the outcome below stands regardless of the cleanup result. await TryDeleteOwnedArtifacts(multiCollectionId, smartCollectionIds, cancellationToken); AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal) ? AutoTuneOutcomeStatus.Skipped : AutoTuneOutcomeStatus.Failed; return new AutoTuneChannelOutcome(name, status, null, error.Value); } int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId); // Stamp ownership so the artifacts are hidden from user collection lists and cleaned up on channel // delete. Best-effort: an unstamped artifact is a cosmetic/cleanup issue, never a failed channel. await TryStampOwnership(multiCollectionId, smartCollectionIds, channelId); return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null); } private async Task TryStampOwnership( int multiCollectionId, List smartCollectionIds, int channelId) { try { // Post-commit side effect: runs on CancellationToken.None so a late request cancellation can't // abort it after the channel-create commit landed (#254) — an un-stamped artifact would be a // permanent orphan (never cleaned on delete, and visible in the user collection lists). The MC + // its member smart collections are stamped in one transaction so a mid-way failure can't leave the // MC owned while the smart collections stay orphaned. await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None); await using var transaction = await dbContext.Database.BeginTransactionAsync(CancellationToken.None); await dbContext.MultiCollections .Where(mc => mc.Id == multiCollectionId) .ExecuteUpdateAsync(s => s.SetProperty(mc => mc.OwnedByChannelId, channelId), CancellationToken.None); await dbContext.SmartCollections .Where(sc => smartCollectionIds.Contains(sc.Id)) .ExecuteUpdateAsync(s => s.SetProperty(sc => sc.OwnedByChannelId, channelId), CancellationToken.None); await transaction.CommitAsync(CancellationToken.None); } catch (Exception) { // intentionally ignored; see call site } } private async Task TryDeleteOwnedArtifacts( int multiCollectionId, List smartCollectionIds, CancellationToken cancellationToken) { try { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await dbContext.MultiCollections .Where(mc => mc.Id == multiCollectionId) .ExecuteDeleteAsync(cancellationToken); await dbContext.SmartCollections .Where(sc => smartCollectionIds.Contains(sc.Id)) .ExecuteDeleteAsync(cancellationToken); searchTargets.SearchTargetsChanged(); await smartCollectionCache.Refresh(CancellationToken.None); } catch (Exception) { // intentionally ignored; see call site } } }