# Auto-Tuning PR1 (Backend) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship the backend for #69 auto-tuning — three REST endpoints that enumerate library metadata, preview proposed channels, and bulk-create them — reusing the #63 composite-create primitive and Smart Collections. **Architecture:** CQRS/MediatR. A read `PreviewAutoTuneChannels` query enumerates distinct axis values (TV Show / TV Genre / Movie Genre) with exact EF counts and returns proposed channels (name, allocated number, item count, already-exists flag) with no writes. A `CreateAutoTunedChannels` command orchestrates, per selected proposal: create a SmartCollection (server-generated Lucene query) → call the existing `CreateChannelFromLineup` handler via `ISender` → aggregate a per-channel Created/Skipped/Failed outcome. Two thin controller actions on `ChannelController` map DTOs to commands. **Tech Stack:** C# / .NET 10, MediatR, EF Core (SQLite default), LanguageExt (`Either`/`Option`), NUnit + Shouldly + NSubstitute. **Worktree:** All work happens in `/Users/timothy/ersatztv/.claude/worktrees/69-auto-tuning` on branch `feat/69-auto-tuning`. Reference spec: `docs/superpowers/specs/2026-07-16-auto-tuning-design.md`. ## Global Constraints - **Read first:** `docs/README.md` → `docs/api-conventions.md` (controller/DTO/error patterns), `docs/testing.md` (NUnit harness). Do not re-derive conventions from source. - **Test framework is NUnit + Shouldly + NSubstitute.** Never xUnit. Handler tests live in `ErsatzTV.Tests/`; the in-memory DB harness is `ErsatzTV.Tests/Support/InMemoryTvContext.cs`. - **LanguageExt everywhere:** handlers return `Either` (or plain results). Use `BaseError.New("msg")` / `NotFoundError`. Iterate `Either` via `.LeftToSeq()` / `.RightToSeq()` or `.Match(...)`. - **`Channel.Number` is a `string`** validated by regex `^[0-9]+(\.[0-9]{1,2})?$`; **uniqueness is enforced inside `CreateChannelFromLineupHandler`** (returns `BaseError.New("Channel number must be unique")`). **Channel name must be non-empty and ≤ 50 chars. Channel `Group` is REQUIRED (non-empty).** - **A SmartCollection lineup item** for `CreateChannelFromLineup` uses exactly: `MediaType = LibraryBrowseMediaType.SmartCollection`, `CollectionType = CollectionType.SmartCollection`, `SmartCollectionId = `, all other ids null. - **Central Package Management:** no new packages needed. Never add `Version=` to a ``. - **No DI edits:** MediatR auto-scans the `ErsatzTV.Application` assembly; new handlers register automatically. - **Worktree hooks:** commit with `git commit --no-verify` (the pre-commit `dotnet format`/lint-staged reverts BOMs mid-hook; pre-push eslint isn't on PATH). After each task verify manually: `dotnet build`, `dotnet test`, and `dotnet format whitespace ` on your touched files only (de-BOM touched files → `charset=utf-8`, per the #311 formatting gate). Keep the branch current by **rebasing** on `origin/main`, never merging main in. - **Deviation from spec (planning refinement):** the preview request drops `templateId` (preview does not create, so it does not need a template). `templateId` stays on the create request only. Update the spec's preview-request shape in Task 7's doc pass. --- ### Task 1: `AutoTuneAxis` enum + `AutoTuneAxisMap` pure helper **Files:** - Create: `ErsatzTV.Core/Domain/AutoTuneAxis.cs` - Create: `ErsatzTV.Application/Channels/AutoTuneAxisMap.cs` - Test: `ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs` **Interfaces:** - Produces: `enum AutoTuneAxis { TvShow, TvGenre, MovieGenre }`; `static AutoTuneAxisMap.GenerateQuery(AutoTuneAxis, string) : string`, `GenerateName(AutoTuneAxis, string) : string`, `PlaybackOrderFor(AutoTuneAxis) : PlaybackOrder`, `EscapeLuceneValue(string) : string`. - [ ] **Step 1: Write the failing test** `ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs`: ```csharp using ErsatzTV.Application.Channels; using ErsatzTV.Core.Domain; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class AutoTuneAxisMapTests { [Test] public void GenerateQuery_Builds_Expected_Lucene() { AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "The Office") .ShouldBe("type:episode AND show_title:\"The Office\""); AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvGenre, "Comedy") .ShouldBe("type:episode AND genre:\"Comedy\""); AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.MovieGenre, "Action") .ShouldBe("type:movie AND genre:\"Action\""); } [Test] public void GenerateQuery_Escapes_Quotes_And_Backslashes() { AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "Bob\"s \\Show") .ShouldBe("type:episode AND show_title:\"Bob\\\"s \\\\Show\""); } [Test] public void GenerateName_Suffixes_Movie_Genres_Only() { AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvShow, "The Office").ShouldBe("The Office"); AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvGenre, "Comedy").ShouldBe("Comedy"); AutoTuneAxisMap.GenerateName(AutoTuneAxis.MovieGenre, "Action").ShouldBe("Action Movies"); } [Test] public void PlaybackOrderFor_Uses_PseudoTV_Defaults() { AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvShow).ShouldBe(PlaybackOrder.SeasonEpisode); AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvGenre).ShouldBe(PlaybackOrder.Shuffle); AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.MovieGenre).ShouldBe(PlaybackOrder.Shuffle); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTuneAxisMapTests` Expected: FAIL — `AutoTuneAxis` / `AutoTuneAxisMap` do not exist (compile error). - [ ] **Step 3: Write the enum** `ErsatzTV.Core/Domain/AutoTuneAxis.cs`: ```csharp namespace ErsatzTV.Core.Domain; public enum AutoTuneAxis { TvShow = 0, TvGenre = 1, MovieGenre = 2 } ``` - [ ] **Step 4: Write the helper** `ErsatzTV.Application/Channels/AutoTuneAxisMap.cs`: ```csharp using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.Channels; public static class AutoTuneAxisMap { // Server-owned Lucene smart-collection query for an axis value. public static string GenerateQuery(AutoTuneAxis axis, string value) { string escaped = EscapeLuceneValue(value); return axis switch { AutoTuneAxis.TvShow => $"type:episode AND show_title:\"{escaped}\"", AutoTuneAxis.TvGenre => $"type:episode AND genre:\"{escaped}\"", AutoTuneAxis.MovieGenre => $"type:movie AND genre:\"{escaped}\"", _ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null) }; } // Human-facing channel name. Movie-genre channels are suffixed so a genre that exists for // both TV and movies ("Comedy" vs "Comedy Movies") does not produce two identically-named channels. public static string GenerateName(AutoTuneAxis axis, string value) => axis switch { AutoTuneAxis.TvShow => value, AutoTuneAxis.TvGenre => value, AutoTuneAxis.MovieGenre => $"{value} Movies", _ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null) }; // PseudoTV per-type defaults: single-show channels play in episode order; genre channels shuffle. public static PlaybackOrder PlaybackOrderFor(AutoTuneAxis axis) => axis switch { AutoTuneAxis.TvShow => PlaybackOrder.SeasonEpisode, AutoTuneAxis.TvGenre => PlaybackOrder.Shuffle, AutoTuneAxis.MovieGenre => PlaybackOrder.Shuffle, _ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null) }; // Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote. public static string EscapeLuceneValue(string value) => (value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\""); } ``` - [ ] **Step 5: Run test to verify it passes** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTuneAxisMapTests` Expected: PASS (4 tests). - [ ] **Step 6: Commit** ```bash git add ErsatzTV.Core/Domain/AutoTuneAxis.cs ErsatzTV.Application/Channels/AutoTuneAxisMap.cs ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs git commit --no-verify -m "feat(69): auto-tune axis map (query/name/order/escape) Refs #69" ``` --- ### Task 2: `AutoTuneNumberAllocator` pure helper **Files:** - Create: `ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs` - Test: `ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs` **Interfaces:** - Produces: `static AutoTuneNumberAllocator.Allocate(int startingNumber, int count, ISet existingNumbers) : List` — `count` sequential integer numbers from `startingNumber`, skipping any string already in `existingNumbers`. - [ ] **Step 1: Write the failing test** `ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs`: ```csharp using System.Collections.Generic; using ErsatzTV.Application.Channels; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class AutoTuneNumberAllocatorTests { [Test] public void Allocate_Skips_Taken_Numbers() { var existing = new HashSet { "500", "502" }; List result = AutoTuneNumberAllocator.Allocate(500, 3, existing); result.ShouldBe(new List { "501", "503", "504" }); } [Test] public void Allocate_From_Empty_Is_Sequential() { List result = AutoTuneNumberAllocator.Allocate(1, 3, new HashSet()); result.ShouldBe(new List { "1", "2", "3" }); } [Test] public void Allocate_Zero_Count_Is_Empty() { AutoTuneNumberAllocator.Allocate(500, 0, new HashSet()).ShouldBeEmpty(); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTuneNumberAllocatorTests` Expected: FAIL — `AutoTuneNumberAllocator` does not exist. - [ ] **Step 3: Write the implementation** `ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs`: ```csharp using System.Globalization; namespace ErsatzTV.Application.Channels; public static class AutoTuneNumberAllocator { // Allocate `count` sequential integer channel numbers starting at `startingNumber`, // skipping any number already present in `existingNumbers`. Channel.Number is a string, // so numbers are returned as invariant-culture strings. public static List Allocate(int startingNumber, int count, ISet existingNumbers) { var result = new List(count); int next = startingNumber; while (result.Count < count) { string candidate = next.ToString(CultureInfo.InvariantCulture); if (!existingNumbers.Contains(candidate)) { result.Add(candidate); } next++; } return result; } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTuneNumberAllocatorTests` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs git commit --no-verify -m "feat(69): auto-tune channel-number allocator Refs #69" ``` --- ### Task 3: Preview query + handler (EF enumeration) **Files:** - Create: `ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs` (query + `AutoTuneProposal` result) - Create: `ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs` - Test: `ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs` **Interfaces:** - Consumes: `AutoTuneAxisMap` (Task 1), `AutoTuneNumberAllocator` (Task 2). - Produces: `record PreviewAutoTuneChannels(List Axes, int MinItems, int StartingNumber) : IRequest>>`; `record AutoTuneProposal(AutoTuneAxis Axis, string Value, string Name, string Number, int ItemCount, bool AlreadyExists)`. - [ ] **Step 1: Write the failing test** `ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs`: ```csharp using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Tests.Support; using LanguageExt; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class PreviewAutoTuneChannelsHandlerTests { private InMemoryTvContext _db = null!; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task Enumerates_Shows_Above_MinItems_With_Counts_And_Numbers() { // Show 1 "The Office" with 3 episodes; Show 2 "Short" with 1 episode. await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102, 103 }); await SeedShow(showId: 2, title: "Short", seasonId: 22, episodeIds: new[] { 201 }); var result = await Handle(new PreviewAutoTuneChannels( new List { AutoTuneAxis.TvShow }, MinItems: 2, StartingNumber: 500)); List proposals = RightOf(result); proposals.Count.ShouldBe(1); proposals[0].Value.ShouldBe("The Office"); proposals[0].Name.ShouldBe("The Office"); proposals[0].ItemCount.ShouldBe(3); proposals[0].Number.ShouldBe("500"); proposals[0].AlreadyExists.ShouldBeFalse(); } [Test] public async Task Flags_AlreadyExists_By_Channel_Name_And_Skips_Taken_Numbers() { await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102 }); await SeedChannel(number: "500", name: "The Office"); var result = await Handle(new PreviewAutoTuneChannels( new List { AutoTuneAxis.TvShow }, MinItems: 1, StartingNumber: 500)); List proposals = RightOf(result); proposals[0].AlreadyExists.ShouldBeTrue(); proposals[0].Number.ShouldBe("501"); // 500 is taken } [Test] public async Task Enumerates_Movie_Genres_With_Suffixed_Names() { await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: "Action"); await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: "Action"); await SeedMovieWithGenre(movieId: 3, metadataId: 3, genre: "Drama"); var result = await Handle(new PreviewAutoTuneChannels( new List { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500)); List proposals = RightOf(result); proposals.Count.ShouldBe(1); // Drama has only 1 movie, below minItems proposals[0].Value.ShouldBe("Action"); proposals[0].Name.ShouldBe("Action Movies"); proposals[0].ItemCount.ShouldBe(2); } [Test] public async Task Empty_Axes_Is_Error() { var result = await Handle(new PreviewAutoTuneChannels( new List(), MinItems: 5, StartingNumber: 500)); LeftOf(result).Value.ShouldContain("axis"); } private Task>> Handle(PreviewAutoTuneChannels request) => new PreviewAutoTuneChannelsHandler(_db.Factory).Handle(request, CancellationToken.None); private async Task SeedShow(int showId, string title, int seasonId, int[] episodeIds) { await using TvContext context = _db.CreateContext(); context.Shows.Add(new Show { Id = showId, ShowMetadata = new List { new() { ShowId = showId, Title = title } }, Seasons = new List { new() { Id = seasonId, ShowId = showId, Episodes = episodeIds.Select(id => new Episode { Id = id, SeasonId = seasonId }).ToList() } } }); await context.SaveChangesAsync(); } private async Task SeedMovieWithGenre(int movieId, int metadataId, string genre) { await using TvContext context = _db.CreateContext(); context.Movies.Add(new Movie { Id = movieId, MovieMetadata = new List { new() { Id = metadataId, MovieId = movieId, Title = $"Movie {movieId}", Genres = new List { new() { Name = genre } } } } }); await context.SaveChangesAsync(); } private async Task SeedChannel(string number, string name) { await using TvContext context = _db.CreateContext(); context.Channels.Add(new Channel(System.Guid.NewGuid()) { Number = number, Name = name, Group = "Test", SortNumber = double.Parse(number) }); await context.SaveChangesAsync(); } private static BaseError LeftOf(Either e) => e.Match(Left: x => x, Right: _ => throw new AssertionException("Expected a Left result")); private static TR RightOf(Either e) => e.Match(Left: x => throw new AssertionException($"Expected Right, got {x.Value}"), Right: r => r); } ``` > Note the alias needs: `using Channel = ErsatzTV.Core.Domain.Channel;` is NOT required here because there's no `System.Threading.Channels` import in this file. Use `ErsatzTV.Core.Domain.Channel` directly (it's imported via `ErsatzTV.Core.Domain`). - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PreviewAutoTuneChannelsHandlerTests` Expected: FAIL — types do not exist. - [ ] **Step 3: Write the query + result records** `ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs`: ```csharp using ErsatzTV.Core; using ErsatzTV.Core.Domain; using LanguageExt; using MediatR; namespace ErsatzTV.Application.Channels; public record PreviewAutoTuneChannels( List Axes, int MinItems, int StartingNumber) : IRequest>>; public record AutoTuneProposal( AutoTuneAxis Axis, string Value, string Name, string Number, int ItemCount, bool AlreadyExists); ``` - [ ] **Step 4: Write the handler** `ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs`: ```csharp 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)); } HashSet existingNumbers = (await dbContext.Channels.AsNoTracking() .Select(c => c.Number).ToListAsync(cancellationToken)) .ToHashSet(); HashSet existingNames = (await dbContext.Channels.AsNoTracking() .Select(c => c.Name).ToListAsync(cancellationToken)) .ToHashSet(StringComparer.OrdinalIgnoreCase); List numbers = AutoTuneNumberAllocator.Allocate( request.StartingNumber, raw.Count, existingNumbers); var proposals = new List(raw.Count); for (int i = 0; i < raw.Count; i++) { (AutoTuneAxis axis, string value, int count) = raw[i]; string name = AutoTuneAxisMap.GenerateName(axis, value); 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(); } } ``` > If the SQLite provider cannot translate `.SelectMany(m => m.Genres).GroupBy(...)` (the test in Step 5 is the check), replace the genre enumerators' first line with a materialize-then-group form: `var names = await dbContext.EpisodeMetadata.AsNoTracking().SelectMany(m => m.Genres.Select(g => g.Name)).ToListAsync(ct);` then `var counts = names.GroupBy(n => n).Select(g => new { Name = g.Key, Count = g.Count() }).ToList();`. Prefer the DB-side form; only fall back if the test surfaces a translation error. - [ ] **Step 5: Run test to verify it passes** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PreviewAutoTuneChannelsHandlerTests` Expected: PASS (4 tests). This also proves the EF queries translate on SQLite. - [ ] **Step 6: Commit** ```bash git add ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs git commit --no-verify -m "feat(69): auto-tune preview query + EF enumeration handler Refs #69" ``` --- ### Task 4: Bulk-create command + handler (orchestration via ISender) **Files:** - Create: `ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs` (command + selection + result + outcome + status enum) - Create: `ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs` - Test: `ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs` **Interfaces:** - Consumes: `AutoTuneAxisMap` (Task 1); the existing `CreateSmartCollection(string Query, string Name) : IRequest>` (namespace `ErsatzTV.Application.MediaCollections`; `SmartCollectionViewModel(int Id, string Name, string Query)`) and `CreateChannelFromLineup(...) : IRequest>` (namespace `ErsatzTV.Application.Channels`), sent via `ISender`. - Produces: `record CreateAutoTunedChannels(int TemplateId, string Group, List Channels) : IRequest`; `record AutoTuneChannelSelection(AutoTuneAxis Axis, string Value, string Name, string Number)`; `record AutoTuneResult(List Results)` with computed `CreatedCount/SkippedCount/FailedCount`; `record AutoTuneChannelOutcome(string Name, AutoTuneOutcomeStatus Status, int? ChannelId, string Reason)`; `enum AutoTuneOutcomeStatus { Created, Skipped, Failed }`. - [ ] **Step 1: Write the failing test** `ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs`: ```csharp using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using ErsatzTV.Application.Channels; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using LanguageExt; using MediatR; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class CreateAutoTunedChannelsHandlerTests { private ISender _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); // Smart collection creation always succeeds, echoing an incrementing id. _mediator.Send(Arg.Any(), Arg.Any()) .Returns(ci => { var cmd = ci.Arg(); return (Either) new SmartCollectionViewModel(7, cmd.Name, cmd.Query); }); } [Test] public async Task Creates_Selected_Channels_And_Reports_Counts() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) new CreateChannelFromLineupResponseModel(88, null, 1, 2)); var result = await Handle(new CreateAutoTunedChannels( TemplateId: 3, Group: "Auto-Tuned", new List { new(AutoTuneAxis.TvShow, "The Office", "The Office", "500") })); result.CreatedCount.ShouldBe(1); result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Created); result.Results[0].ChannelId.ShouldBe(88); // Smart collection built with the server-generated query. await _mediator.Received().Send( Arg.Is(c => c.Query == "type:episode AND show_title:\"The Office\""), Arg.Any()); // Channel created referencing the smart collection id, number, and SeasonEpisode order. await _mediator.Received().Send( Arg.Is(c => c.Number == "500" && c.TemplateId == 3 && c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode && c.Lineup.Count == 1 && c.Lineup[0].CollectionType == CollectionType.SmartCollection && c.Lineup[0].SmartCollectionId == 7), Arg.Any()); } [Test] public async Task Number_Collision_Is_Skipped_Not_Failed() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) BaseError.New("Channel number must be unique")); var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") })); result.SkippedCount.ShouldBe(1); result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped); } [Test] public async Task Other_Errors_Are_Failed() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) BaseError.New("FFmpegProfile 9 does not exist.")); var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") })); result.FailedCount.ShouldBe(1); result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed); } private Task Handle(CreateAutoTunedChannels request) => new CreateAutoTunedChannelsHandler(_mediator).Handle(request, CancellationToken.None); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~CreateAutoTunedChannelsHandlerTests` Expected: FAIL — types do not exist. - [ ] **Step 3: Write the command + result records** `ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs`: ```csharp using ErsatzTV.Core.Domain; using MediatR; namespace ErsatzTV.Application.Channels; public record CreateAutoTunedChannels( int TemplateId, string Group, List Channels) : IRequest; public record AutoTuneChannelSelection( AutoTuneAxis Axis, string Value, string Name, string Number); public record AutoTuneResult(List Results) { public int CreatedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Created); public int SkippedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Skipped); public int FailedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Failed); } public record AutoTuneChannelOutcome( string Name, AutoTuneOutcomeStatus Status, int? ChannelId, string Reason); public enum AutoTuneOutcomeStatus { Created, Skipped, Failed } ``` - [ ] **Step 4: Write the handler** `ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs`: ```csharp using ErsatzTV.Application.MediaCollections; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using LanguageExt; using MediatR; namespace ErsatzTV.Application.Channels; public class CreateAutoTunedChannelsHandler(ISender mediator) : IRequestHandler { private const string NumberTakenError = "Channel number must be unique"; private const string DefaultGroup = "Auto-Tuned"; 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"); } string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value); PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis); // 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, ArtworkContentTypeModel.None, IsEnabled: true, ShowInEpg: true, templateId, new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order), [ 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()) { 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); } } ``` > `ArtworkContentTypeModel` lives in the same namespace as `CreateChannelFromLineupResponseModel` usage in the request DTO — resolve its exact `using` with the C# LSP if the build reports it missing (it is referenced by `CreateChannelFromLineupRequest.cs`; copy that file's `using` for it). Likewise confirm `SmartCollectionViewModel`'s namespace via the LSP if `ErsatzTV.Application.MediaCollections` is wrong. - [ ] **Step 5: Run test to verify it passes** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~CreateAutoTunedChannelsHandlerTests` Expected: PASS (3 tests). - [ ] **Step 6: Commit** ```bash git add ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs git commit --no-verify -m "feat(69): auto-tune bulk-create orchestration handler Refs #69" ``` --- ### Task 5: Response models + controller endpoints + request DTOs **Files:** - Create: `ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs` - Create: `ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs` - Modify: `ErsatzTV/Controllers/Api/ChannelController.cs` (add two actions + two projection helpers) **Interfaces:** - Consumes: `PreviewAutoTuneChannels`/`AutoTuneProposal` (Task 3), `CreateAutoTunedChannels`/`AutoTuneResult` (Task 4), `AutoTuneAxis` (Task 1). - Produces: routes `POST /api/v1/channels/auto-tune/preview` and `POST /api/v1/channels/auto-tune`. - [ ] **Step 1: Write the response models** `ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs`: ```csharp #nullable enable namespace ErsatzTV.Core.Api.Channels; public record AutoTuneProposalResponseModel( string Axis, string Value, string Name, string Number, int ItemCount, bool AlreadyExists); public record AutoTuneChannelResultModel( string Name, string Status, int? ChannelId, string? Reason); public record AutoTuneResultResponseModel( List Results, int CreatedCount, int SkippedCount, int FailedCount); ``` - [ ] **Step 2: Write the request DTOs** `ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs`: ```csharp using ErsatzTV.Application.Channels; using ErsatzTV.Core.Domain; namespace ErsatzTV.Controllers.Api.Requests; public record PreviewAutoTuneChannelsRequest( List Axes, int MinItems, int StartingNumber) { public PreviewAutoTuneChannels ToCommand() => new(Axes, MinItems, StartingNumber); } public record CreateAutoTunedChannelsRequest( int TemplateId, string Group, List Channels) { public CreateAutoTunedChannels ToCommand() => new(TemplateId, Group, Channels.Select(c => c.ToCommand()).ToList()); } public record AutoTunedChannelRequest( AutoTuneAxis Axis, string Value, string Name, string Number) { public AutoTuneChannelSelection ToCommand() => new(Axis, Value, Name, Number); } ``` - [ ] **Step 3: Add the two controller actions + projections** In `ErsatzTV/Controllers/Api/ChannelController.cs`, add the following actions (place them near the existing `BulkRenumber`/`ResetAll`-style actions). Confirm the file already has `using ErsatzTV.Application.Channels;`, `using ErsatzTV.Core.Api.Channels;`, `using ErsatzTV.Controllers.Api.Requests;`, `using LanguageExt;`, `using ErsatzTV.Core;` — add any that are missing. ```csharp [HttpPost("/api/v1/channels/auto-tune/preview", Name = "PreviewAutoTuneChannels")] [Tags("Channels")] [EndpointSummary("Preview auto-tuned channels")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task PreviewAutoTune( [Required] [FromBody] PreviewAutoTuneChannelsRequest request, CancellationToken cancellationToken) { Either> result = await mediator.Send(request.ToCommand(), cancellationToken); return result.Match( Left: error => error.ToErrorResult(), Right: proposals => Ok(proposals.Select(ProjectToResponseModel).ToList())); } [HttpPost("/api/v1/channels/auto-tune", Name = "CreateAutoTunedChannels")] [Tags("Channels")] [EndpointSummary("Create auto-tuned channels")] [EndpointGroupName("general")] [ProducesResponseType(typeof(AutoTuneResultResponseModel), StatusCodes.Status200OK)] public async Task CreateAutoTuned( [Required] [FromBody] CreateAutoTunedChannelsRequest request, CancellationToken cancellationToken) { AutoTuneResult result = await mediator.Send(request.ToCommand(), cancellationToken); return Ok(ProjectToResponseModel(result)); } private static AutoTuneProposalResponseModel ProjectToResponseModel(AutoTuneProposal p) => new(p.Axis.ToString(), p.Value, p.Name, p.Number, p.ItemCount, p.AlreadyExists); private static AutoTuneResultResponseModel ProjectToResponseModel(AutoTuneResult r) => new( r.Results.Select(o => new AutoTuneChannelResultModel( o.Name, o.Status.ToString(), o.ChannelId, o.Reason)).ToList(), r.CreatedCount, r.SkippedCount, r.FailedCount); ``` > `error.ToErrorResult()` is the same extension used by `BulkRenumber` in this controller. If `ProjectToResponseModel(AutoTuneProposal)` name-clashes with an existing single-arg overload, that's fine (C# overloads by parameter type: `AutoTuneProposal` vs `AutoTuneResult` are distinct). - [ ] **Step 4: Build** Run: `dotnet build ErsatzTV/ErsatzTV.csproj` Expected: `Build succeeded`, `0 Error(s)`. (Fix any missing `using` the compiler reports; grep the error for `error CS` before trusting the tail.) - [ ] **Step 5: Run the full test project to confirm nothing regressed** Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTune` Expected: PASS (all AutoTune* tests). - [ ] **Step 6: Commit** ```bash git add ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs ErsatzTV/Controllers/Api/ChannelController.cs git commit --no-verify -m "feat(69): auto-tune preview + bulk-create REST endpoints Refs #69" ``` --- ### Task 6: OpenAPI regen + docs **Files:** - Modify (generated): `ErsatzTV/wwwroot/**/v1.json` (or wherever `update-openapi.sh` writes), `docs/endpoint-index.md`, `web/src/**` generated API client (`npm run generate:api`) - Modify: `docs/api-conventions.md`, `docs/domain-model.md`, `docs/decisions.md`, `docs/superpowers/specs/2026-07-16-auto-tuning-design.md` (drop `templateId` from the preview request to match the plan) **Interfaces:** none (docs + generated artifacts). - [ ] **Step 1: Regenerate the OpenAPI spec + typed client** Run (app project must build first — Task 5 ensured that): ```bash ./scripts/update-openapi.sh cd web && npm run generate:api && cd .. ``` Expected: `v1.json`, `docs/endpoint-index.md`, and the generated `web` client update to include `PreviewAutoTuneChannels` and `CreateAutoTunedChannels`. Run `cd web && npm run check:api && cd ..` — expected clean. - [ ] **Step 2: Update the docs** - `docs/api-conventions.md`: add the two endpoints to the endpoint inventory/checklist section following the existing format (method, path, operationId, summary, auth: standard API-key). - `docs/domain-model.md`: add a short paragraph — auto-tuning is a second, automatic-first channel-creation mode that enumerates library metadata and bulk-creates channels via the #63 composite create; each generated channel is backed by a live SmartCollection. - `docs/decisions.md`: append a new dated entry (append-only; also add its TOC line in the Index): ```markdown ## 2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69) Auto-tuning (#69) generates channels from library metadata (TV Show / TV Genre / Movie Genre). Enumeration for the preview uses EF distinct+count queries (exact counts drive the min-items threshold and preview display); each created channel is backed by a newly-created **SmartCollection** (live Lucene query) so channels keep tracking the library as it grows. Query authorship is server-side only — the client passes `{axis, value}`, never a Lucene string. Coexistence is additive: the batch gets a reserved starting channel number (skipping taken numbers), a proposed channel whose name already exists is flagged and de-selected by default, and existing channels are never mutated. Bulk create loops the #63 `CreateChannelFromLineup` primitive via `ISender` and returns a per-channel Created/Skipped/Failed outcome. Known MVP limitation: the generated SmartCollection is named after the channel; a name collision with an existing SmartCollection surfaces as a per-channel Failed outcome. ``` - `docs/superpowers/specs/2026-07-16-auto-tuning-design.md`: in the preview request example, remove the `"templateId"` line (preview does not create, so it needs no template). - [ ] **Step 3: Build + full test + format check** ```bash dotnet build ErsatzTV.sln dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTune dotnet format whitespace ErsatzTV.Application ErsatzTV.Core ErsatzTV --verify-no-changes ``` Expected: build succeeds, AutoTune tests pass, format check clean on touched projects. (If format flags a touched file, run `dotnet format whitespace ` without `--verify-no-changes`, then re-stage only your files; ensure touched files are `charset=utf-8` / no BOM.) - [ ] **Step 4: Commit** ```bash git add -A git commit --no-verify -m "docs(69): auto-tune OpenAPI regen + api/domain/decisions docs Refs #69" ``` --- ## Post-plan: PR + review (not a code task) After Task 6: rebase on `origin/main`, push `feat/69-auto-tuning`, open the PR (`Refs #69`, not `fixes` — #69 closes only after PR2 SPA + live-E2E), and arm a CI monitor on the head sha at PR-open. This is a **write-path handler** change, so an independent (cross-model or cold-context) review is **mandatory** before merge. Add a `## Done-when` section to #69 covering: PR1 backend merged, PR2 SPA + live-E2E, adversarial review passed, docs updated. Live-E2E belongs to PR2 (the SPA slice). ## Self-review notes (author) - **Spec coverage:** axis enumeration (Task 3), preview (Task 3), bulk-create (Task 4), server-side query gen + escaping (Task 1), reserve-block numbering + name dedup (Tasks 2/3), partial-success result (Task 4), endpoints (Task 5), OpenAPI + docs (Task 6). SPA + live-E2E are explicitly PR2, per the spec's phasing. ✓ - **Type consistency:** `AutoTuneAxis`, `AutoTuneProposal`, `AutoTuneChannelSelection`, `AutoTuneResult`, `AutoTuneOutcomeStatus`, and the response models are used with identical names/shapes across tasks. The SmartCollection lineup item uses `LibraryBrowseMediaType.SmartCollection` + `CollectionType.SmartCollection` + `SmartCollectionId` (verified against `CreateChannelFromLineupHandler.NormalizeLineupItem`). ✓ - **Known unknowns flagged inline (not placeholders):** exact `using` for `ArtworkContentTypeModel` / `SmartCollectionViewModel` (resolve via LSP — types definitely exist); SQLite translatability of grouped `SelectMany` over the genre nav (the Task 3 test is the gate, with a decisive in-memory fallback noted). ✓