Task-by-task TDD plan for the auto-tuning backend: axis map, number allocator, preview query + EF enumeration, bulk-create orchestration, REST endpoints, OpenAPI + docs. Refs #69 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1114 lines
47 KiB
Markdown
1114 lines
47 KiB
Markdown
# 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<BaseError, T>` (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 = <id>`, all other ids null.
|
|
- **Central Package Management:** no new packages needed. Never add `Version=` to a `<PackageReference>`.
|
|
- **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 <project>` 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<string> existingNumbers) : List<string>` — `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<string> { "500", "502" };
|
|
List<string> result = AutoTuneNumberAllocator.Allocate(500, 3, existing);
|
|
result.ShouldBe(new List<string> { "501", "503", "504" });
|
|
}
|
|
|
|
[Test]
|
|
public void Allocate_From_Empty_Is_Sequential()
|
|
{
|
|
List<string> result = AutoTuneNumberAllocator.Allocate(1, 3, new HashSet<string>());
|
|
result.ShouldBe(new List<string> { "1", "2", "3" });
|
|
}
|
|
|
|
[Test]
|
|
public void Allocate_Zero_Count_Is_Empty()
|
|
{
|
|
AutoTuneNumberAllocator.Allocate(500, 0, new HashSet<string>()).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<string> Allocate(int startingNumber, int count, ISet<string> existingNumbers)
|
|
{
|
|
var result = new List<string>(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<AutoTuneAxis> Axes, int MinItems, int StartingNumber) : IRequest<Either<BaseError, List<AutoTuneProposal>>>`; `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> { AutoTuneAxis.TvShow }, MinItems: 2, StartingNumber: 500));
|
|
|
|
List<AutoTuneProposal> 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> { AutoTuneAxis.TvShow }, MinItems: 1, StartingNumber: 500));
|
|
|
|
List<AutoTuneProposal> 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> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
|
|
|
|
List<AutoTuneProposal> 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<AutoTuneAxis>(), MinItems: 5, StartingNumber: 500));
|
|
LeftOf(result).Value.ShouldContain("axis");
|
|
}
|
|
|
|
private Task<Either<BaseError, List<AutoTuneProposal>>> 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<ShowMetadata> { new() { ShowId = showId, Title = title } },
|
|
Seasons = new List<Season>
|
|
{
|
|
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<MovieMetadata>
|
|
{
|
|
new() { Id = metadataId, MovieId = movieId, Title = $"Movie {movieId}",
|
|
Genres = new List<Genre> { 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<TR>(Either<BaseError, TR> e) =>
|
|
e.Match(Left: x => x, Right: _ => throw new AssertionException("Expected a Left result"));
|
|
private static TR RightOf<TR>(Either<BaseError, TR> 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<AutoTuneAxis> Axes,
|
|
int MinItems,
|
|
int StartingNumber) : IRequest<Either<BaseError, List<AutoTuneProposal>>>;
|
|
|
|
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<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));
|
|
}
|
|
|
|
HashSet<string> existingNumbers = (await dbContext.Channels.AsNoTracking()
|
|
.Select(c => c.Number).ToListAsync(cancellationToken))
|
|
.ToHashSet();
|
|
HashSet<string> existingNames = (await dbContext.Channels.AsNoTracking()
|
|
.Select(c => c.Name).ToListAsync(cancellationToken))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
List<string> numbers = AutoTuneNumberAllocator.Allocate(
|
|
request.StartingNumber, raw.Count, existingNumbers);
|
|
|
|
var proposals = new List<AutoTuneProposal>(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<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();
|
|
}
|
|
}
|
|
```
|
|
> 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<Either<BaseError, SmartCollectionViewModel>>` (namespace `ErsatzTV.Application.MediaCollections`; `SmartCollectionViewModel(int Id, string Name, string Query)`) and `CreateChannelFromLineup(...) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>>` (namespace `ErsatzTV.Application.Channels`), sent via `ISender`.
|
|
- Produces: `record CreateAutoTunedChannels(int TemplateId, string Group, List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>`; `record AutoTuneChannelSelection(AutoTuneAxis Axis, string Value, string Name, string Number)`; `record AutoTuneResult(List<AutoTuneChannelOutcome> 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<ISender>();
|
|
// Smart collection creation always succeeds, echoing an incrementing id.
|
|
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
|
.Returns(ci =>
|
|
{
|
|
var cmd = ci.Arg<CreateSmartCollection>();
|
|
return (Either<BaseError, SmartCollectionViewModel>)
|
|
new SmartCollectionViewModel(7, cmd.Name, cmd.Query);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Creates_Selected_Channels_And_Reports_Counts()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
|
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
|
|
new CreateChannelFromLineupResponseModel(88, null, 1, 2));
|
|
|
|
var result = await Handle(new CreateAutoTunedChannels(
|
|
TemplateId: 3, Group: "Auto-Tuned",
|
|
new List<AutoTuneChannelSelection>
|
|
{
|
|
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<CreateSmartCollection>(c => c.Query == "type:episode AND show_title:\"The Office\""),
|
|
Arg.Any<CancellationToken>());
|
|
|
|
// Channel created referencing the smart collection id, number, and SeasonEpisode order.
|
|
await _mediator.Received().Send(
|
|
Arg.Is<CreateChannelFromLineup>(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<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Number_Collision_Is_Skipped_Not_Failed()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
|
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
|
|
BaseError.New("Channel number must be unique"));
|
|
|
|
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
|
|
new List<AutoTuneChannelSelection> { 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<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
|
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
|
|
BaseError.New("FFmpegProfile 9 does not exist."));
|
|
|
|
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
|
|
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
|
|
|
|
result.FailedCount.ShouldBe(1);
|
|
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed);
|
|
}
|
|
|
|
private Task<AutoTuneResult> 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<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
|
|
|
|
public record AutoTuneChannelSelection(
|
|
AutoTuneAxis Axis,
|
|
string Value,
|
|
string Name,
|
|
string Number);
|
|
|
|
public record AutoTuneResult(List<AutoTuneChannelOutcome> 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<CreateAutoTunedChannels, AutoTuneResult>
|
|
{
|
|
private const string NumberTakenError = "Channel number must be unique";
|
|
private const string DefaultGroup = "Auto-Tuned";
|
|
|
|
public async Task<AutoTuneResult> Handle(
|
|
CreateAutoTunedChannels request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim();
|
|
var outcomes = new List<AutoTuneChannelOutcome>();
|
|
|
|
foreach (AutoTuneChannelSelection selection in request.Channels ?? [])
|
|
{
|
|
outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken));
|
|
}
|
|
|
|
return new AutoTuneResult(outcomes);
|
|
}
|
|
|
|
private async Task<AutoTuneChannelOutcome> 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<BaseError, SmartCollectionViewModel> 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<BaseError, CreateChannelFromLineupResponseModel> 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<AutoTuneChannelResultModel> 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<AutoTuneAxis> Axes,
|
|
int MinItems,
|
|
int StartingNumber)
|
|
{
|
|
public PreviewAutoTuneChannels ToCommand() => new(Axes, MinItems, StartingNumber);
|
|
}
|
|
|
|
public record CreateAutoTunedChannelsRequest(
|
|
int TemplateId,
|
|
string Group,
|
|
List<AutoTunedChannelRequest> 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<AutoTuneProposalResponseModel>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
|
public async Task<IActionResult> PreviewAutoTune(
|
|
[Required] [FromBody] PreviewAutoTuneChannelsRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Either<BaseError, List<AutoTuneProposal>> result =
|
|
await mediator.Send(request.ToCommand(), cancellationToken);
|
|
return result.Match<IActionResult>(
|
|
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<IActionResult> 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 <project>` 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). ✓
|