using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using LanguageExt; using MediatR; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Channels; public class PreviewAutoTuneChannelsHandler(IDbContextFactory dbContextFactory) : IRequestHandler>> { public async Task>> Handle( PreviewAutoTuneChannels request, CancellationToken cancellationToken) { if (request.Axes is null || request.Axes.Count == 0) { return BaseError.New("At least one axis is required"); } if (request.MinItems < 1) { return BaseError.New("Minimum items must be at least 1"); } if (request.StartingNumber < 1) { return BaseError.New("Starting channel number must be at least 1"); } await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); // Enumerate (axis, value, count) triples per requested axis, preserving axis order. var raw = new List<(AutoTuneAxis Axis, string Value, int Count)>(); foreach (AutoTuneAxis axis in request.Axes.Distinct()) { raw.AddRange(await EnumerateAxis(dbContext, axis, request.MinItems, cancellationToken)); } System.Collections.Generic.HashSet existingNumbers = (await dbContext.Channels.AsNoTracking() .Select(c => c.Number).ToListAsync(cancellationToken)) .ToHashSet(); System.Collections.Generic.HashSet existingNames = (await dbContext.Channels.AsNoTracking() .Select(c => c.Name).ToListAsync(cancellationToken)) .ToHashSet(StringComparer.OrdinalIgnoreCase); // Drop entries whose generated name would be rejected at create time (Channel name <= 50 // chars) before number allocation, so numbers aren't wasted on proposals that can never // be created. List<(AutoTuneAxis Axis, string Value, int Count, string Name)> survivors = raw .Select(r => (r.Axis, r.Value, r.Count, Name: AutoTuneAxisMap.GenerateName(r.Axis, r.Value))) .Where(r => r.Name.Length <= 50) .ToList(); List numbers = AutoTuneNumberAllocator.Allocate( request.StartingNumber, survivors.Count, existingNumbers); var proposals = new List(survivors.Count); for (int i = 0; i < survivors.Count; i++) { (AutoTuneAxis axis, string value, int count, string name) = survivors[i]; proposals.Add(new AutoTuneProposal( axis, value, name, numbers[i], count, existingNames.Contains(name))); } return proposals; } private static async Task> EnumerateAxis( TvContext dbContext, AutoTuneAxis axis, int minItems, CancellationToken cancellationToken) => axis switch { AutoTuneAxis.TvShow => await EnumerateTvShows(dbContext, minItems, cancellationToken), AutoTuneAxis.TvGenre => await EnumerateEpisodeGenres(dbContext, minItems, cancellationToken), AutoTuneAxis.MovieGenre => await EnumerateMovieGenres(dbContext, minItems, cancellationToken), _ => [] }; private static async Task> EnumerateTvShows( TvContext dbContext, int minItems, CancellationToken cancellationToken) { // Episode count per show id (Episode -> Season -> ShowId). Proven query style from LibraryBrowseItemMapper. Dictionary episodeCounts = await dbContext.Episodes.AsNoTracking() .GroupBy(e => e.Season.ShowId) .Select(g => new { ShowId = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken); var showTitles = await dbContext.ShowMetadata.AsNoTracking() .Select(sm => new { sm.ShowId, sm.Title }) .ToListAsync(cancellationToken); // Collapse shows that share a title (the generated show_title query matches them together). var byTitle = new Dictionary(); foreach (var row in showTitles) { if (string.IsNullOrWhiteSpace(row.Title)) { continue; } episodeCounts.TryGetValue(row.ShowId, out int count); byTitle[row.Title] = byTitle.GetValueOrDefault(row.Title) + count; } return byTitle .Where(kv => kv.Value >= minItems) .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) .Select(kv => (AutoTuneAxis.TvShow, kv.Key, kv.Value)) .ToList(); } private static async Task> EnumerateEpisodeGenres( TvContext dbContext, int minItems, CancellationToken cancellationToken) { var counts = await dbContext.EpisodeMetadata.AsNoTracking() .SelectMany(m => m.Genres) .GroupBy(g => g.Name) .Select(grp => new { Name = grp.Key, Count = grp.Count() }) .ToListAsync(cancellationToken); return counts .Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems) .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) .Select(c => (AutoTuneAxis.TvGenre, c.Name, c.Count)) .ToList(); } private static async Task> EnumerateMovieGenres( TvContext dbContext, int minItems, CancellationToken cancellationToken) { var counts = await dbContext.MovieMetadata.AsNoTracking() .SelectMany(m => m.Genres) .GroupBy(g => g.Name) .Select(grp => new { Name = grp.Key, Count = grp.Count() }) .ToListAsync(cancellationToken); return counts .Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems) .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) .Select(c => (AutoTuneAxis.MovieGenre, c.Name, c.Count)) .ToList(); } }