From 35ad7df6a42c214e6eb0c24cb6ac1a02f2bfc6e1 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 21:06:20 +0200 Subject: [PATCH 01/11] docs(69): auto-tuning 3-axis MVP design spec Design for #69 auto-tuning: enumerate library metadata (TV Show / TV Genre / Movie Genre) -> preview -> select -> bulk-create channels. Additive/non-destructive, enumerate-via-EF + persist-via-SmartCollection, server-side query generation, reserve-block numbering. Refs #69 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-16-auto-tuning-design.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-auto-tuning-design.md diff --git a/docs/superpowers/specs/2026-07-16-auto-tuning-design.md b/docs/superpowers/specs/2026-07-16-auto-tuning-design.md new file mode 100644 index 000000000..16dd08b03 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-auto-tuning-design.md @@ -0,0 +1,186 @@ +# Auto-Tuning: generate channels automatically from library metadata (#69) + +**Status:** Design approved 2026-07-16. Implementation pending. +**Issue:** [ersatztv#69](http://192.168.1.95:3000/timothy/ersatztv/issues/69) +**Depends on (shipped):** #63 composite create-channel-from-lineup, #64 Channel Templates. + +## Goal + +"Install it and it builds a lineup for you." A creation mode that auto-generates whole +channels from existing library metadata — a second, automatic-first path alongside the +manual create-channel builder (epic #62). A large library becomes a full channel lineup +with little manual work. + +Inspired by **PseudoTV Live's** signature Auto-Tuning feature. We deliberately fix its two +biggest weaknesses: +- PseudoTV is **all-or-nothing per category** with **no preview/selection**. We add a + preview-and-select step (this is written into #69's body). +- PseudoTV **wipes and rebuilds the whole lineup on every run** (destructive to manual + tweaks). We are **additive and non-destructive** — auto-tune never mutates or deletes + existing channels. + +## Scope — first slice (3-axis MVP) + +The full issue lists nine axes. This MVP ships the **complete pipeline** +(enumerate → preview → select → bulk-create) for **three axes only**, the highest-value and +simplest, covering both TV and movie libraries: + +| Axis | Enumerate (exact count, EF) | Generated channel query (SmartCollection) | Playback order | Channel name | +|------|------------------------------|-------------------------------------------|----------------|--------------| +| **TV Show** (24/7 per-show) | distinct shows with ≥ minItems episodes | `type:episode AND show_title:"X"` | `SeasonEpisode` | `X` | +| **TV Genre** | distinct genres on episodes/shows with ≥ minItems episodes | `type:episode AND genre:"X"` | `Shuffle` | `X` | +| **Movie Genre** | distinct genres on movies with ≥ minItems movies | `type:movie AND genre:"X"` | `Shuffle` | `X Movies` | + +Deferred to follow-up PRs (not this MVP): TV Network, Movie Studio, Mixed Genre, Music +Genre, Smart-Collection→channel, Mixed Content; per-axis templates; "even show +distribution" balancing. + +## Key architectural decision — enumerate via EF, persist via SmartCollection + +- **Enumeration** (for the preview) uses **EF Core distinct + count queries** over the + metadata tables (`ShowMetadata`, `GenreMetadata`, `MovieMetadata`, …). This gives *exact* + item counts, which we need for the min-items threshold and the preview display. +- **Persistence** — each generated channel references a newly-created **SmartCollection** + (a live Lucene query), not a static Collection. So a "Comedy" channel keeps picking up new + comedies as the library grows — PseudoTV's regenerative intent, but non-destructive. + #63's lineup `Item` DTO already accepts a `SmartCollectionId`. +- **Query authorship is server-side only.** The client never sends a Lucene string. The + preview returns `{ axis, value }`; bulk-create takes `{ axis, value, … }` and the server + regenerates the query. This avoids client-authored query injection and keeps the axis + semantics in one place. + +Query-value escaping: show titles / genres containing quotes or Lucene special characters +must be escaped when building `show_title:"…"` / `genre:"…"`. Enumeration returns raw +values; query generation escapes them. + +## Coexistence model (additive, non-destructive) + +- **Numbering**: the user picks a **starting channel number** for the batch (default `500`). + Generated channels get sequential numbers from there, **skipping any already taken**. +- **Dedup**: on preview, a proposed channel whose generated **name already matches an + existing channel** is flagged `alreadyExists: true` and is **de-selected by default** in + the UI. Re-running auto-tune after adding library content therefore surfaces only + genuinely-new channels. +- Auto-tune **never** edits or deletes an existing channel. Number/name collisions are + resolved by skipping, never overwriting. +- **Concurrency**: numbers are re-validated at create time (another channel may have taken a + number between preview and create); a now-taken number is skipped/reallocated, and that + outcome is reported per-channel rather than failing the batch. + +## API surface (under the frozen `/api/v1`) + +All three follow `docs/api-conventions.md` (§1 controller shape, §2 request DTO with +`ToCommand()`, §3 error mapping, §7b post-commit side effects on `CancellationToken.None`, +§9 auth posture). New endpoints are additive to the frozen contract. + +### 1. `POST /api/v1/channels/auto-tune/preview` (no writes) + +Request: +``` +{ + "axes": ["TvShow", "TvGenre", "MovieGenre"], // subset, ≥1 + "minItems": 5, // default 5 + "startingNumber": 500, // default 500 + "templateId": // default = system default template +} +``` +Response — a list of proposed channels: +``` +[ + { "axis": "TvShow", "value": "The Office", "name": "The Office", + "proposedNumber": 500, "itemCount": 201, "alreadyExists": false }, + { "axis": "MovieGenre", "value": "Action", "name": "Action Movies", + "proposedNumber": 501, "itemCount": 42, "alreadyExists": false }, + ... +] +``` +Ordering of results: grouped by axis (TvShow, TvGenre, MovieGenre), then by value. Number +allocation is computed here so the UI can show final numbers; it is advisory (re-validated +at create). + +### 2. `POST /api/v1/channels/auto-tune` (bulk create) + +Request — the selected proposals echoed back (server regenerates the query from +`axis`+`value`): +``` +{ + "templateId": , + "channels": [ + { "axis": "TvShow", "value": "The Office", "name": "The Office", "number": 500 }, + { "axis": "MovieGenre", "value": "Action", "name": "Action Movies", "number": 501 } + ] +} +``` +Response — partial-success list, mirroring the existing `ResetAllPlayoutsResult` / +`…ResponseModel` pattern (api-conventions §3a): +``` +{ + "results": [ + { "name": "The Office", "status": "Created", "channelId": 88 }, + { "name": "Action Movies", "status": "Skipped", "reason": "number 501 already taken" }, + { "name": "Sci-Fi", "status": "Failed", "error": "…" } + ], + "createdCount": 1, "skippedCount": 1, "failedCount": 1 +} +``` + +Per selected channel the handler: (a) creates a SmartCollection with the server-generated +query, (b) allocates a free number, (c) calls the **#63 composite create-from-lineup** +handler with a single-item lineup referencing that `SmartCollectionId`, the batch +`templateId`, and an `Advanced.PlaybackOrder` override for the axis (SeasonEpisode / Shuffle). +Each channel is independent — one failure does not abort the batch. Side effects +(`BuildPlayout`, `RefreshChannelList`) are enqueued by the reused #63 handler on +`CancellationToken.None`. + +## SPA + +New wizard screen `web/src/screens/AutoTuneScreen.tsx` following `docs/spa-conventions.md`, +reached from the Channels area (a "Auto-tune channels" action). Flow: +1. **Configure** — axis checkboxes, starting number, min items, template select. +2. **Preview** — calls the preview endpoint; renders a table grouped by axis with per-row + checkboxes, select-all-per-axis, item counts, and `alreadyExists` rows shown greyed and + unchecked. +3. **Create** — posts selected rows to the bulk endpoint; shows a per-channel result summary + (created / skipped / failed counts + any errors). + +No client-side query construction; the screen only passes `axis`+`value` back. + +## Testing / verification + +- **Application handler tests** (NUnit + Shouldly, the in-memory Sqlite + `EnsureCreatedAsync` harness from #28): enumeration distinct+count correctness, min-items + threshold, name-dedup / `alreadyExists`, number allocation with gaps, and bulk-create + partial-success (Created/Skipped/Failed). **Enumerate lazy LanguageExt returns in tests** + (the #229 lesson — a lazy `Map`/`Seq` the test never enumerates hides write-path faults). +- **OpenAPI**: build the app project, then `./scripts/update-openapi.sh` + `npm run + generate:api`; update `docs/api-conventions.md` checklist, regenerate `endpoint-index.md`. +- **Docs**: `docs/domain-model.md` (auto-tune as a second creation mode), + `docs/spa-conventions.md` if a new pattern is introduced, `docs/decisions.md` (the + enumerate-via-EF / persist-via-SmartCollection + additive-coexistence decisions), + `docs/blazor-route-parity.md` + `docs/README.md` for the new screen/route. +- **Live-E2E (required — write-path handlers)** via `scripts/e2e-local.sh`: seed a tiny TV + + movie library (testsrc MKVs + `LibraryPath` rows + scan per `docs/e2e-local.md`), run + preview → create, and verify the generated channels appear and produce valid M3U/XMLTV. + Never exercise download endpoints via browser tabs — curl them. + +## Phasing (2 PRs) + +- **PR1 — backend**: EF enumeration, preview endpoint, bulk-create endpoint, + server-side SmartCollection query generation, handler tests, OpenAPI regen + doc updates. + Write-path handlers → independent (cross-model or cold) review is mandatory. +- **PR2 — SPA wizard** + live-E2E verification. + +## Defaults chosen (recorded so they can be revisited) + +- `minItems` default **5**. +- Movie-genre channels suffixed **" Movies"**; TV genre and TV show names bare. (Disambiguates + a genre that exists for both TV and movies, e.g. "Comedy" vs "Comedy Movies".) +- Per-axis playback order: TV Show → `SeasonEpisode`; TV/Movie Genre → `Shuffle`. +- One Channel Template for the whole batch (per-axis templates deferred). +- Server owns all query generation; the client never sends Lucene. + +## Non-goals (MVP) + +- The remaining six axes, per-axis templates, even-show-distribution balancing, editing + generated channels in-wizard, scheduling filler/bumpers between items (channels inherit + whatever the chosen template configures), and any destructive "rebuild my lineup" mode. -- 2.47.3 From 2352604836415bbf41ed1020815878ce8bae3d9b Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 21:21:55 +0200 Subject: [PATCH 02/11] docs(69): PR1 backend implementation plan 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) --- .../2026-07-16-auto-tuning-pr1-backend.md | 1113 +++++++++++++++++ 1 file changed, 1113 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-auto-tuning-pr1-backend.md diff --git a/docs/superpowers/plans/2026-07-16-auto-tuning-pr1-backend.md b/docs/superpowers/plans/2026-07-16-auto-tuning-pr1-backend.md new file mode 100644 index 000000000..6a1a991bd --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-auto-tuning-pr1-backend.md @@ -0,0 +1,1113 @@ +# 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). ✓ -- 2.47.3 From 9605f9ea65c174e6d27734c656d241157a303657 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 21:42:05 +0200 Subject: [PATCH 03/11] feat(69): auto-tune axis map (query/name/order/escape) Refs #69 --- .../Channels/AutoTuneAxisMap.cs | 44 +++++++++++++++++++ ErsatzTV.Core/Domain/AutoTuneAxis.cs | 8 ++++ .../Channels/AutoTuneAxisMapTests.cs | 44 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 ErsatzTV.Application/Channels/AutoTuneAxisMap.cs create mode 100644 ErsatzTV.Core/Domain/AutoTuneAxis.cs create mode 100644 ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs diff --git a/ErsatzTV.Application/Channels/AutoTuneAxisMap.cs b/ErsatzTV.Application/Channels/AutoTuneAxisMap.cs new file mode 100644 index 000000000..219cf7cc5 --- /dev/null +++ b/ErsatzTV.Application/Channels/AutoTuneAxisMap.cs @@ -0,0 +1,44 @@ +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("\"", "\\\""); +} diff --git a/ErsatzTV.Core/Domain/AutoTuneAxis.cs b/ErsatzTV.Core/Domain/AutoTuneAxis.cs new file mode 100644 index 000000000..cd9ecf477 --- /dev/null +++ b/ErsatzTV.Core/Domain/AutoTuneAxis.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain; + +public enum AutoTuneAxis +{ + TvShow = 0, + TvGenre = 1, + MovieGenre = 2 +} diff --git a/ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs b/ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs new file mode 100644 index 000000000..32064da2a --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs @@ -0,0 +1,44 @@ +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); + } +} -- 2.47.3 From 456c2c7c8478d46efdbc2ee10be1eb88446a1cb0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 21:44:53 +0200 Subject: [PATCH 04/11] feat(69): auto-tune channel-number allocator Refs #69 --- .../Channels/AutoTuneNumberAllocator.cs | 27 ++++++++++++++++ .../Channels/AutoTuneNumberAllocatorTests.cs | 31 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs create mode 100644 ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs diff --git a/ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs b/ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs new file mode 100644 index 000000000..f57310fb6 --- /dev/null +++ b/ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs @@ -0,0 +1,27 @@ +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; + } +} diff --git a/ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs b/ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs new file mode 100644 index 000000000..d71c506fd --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs @@ -0,0 +1,31 @@ +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(); + } +} -- 2.47.3 From 0d57cb6c0df3e4ac73c7b6ab53376669819d38c4 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 21:48:35 +0200 Subject: [PATCH 05/11] feat(69): auto-tune preview query + EF enumeration handler Refs #69 --- .../Channels/PreviewAutoTuneChannels.cs | 19 +++ .../PreviewAutoTuneChannelsHandler.cs | 137 ++++++++++++++++++ .../PreviewAutoTuneChannelsHandlerTests.cs | 136 +++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs create mode 100644 ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs create mode 100644 ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs diff --git a/ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs b/ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs new file mode 100644 index 000000000..482154ac4 --- /dev/null +++ b/ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs @@ -0,0 +1,19 @@ +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); diff --git a/ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs b/ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs new file mode 100644 index 000000000..2e13e5bb3 --- /dev/null +++ b/ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs @@ -0,0 +1,137 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using LanguageExt; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Channels; + +public class PreviewAutoTuneChannelsHandler(IDbContextFactory dbContextFactory) + : IRequestHandler>> +{ + public async Task>> Handle( + PreviewAutoTuneChannels request, + CancellationToken cancellationToken) + { + if (request.Axes is null || request.Axes.Count == 0) + { + return BaseError.New("At least one axis is required"); + } + + if (request.MinItems < 1) + { + return BaseError.New("Minimum items must be at least 1"); + } + + if (request.StartingNumber < 1) + { + return BaseError.New("Starting channel number must be at least 1"); + } + + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + // Enumerate (axis, value, count) triples per requested axis, preserving axis order. + var raw = new List<(AutoTuneAxis Axis, string Value, int Count)>(); + foreach (AutoTuneAxis axis in request.Axes.Distinct()) + { + raw.AddRange(await EnumerateAxis(dbContext, axis, request.MinItems, cancellationToken)); + } + + System.Collections.Generic.HashSet existingNumbers = (await dbContext.Channels.AsNoTracking() + .Select(c => c.Number).ToListAsync(cancellationToken)) + .ToHashSet(); + System.Collections.Generic.HashSet existingNames = (await dbContext.Channels.AsNoTracking() + .Select(c => c.Name).ToListAsync(cancellationToken)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + 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(); + } +} diff --git a/ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs new file mode 100644 index 000000000..6ec918d74 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs @@ -0,0 +1,136 @@ +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.Infrastructure.Data; +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); +} -- 2.47.3 From 4a7bd0b24cfc1fcea85cbffb9fdcace374d0c6fa Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 21:52:34 +0200 Subject: [PATCH 06/11] feat(69): auto-tune bulk-create orchestration handler Refs #69 --- .../Channels/CreateAutoTunedChannels.cs | 35 ++++++ .../CreateAutoTunedChannelsHandler.cs | 101 +++++++++++++++++ .../CreateAutoTunedChannelsHandlerTests.cs | 102 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs create mode 100644 ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs create mode 100644 ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs diff --git a/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs b/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs new file mode 100644 index 000000000..509a5cae3 --- /dev/null +++ b/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs @@ -0,0 +1,35 @@ +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 +} diff --git a/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs new file mode 100644 index 000000000..5d883e261 --- /dev/null +++ b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs @@ -0,0 +1,101 @@ +using ErsatzTV.Application.Artworks; +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); + } +} diff --git a/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs new file mode 100644 index 000000000..9c39bd065 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs @@ -0,0 +1,102 @@ +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); +} -- 2.47.3 From e9e701f62165f301b9a44488b25d4628f6ab2bcb Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 21:57:50 +0200 Subject: [PATCH 07/11] feat(69): auto-tune preview + bulk-create REST endpoints Refs #69 --- .../Api/Channels/AutoTuneResponseModels.cs | 22 ++++++++++ ErsatzTV/Controllers/Api/ChannelController.cs | 41 +++++++++++++++++++ .../Api/Requests/AutoTuneRequests.cs | 30 ++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs diff --git a/ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs b/ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs new file mode 100644 index 000000000..6cd9c861c --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs @@ -0,0 +1,22 @@ +#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); diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index c29ff6278..a2925f2ed 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -214,6 +214,47 @@ public class ChannelController( return result.ToDeletedResult(); } + [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 => new OkObjectResult(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 new OkObjectResult(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); + [HttpPost("/api/v1/channels/{id:int}/playout/reset")] [Tags("Channels")] [EndpointSummary("Reset channel playout")] diff --git a/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs b/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs new file mode 100644 index 000000000..773b594e6 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs @@ -0,0 +1,30 @@ +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); +} -- 2.47.3 From 0320735f47de4a266d2ffb088f69f1b97f5832a4 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 22:03:14 +0200 Subject: [PATCH 08/11] docs(69): auto-tune OpenAPI regen + api/domain/decisions docs Refs #69 --- ErsatzTV/wwwroot/openapi/v1.json | 374 ++++++++++++++++++ docs/api-conventions.md | 9 + docs/decisions.md | 14 + docs/domain-model.md | 7 + docs/endpoint-index.md | 6 +- .../specs/2026-07-16-auto-tuning-design.md | 3 +- web/src/api/generated/v1.d.ts | 37 ++ 7 files changed, 445 insertions(+), 5 deletions(-) diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index c51fb78de..4908aaada 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2364,6 +2364,193 @@ ] } }, + "/api/v1/channels/auto-tune/preview": { + "post": { + "tags": [ + "Channels" + ], + "summary": "Preview auto-tuned channels", + "operationId": "PreviewAutoTuneChannels", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/PreviewAutoTuneChannelsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewAutoTuneChannelsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PreviewAutoTuneChannelsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/PreviewAutoTuneChannelsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutoTuneProposalResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutoTuneProposalResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutoTuneProposalResponseModel" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/api/v1/channels/auto-tune": { + "post": { + "tags": [ + "Channels" + ], + "summary": "Create auto-tuned channels", + "operationId": "CreateAutoTunedChannels", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateAutoTunedChannelsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAutoTunedChannelsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateAutoTunedChannelsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateAutoTunedChannelsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/AutoTuneResultResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoTuneResultResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AutoTuneResultResponseModel" + } + } + } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, "/api/v1/channels/{id}/playout/reset": { "post": { "tags": [ @@ -22535,6 +22722,137 @@ } } }, + "AutoTuneAxis": { + "enum": [ + "TvShow", + "TvGenre", + "MovieGenre" + ], + "type": "string" + }, + "AutoTuneChannelResultModel": { + "required": [ + "name", + "status", + "channelId", + "reason" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "channelId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "reason": { + "type": [ + "null", + "string" + ] + } + } + }, + "AutoTunedChannelRequest": { + "required": [ + "axis", + "value", + "name", + "number" + ], + "type": "object", + "properties": { + "axis": { + "$ref": "#/components/schemas/AutoTuneAxis" + }, + "value": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "string" + ] + } + } + }, + "AutoTuneProposalResponseModel": { + "required": [ + "axis", + "value", + "name", + "number", + "itemCount", + "alreadyExists" + ], + "type": "object", + "properties": { + "axis": { + "type": "string" + }, + "value": { + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "string" + }, + "itemCount": { + "type": "integer", + "format": "int32" + }, + "alreadyExists": { + "type": "boolean" + } + } + }, + "AutoTuneResultResponseModel": { + "required": [ + "results", + "createdCount", + "skippedCount", + "failedCount" + ], + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutoTuneChannelResultModel" + } + }, + "createdCount": { + "type": "integer", + "format": "int32" + }, + "skippedCount": { + "type": "integer", + "format": "int32" + }, + "failedCount": { + "type": "integer", + "format": "int32" + } + } + }, "BlockGroupResponseModel": { "required": [ "id", @@ -23616,6 +23934,35 @@ } } }, + "CreateAutoTunedChannelsRequest": { + "required": [ + "templateId", + "group", + "channels" + ], + "type": "object", + "properties": { + "templateId": { + "type": "integer", + "format": "int32" + }, + "group": { + "type": [ + "null", + "string" + ] + }, + "channels": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/AutoTunedChannelRequest" + } + } + } + }, "CreateBlockGroupRequest": { "required": [ "name" @@ -28295,6 +28642,33 @@ } } }, + "PreviewAutoTuneChannelsRequest": { + "required": [ + "axes", + "minItems", + "startingNumber" + ], + "type": "object", + "properties": { + "axes": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/AutoTuneAxis" + } + }, + "minItems": { + "type": "integer", + "format": "int32" + }, + "startingNumber": { + "type": "integer", + "format": "int32" + } + } + }, "ProblemDetails": { "type": "object", "properties": { diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 013015dd7..e0787bc8e 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -307,6 +307,15 @@ After any controller/DTO change: `endpoint-index.md` from source and fails if any of the three is stale in the diff. This is the mechanized half of the "docs-update in the same PR" rule for the API contract. +**Endpoint inventory additions (#69, auto-tuning PR1 backend)**: two `ChannelController` actions, +both requiring the standard credential (session-or-key, per §9 — no `[SkipApiAuthorization]`/ +`[RequiresAuthentication]` override): + +| Method | Path | Operation | Summary | +|---|---|---|---| +| POST | `/api/v1/channels/auto-tune/preview` | `PreviewAutoTuneChannels` | Preview auto-tuned channels | +| POST | `/api/v1/channels/auto-tune` | `CreateAutoTunedChannels` | Create auto-tuned channels | + **Resolved wart (#287)**: `DayOfWeek` previously serialized as an integer in the OpenAPI schema while the runtime JSON payload is the enum's **name string** ("Sunday".."Saturday"). It is now added to `Startup.UseStringEnumSchemas`'s hand-list, so the "v1" schema emits it as a **string enum** matching diff --git a/docs/decisions.md b/docs/decisions.md index a25ce3fed..1cf991e38 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -91,6 +91,7 @@ keep append-only from accreting stale, contradictory, or unreadably-large histor - [2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)](#2026-07-13--release-promotion-floating-prod-exact-image-scan-before-manual-deploy-335) - [2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)](#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) - [2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)](#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) +- [2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)](#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69) --- @@ -1921,3 +1922,16 @@ was explicitly out of scope. Core `PathBase` (request routing/prefix); it does not advertise a scheme+host. `iptv.base_url` is the separate, DB-stored (`ConfigElementKey.IptvBaseUrl`, **no EF migration**) advertised origin for IPTV output. See `docs/m3u-xmltv.md` → "IPTV base URL (#340)". + +## 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. diff --git a/docs/domain-model.md b/docs/domain-model.md index cfa546762..9c51427fb 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -12,6 +12,13 @@ external-JSON) — a playout belongs to exactly one **channel**. Channels are ex (Jellyfin, Dispatcharr) as an M3U playlist + XMLTV guide, and streamed on demand via FFmpeg, all under the `/iptv/*` routes (`ErsatzTV/Controllers/IptvController.cs`). +**Auto-tuning (#69)** is a second, automatic-first channel-creation mode alongside the manual +single-channel flow: it enumerates library metadata along an axis (TV Show / TV Genre / Movie +Genre), previews the proposed channels (name, allocated number, item count) with no writes, and +bulk-creates the selected ones via the #63 composite `CreateChannelFromLineup` primitive. Each +generated channel is backed by a newly-created, live **SmartCollection** query, so it keeps +tracking the library as it grows rather than freezing a static item list. + ## Entity chain sketch ``` diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index a7d7e6242..828e2a382 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -161 endpoints, 244 operations. +162 endpoints, 244 operations. ## Artists @@ -55,6 +55,8 @@ | Method | Path | Operation | Summary | |---|---|---|---| | POST | `/api/v1/channels` | ChannelCreate | Create a channel | +| POST | `/api/v1/channels/auto-tune` | CreateAutoTunedChannels | Create auto-tuned channels | +| POST | `/api/v1/channels/auto-tune/preview` | PreviewAutoTuneChannels | Preview auto-tuned channels | | POST | `/api/v1/channels/bulk/delete` | ChannelBulkDelete | Delete channels | | POST | `/api/v1/channels/bulk/group` | ChannelBulkMoveToGroup | Move channels to a group | | POST | `/api/v1/channels/bulk/renumber` | ChannelBulkRenumber | Renumber channels | @@ -354,8 +356,6 @@ | PUT | `/api/v1/settings/ffmpeg` | UpdateFfmpegSettings | Update global FFmpeg settings | | GET | `/api/v1/settings/hdhr` | GetHdhrSettings | Get HDHomeRun emulation settings | | PUT | `/api/v1/settings/hdhr` | UpdateHdhrSettings | Update HDHomeRun emulation settings | -| GET | `/api/v1/settings/iptv` | GetIptvSettings | Get IPTV output settings | -| PUT | `/api/v1/settings/iptv` | UpdateIptvSettings | Update IPTV output settings | | GET | `/api/v1/settings/logging` | GetLoggingSettings | Get per-area minimum log levels | | PUT | `/api/v1/settings/logging` | UpdateLoggingSettings | Update per-area minimum log levels | | GET | `/api/v1/settings/playout` | GetPlayoutSettings | Get global playout settings | diff --git a/docs/superpowers/specs/2026-07-16-auto-tuning-design.md b/docs/superpowers/specs/2026-07-16-auto-tuning-design.md index 16dd08b03..588ce6918 100644 --- a/docs/superpowers/specs/2026-07-16-auto-tuning-design.md +++ b/docs/superpowers/specs/2026-07-16-auto-tuning-design.md @@ -80,8 +80,7 @@ Request: { "axes": ["TvShow", "TvGenre", "MovieGenre"], // subset, ≥1 "minItems": 5, // default 5 - "startingNumber": 500, // default 500 - "templateId": // default = system default template + "startingNumber": 500 // default 500 } ``` Response — a list of proposed channels: diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 88fe97802..0673ca366 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -58,6 +58,33 @@ export interface components { "ArtworkUploadResponseModel": { "path": string; "contentType": string; + }; + "AutoTuneAxis": "TvShow" | "TvGenre" | "MovieGenre"; + "AutoTuneChannelResultModel": { + "name": string; + "status": string; + "channelId": null | number; + "reason": null | string; + }; + "AutoTunedChannelRequest": { + "axis": components["schemas"]["AutoTuneAxis"]; + "value": null | string; + "name": null | string; + "number": null | string; + }; + "AutoTuneProposalResponseModel": { + "axis": string; + "value": string; + "name": string; + "number": string; + "itemCount": number; + "alreadyExists": boolean; + }; + "AutoTuneResultResponseModel": { + "results": Array; + "createdCount": number; + "skippedCount": number; + "failedCount": number; }; "BlockGroupResponseModel": { "id": number; @@ -270,6 +297,11 @@ export interface components { "CopyTemplateRequest": { "templateGroupId": number; "name": null | string; + }; + "CreateAutoTunedChannelsRequest": { + "templateId": number; + "group": null | string; + "channels": null | Array; }; "CreateBlockGroupRequest": { "name": null | string; @@ -1143,6 +1175,11 @@ export interface components { }; "PlexPinFlowResponseModel": { "authUrl": string; + }; + "PreviewAutoTuneChannelsRequest": { + "axes": null | Array; + "minItems": number; + "startingNumber": number; }; "ProblemDetails": { "type"?: null | string; -- 2.47.3 From f31476e01284e82e2477cb31938b9d107595b7bd Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 22:11:58 +0200 Subject: [PATCH 09/11] =?UTF-8?q?fix(69):=20auto-tune=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20null=20channels,=20orphaned=20SmartCollection,=20ov?= =?UTF-8?q?ersized=20preview=20names,=20doc=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../CreateAutoTunedChannelsHandler.cs | 5 +++++ .../PreviewAutoTuneChannelsHandler.cs | 19 +++++++++++++------ .../CreateAutoTunedChannelsHandlerTests.cs | 19 +++++++++++++++++++ .../PreviewAutoTuneChannelsHandlerTests.cs | 16 ++++++++++++++++ .../Api/Requests/AutoTuneRequests.cs | 2 +- .../specs/2026-07-16-auto-tuning-design.md | 6 +++--- 6 files changed, 57 insertions(+), 10 deletions(-) diff --git a/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs index 5d883e261..6629871db 100644 --- a/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs +++ b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs @@ -89,6 +89,11 @@ public class CreateAutoTunedChannelsHandler(ISender mediator) foreach (BaseError error in channelResult.LeftToSeq()) { + // Roll back the smart collection we just created so a retry of this + // axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort; + // the primary outcome below is still Skipped/Failed regardless of the delete result. + await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken); + AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal) ? AutoTuneOutcomeStatus.Skipped : AutoTuneOutcomeStatus.Failed; diff --git a/ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs b/ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs index 2e13e5bb3..a04bc0304 100644 --- a/ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs +++ b/ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs @@ -45,14 +45,21 @@ public class PreviewAutoTuneChannelsHandler(IDbContextFactory dbConte .Select(c => c.Name).ToListAsync(cancellationToken)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - List numbers = AutoTuneNumberAllocator.Allocate( - request.StartingNumber, raw.Count, existingNumbers); + // 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(); - var proposals = new List(raw.Count); - for (int i = 0; i < raw.Count; i++) + List numbers = AutoTuneNumberAllocator.Allocate( + request.StartingNumber, survivors.Count, existingNumbers); + + var proposals = new List(survivors.Count); + for (int i = 0; i < survivors.Count; i++) { - (AutoTuneAxis axis, string value, int count) = raw[i]; - string name = AutoTuneAxisMap.GenerateName(axis, value); + (AutoTuneAxis axis, string value, int count, string name) = survivors[i]; proposals.Add(new AutoTuneProposal( axis, value, name, numbers[i], count, existingNames.Contains(name))); } diff --git a/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs index 9c39bd065..af076b063 100644 --- a/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs @@ -32,6 +32,8 @@ public class CreateAutoTunedChannelsHandlerTests return (Either) new SmartCollectionViewModel(7, cmd.Name, cmd.Query); }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((Either)LanguageExt.Unit.Default); } [Test] @@ -83,6 +85,23 @@ public class CreateAutoTunedChannelsHandlerTests result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped); } + [Test] + public async Task Number_Collision_Rolls_Back_Orphaned_SmartCollection() + { + _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.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped); + + await _mediator.Received().Send( + Arg.Is(d => d.SmartCollectionId == 7), + Arg.Any()); + } + [Test] public async Task Other_Errors_Are_Failed() { diff --git a/ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs index 6ec918d74..a77fb9874 100644 --- a/ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs @@ -74,6 +74,22 @@ public class PreviewAutoTuneChannelsHandlerTests proposals[0].ItemCount.ShouldBe(2); } + [Test] + public async Task Excludes_Proposals_Whose_Generated_Name_Exceeds_50_Chars() + { + // "Movies" suffix (7 chars) pushes this over the 50-char Channel.Name limit. + const string longGenre = "A Really Really Long And Overly Descriptive Genre"; // 50 chars, +" Movies" = 57 + await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: longGenre); + await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: longGenre); + + var result = await Handle(new PreviewAutoTuneChannels( + new List { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500)); + + List proposals = RightOf(result); + proposals.ShouldNotContain(p => p.Value == longGenre); + proposals.ShouldBeEmpty(); + } + [Test] public async Task Empty_Axes_Is_Error() { diff --git a/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs b/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs index 773b594e6..9f216b728 100644 --- a/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs +++ b/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs @@ -17,7 +17,7 @@ public record CreateAutoTunedChannelsRequest( List Channels) { public CreateAutoTunedChannels ToCommand() => - new(TemplateId, Group, Channels.Select(c => c.ToCommand()).ToList()); + new(TemplateId, Group, (Channels ?? []).Select(c => c.ToCommand()).ToList()); } public record AutoTunedChannelRequest( diff --git a/docs/superpowers/specs/2026-07-16-auto-tuning-design.md b/docs/superpowers/specs/2026-07-16-auto-tuning-design.md index 588ce6918..07848417e 100644 --- a/docs/superpowers/specs/2026-07-16-auto-tuning-design.md +++ b/docs/superpowers/specs/2026-07-16-auto-tuning-design.md @@ -87,9 +87,9 @@ Response — a list of proposed channels: ``` [ { "axis": "TvShow", "value": "The Office", "name": "The Office", - "proposedNumber": 500, "itemCount": 201, "alreadyExists": false }, + "number": 500, "itemCount": 201, "alreadyExists": false }, { "axis": "MovieGenre", "value": "Action", "name": "Action Movies", - "proposedNumber": 501, "itemCount": 42, "alreadyExists": false }, + "number": 501, "itemCount": 42, "alreadyExists": false }, ... ] ``` @@ -117,7 +117,7 @@ Response — partial-success list, mirroring the existing `ResetAllPlayoutsResul "results": [ { "name": "The Office", "status": "Created", "channelId": 88 }, { "name": "Action Movies", "status": "Skipped", "reason": "number 501 already taken" }, - { "name": "Sci-Fi", "status": "Failed", "error": "…" } + { "name": "Sci-Fi", "status": "Failed", "reason": "…" } ], "createdCount": 1, "skippedCount": 1, "failedCount": 1 } -- 2.47.3 From 39b07e178d88ad0851f5bc65307ecb31c8ca1c49 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 22:15:46 +0200 Subject: [PATCH 10/11] fix(channels): swallow rollback delete exceptions in auto-tune handler DeleteSmartCollection rollback in CreateAutoTunedChannelsHandler.CreateOne was called without exception handling; a transient infra exception during the best-effort rollback would propagate and abort the whole batch, contradicting the comment's stated intent. Wrap the send in try/catch so an orphaned SmartCollection is the accepted degraded outcome instead. --- .../Channels/CreateAutoTunedChannelsHandler.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs index 6629871db..e523b558a 100644 --- a/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs +++ b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs @@ -92,7 +92,17 @@ public class CreateAutoTunedChannelsHandler(ISender mediator) // Roll back the smart collection we just created so a retry of this // axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort; // the primary outcome below is still Skipped/Failed regardless of the delete result. - await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken); + // Swallow any exception (not just an Either.Left) so a transient infra failure + // during rollback never aborts this channel's outcome or the batch; the + // orphaned SmartCollection is an acceptable degraded outcome. + try + { + await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken); + } + catch (Exception) + { + // intentionally ignored; see comment above + } AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal) ? AutoTuneOutcomeStatus.Skipped -- 2.47.3 From 938e4e0f505610a6c4def8e699dbd039cb6ed1a7 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 22:20:53 +0200 Subject: [PATCH 11/11] chore(69): regenerate endpoint-index after rebase on origin/main Union of #340 IPTV-settings endpoints + auto-tune endpoints. Refs #69 --- docs/endpoint-index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 828e2a382..2166412be 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -162 endpoints, 244 operations. +163 endpoints, 246 operations. ## Artists @@ -356,6 +356,8 @@ | PUT | `/api/v1/settings/ffmpeg` | UpdateFfmpegSettings | Update global FFmpeg settings | | GET | `/api/v1/settings/hdhr` | GetHdhrSettings | Get HDHomeRun emulation settings | | PUT | `/api/v1/settings/hdhr` | UpdateHdhrSettings | Update HDHomeRun emulation settings | +| GET | `/api/v1/settings/iptv` | GetIptvSettings | Get IPTV output settings | +| PUT | `/api/v1/settings/iptv` | UpdateIptvSettings | Update IPTV output settings | | GET | `/api/v1/settings/logging` | GetLoggingSettings | Get per-area minimum log levels | | PUT | `/api/v1/settings/logging` | UpdateLoggingSettings | Update per-area minimum log levels | | GET | `/api/v1/settings/playout` | GetPlayoutSettings | Get global playout settings | -- 2.47.3