Files
ersatztv/ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs
T
timothy f31476e012 fix(69): auto-tune review fixes — null channels, orphaned SmartCollection, oversized preview names, doc drift
- 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
2026-07-16 22:18:52 +02:00

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();
}
}