- CreateAutoTunedChannelsRequest.ToCommand(): guard null Channels (was NREing on a request body that omits "channels", causing HTTP 500). - CreateAutoTunedChannelsHandler.CreateOne: when CreateChannelFromLineup returns Left (Skipped/Failed), roll back the just-created SmartCollection via DeleteSmartCollection so retries don't fail on SmartCollection-name uniqueness. Best-effort; the delete result does not change the outcome. - PreviewAutoTuneChannelsHandler: filter out proposals whose generated name exceeds the 50-char Channel.Name limit before number allocation, so numbers aren't wasted on proposals that can never be created. - docs/superpowers/specs/2026-07-16-auto-tuning-design.md: fix field-name drift in JSON examples (proposedNumber -> number, error -> reason) to match the actual AutoTuneProposal/AutoTuneChannelOutcome DTOs. Refs #69
145 lines
6.1 KiB
C#
145 lines
6.1 KiB
C#
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<TvContext> dbContextFactory)
|
|
: IRequestHandler<PreviewAutoTuneChannels, Either<BaseError, List<AutoTuneProposal>>>
|
|
{
|
|
public async Task<Either<BaseError, List<AutoTuneProposal>>> 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<string> existingNumbers = (await dbContext.Channels.AsNoTracking()
|
|
.Select(c => c.Number).ToListAsync(cancellationToken))
|
|
.ToHashSet();
|
|
System.Collections.Generic.HashSet<string> 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<string> numbers = AutoTuneNumberAllocator.Allocate(
|
|
request.StartingNumber, survivors.Count, existingNumbers);
|
|
|
|
var proposals = new List<AutoTuneProposal>(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<List<(AutoTuneAxis, string, int)>> 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<List<(AutoTuneAxis, string, int)>> EnumerateTvShows(
|
|
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
|
{
|
|
// Episode count per show id (Episode -> Season -> ShowId). Proven query style from LibraryBrowseItemMapper.
|
|
Dictionary<int, int> 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<string, int>();
|
|
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<List<(AutoTuneAxis, string, int)>> 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<List<(AutoTuneAxis, string, int)>> 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();
|
|
}
|
|
}
|