# Auto-Tune over an arbitrary group-by field — backend design spec

- **Date:** 2026-07-23
- **Issue:** ersatztv#437 (RuleBuilder / Auto-Tune generalization)
- **Status:** Design proposal — no code changes. Backend-first.
- **Scope:** Generalize the fixed 3-axis Auto-Tune picker (`TvShow | TvGenre | MovieGenre`) into an
  arbitrary **group-by field** builder drawn from the shared `GET /api/v1/search/fields` catalog, so
  "one channel per distinct value of field _F_, filtered to media type _M_" works for genre, studio,
  director, network, content rating, tag, decade-from-date, etc.

---

## 1. Summary

Auto-Tune today hard-codes three axes end to end: an `AutoTuneAxis` enum, three bespoke EF
enumerations in `PreviewAutoTuneChannelsHandler`, and a per-axis `switch` in `AutoTuneAxisMap` for
query / name / playback-order / per-source-weight generation. This spec proposes replacing the
closed axis with an open **`(field, mediaType)` group-by** keyed on the **same field vocabulary** the
RuleBuilder (#176), the facet typeahead (#434), and MCP `ersatztv_search` already speak — the
`SearchFieldCatalog`. The field names in that catalog **are** the Lucene index field names, which is
the throughline that makes generic query generation clean.

Recommended shape, in one paragraph: keep the `axis` path frozen and populated (deprecate-in-place
per the `/api/v1` additive-only rule), add an **optional `groupBy { field, mediaType }`** selection to
the preview and create request DTOs, resolve **either** an axis **or** a `groupBy` to one internal
`AutoTuneGroupBy` value object, and drive enumeration off a **generic per-field distinct-value + count
DB query** (an extension of the #434 `GetSource` map) rather than faceting the Lucene index — because
the index exposes **no faceting API today** (`ISearchIndex` has only `Search`). Membership queries,
channel names, and default playback orders all become pure functions of `AutoTuneGroupBy` instead of
the enum. New MCP tools `ersatztv_preview_auto_tune` / `ersatztv_create_auto_tuned_channels` (plus
`ersatztv_list_search_fields`) expose the capability to machine clients on the shared vocabulary.

The two hard, genuinely-uncertain parts are flagged as open questions, not designed away: (a) how much
of the per-source **weights/remainder** machinery (#425) can generalize beyond "sources = shows or
movies", and (b) whether **derived fields** like `decade` belong in the shared catalog or in an
Auto-Tune-only extension.

---

## 2. Problem / Motivation

The 3-axis picker is a small, fixed slice of an obviously-general capability. A user who wants "one
channel per studio", "one channel per director", "one channel per decade", or "one channel per tag"
has to build each SmartCollection + channel by hand, even though:

- The RuleBuilder already presents the full filterable field set (`SearchFieldCatalog`, 26 fields
  across General/TV/Movie/Music/Technical/Dates groups) as a first-class vocabulary.
- The facet typeahead (#434) already enumerates **distinct whole values per field** from the DB for a
  useful subset of those fields.
- Auto-Tune already knows how to turn `(query → SmartCollection → channel)` with number allocation,
  name-length guards, per-channel template/logo/advanced overrides, and per-source weights.

The missing piece is purely the **generalization of the axis** — everything downstream of "here is a
distinct value and a membership query" is already generic. Unifying on the shared field catalog turns
three special cases into one open mechanism and removes the enum as the thing that must be edited every
time a new grouping is wanted.

---

## 3. Current architecture (exact references)

### 3.1 The closed axis

- **`ErsatzTV.Core/Domain/AutoTuneAxis.cs`** — `enum AutoTuneAxis { TvShow = 0, TvGenre = 1, MovieGenre = 2 }`.

### 3.2 Per-axis logic — `ErsatzTV.Application/Channels/AutoTuneAxisMap.cs`

A static class of pure functions, each a `switch (axis)`:

| Function | TvShow | TvGenre | MovieGenre |
|---|---|---|---|
| `GenerateQuery(axis, value)` | `type:episode AND show_title:"v"` | `type:episode AND genre:"v"` | `type:movie AND genre:"v"` |
| `GenerateName(axis, value)` | `v` | `v` | `v Movies` (disambiguating suffix) |
| `PlaybackOrderFor(axis)` | `SeasonEpisode` | `Shuffle` | `Shuffle` |
| `GenerateSourceQuery(axis, disc)` | `type:episode AND show_title:"disc"` | (same as TvShow) | `type:movie AND id:disc` |
| `SourceDiscriminatorClause(axis, disc)` | `show_title:"disc"` | (same) | `id:disc` |
| `GenerateRemainderQuery(...)` | `(base) AND NOT (d1 OR d2 …)` — partitions base set across member sources + remainder |
| `EscapeLuceneValue(v)` | backslash then double-quote escaping for a Lucene phrase |

Note the field names in these templates (`show_title`, `genre`, `id`, `type`) are exactly the Lucene
index field constants (`LuceneSearchIndex.ShowTitleField`, `GenreField`, `IdField`, `TypeField`).

### 3.3 Enumeration — `ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs`

Three **bespoke EF queries**, each hand-written against a specific navigation path:

- `EnumerateTvShows` — groups `Episodes` by `Season.ShowId`, then **collapses shows that share a
  title** into one entry (because the generated `show_title:"…"` query matches them together);
  count = episodes per title.
- `EnumerateEpisodeGenres` — `EpisodeMetadata.SelectMany(m => m.Genres).GroupBy(g => g.Name)`.
- `EnumerateMovieGenres` — `MovieMetadata.SelectMany(m => m.Genres).GroupBy(g => g.Name)`.

Each returns `(axis, value, count)` triples filtered by `count >= minItems`, ordered case-insensitively.
The handler then: generates names via `AutoTuneAxisMap.GenerateName`, drops proposals whose name would
exceed the Channel-name 50-char limit **before** number allocation (so numbers aren't wasted),
allocates sequential numbers skipping existing ones (`AutoTuneNumberAllocator.Allocate`), and flags
`AlreadyExists` against existing channel names (case-insensitive).

**These three queries share no column.** `show_title` is a denormalized string on episodes, `genre` is
a joined `Genre` table via metadata, movie-genre is the same join off a different metadata table. This
is the crux of the generalization problem.

### 3.4 Creation — `ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs`

Turns a proposal into a real channel. Two shapes:

- **Single SmartCollection** (`CreateSingleSmartCollectionChannel`): `CreateSmartCollection(query,
  name)` then `CreateChannelFromLineup` referencing it; axis-default playback order unless overridden;
  rolls back the SmartCollection on channel-create failure.
- **Weighted MultiCollection** (`CreateWeightedChannel` + `BuildWeightedPlan` / `BuildTvPlan` /
  `BuildMoviePlan`, #425): when the caller customized any per-source weight/exclusion/addition, builds
  a system-owned `MultiCollection` of per-source `SmartCollection`s with `WeightedShuffle` plus a
  catch-all **remainder** SmartCollection; stamps `OwnedByChannelId` for cleanup.

### 3.5 Members enumeration — `ErsatzTV.Application/Channels/GetAutoTuneChannelMembersHandler.cs`

`GET /api/v1/channels/auto-tune/members?axis=&value=&pageNum=&pageSize=` runs
`AutoTuneAxisMap.GenerateQuery` through **`ISearchIndex.Search`** (capped at 10k leaf items), then
rolls matches up to distinct content **sources**: parent shows for the TV axes (item count = matching
episodes, not the show total), movies for the movie-genre axis. Decision `sched.autotune-detailpanel-members`
(#384): live search-index roll-up, **not** EF, so the preview matches exactly what the built channel's
SmartCollection resolves to.

### 3.6 DTOs & controller

- **Request:** `ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs`
  - `PreviewAutoTuneChannelsRequest(List<AutoTuneAxis> Axes, int MinItems, int StartingNumber)`
  - `CreateAutoTunedChannelsRequest(int TemplateId, string Group, List<AutoTunedChannelRequest> Channels)`
  - `AutoTunedChannelRequest(AutoTuneAxis Axis, string Value, string Name, string Number, int?
    TemplateId, ArtworkContentTypeModel Logo, CreateChannelFromLineupAdvancedOptionsRequest Advanced,
    List<AutoTuneSourceWeightRequest> Sources)` — the last four optional (#385/#425).
  - `AutoTuneSourceWeightRequest(int SourceId, int Weight = 1, bool Excluded = false)`
- **Commands:** `ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs`,
  `CreateAutoTunedChannels.cs`, `GetAutoTuneChannelMembers.cs`.
- **Response:** `ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs` —
  `AutoTuneProposalResponseModel(string Axis, string Value, string Name, string Number, int ItemCount,
  bool AlreadyExists)`, `AutoTuneChannelResultModel`, `AutoTuneResultResponseModel`. Note `Axis` is
  already serialized as a **string** on the wire.
- **Controller:** `ErsatzTV/Controllers/Api/ChannelController.cs` — `PreviewAutoTune`,
  `CreateAutoTuned`, `GetAutoTuneChannelMembers`. Standard session-or-key credential (no
  `[RequiresAuthentication]`), per `docs/api-conventions.md` §1/§9 and the #69/#384 endpoint inventory.

### 3.7 The shared field vocabulary

- **`ErsatzTV.Application/Search/SearchFieldCatalog.cs`** — `List<SearchFieldResponseModel(string Name,
  string Label, string Type, string Group, string[] Values)>`. `Type ∈ {text, fulltext, number, date,
  enum}`; `Values` populated only for `enum` (only `type` today, with the 10 item-type tokens). **Field
  `Name` == Lucene field name.**
- **`GET /api/v1/search/fields`** → `GetSearchFieldCatalogHandler` (returns the catalog).
- **`GET /api/v1/search/fields/{name}/values?q=&limit=`** → `GetSearchFieldValuesHandler` (#434). Its
  private `GetSource(dbContext, name)` maps a field name to an `IQueryable<string>` of distinct whole
  values (genre/show_genre → `Genre.Name`, studio → `Studio.Name`, director → `Director.Name`, writer,
  actor, artist, tag/network → `Tag.Name` filtered by `ExternalTypeId`, collection, video_codec, album,
  plus in-memory `state`/`video_dynamic_range`/`content_rating`). **This is the closest thing to a
  generic distinct-value backbone already in the codebase** — but it returns values only, **no counts**,
  and applies no media-type scope.
- SPA consumer: `web/src/builder/rules/fieldCatalog.ts` (`useSearchFields`), which coerces the
  all-nullable generated `SearchField` into a clean `RuleField`.

### 3.8 The index has no faceting API

**`ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs`** exposes only `Search(query, smartCollectionName,
skip, limit, [defaultFields], ct)` returning `SearchResult(List<SearchItem> Items, int TotalCount)`
(+ a `SearchPageMap` jump-letter map). There is **no** `Facet` / `GroupBy` / term-enumeration method.
`LuceneSearchIndex` indexes text fields as analyzed `TextField`s (lowercased word tokens) and dates as
`yyyyMMdd` `StringField`s. This is decisive for Area 1 below.

---

## 4. Proposed design

### 4.1 The unifying value object

Introduce an internal group-by descriptor (Application layer, alongside `AutoTuneAxisMap`):

```csharp
// ErsatzTV.Application/Channels/AutoTuneGroupBy.cs (new)
public record AutoTuneGroupBy(
    string Field,                 // a SearchFieldCatalog.Name (Lucene field name), OR a derived key (see 4.4)
    AutoTuneMediaType MediaType); // Episode | Movie | MusicVideo | OtherVideo | Song | Any (see open Q)

public enum AutoTuneMediaType { Episode, Movie, /* … */ Any }
```

`AutoTuneAxisMap` becomes a **resolver + generic generator** rather than a per-axis switch:

- `AutoTuneGroupBy Resolve(AutoTuneAxis axis)` maps the three legacy axes to their group-by:
  - `TvShow` → `("show_title", Episode)` (special enumeration, see 4.2)
  - `TvGenre` → `("genre", Episode)`
  - `MovieGenre` → `("genre", Movie)`
- The `GenerateQuery` / `GenerateName` / `PlaybackOrderFor` / member-query functions take an
  `AutoTuneGroupBy` and become field-generic (4.3). The existing `AutoTuneAxis` overloads stay as thin
  wrappers `=> Resolve(axis)`-then-delegate, so every current caller and test keeps compiling and
  behaving byte-identically.

### 4.2 Area 1 — Generic distinct-value enumeration

**Recommendation: enumerate via the database (an extended #434 `GetSource` + count), NOT by faceting
the Lucene index.**

Rationale — faceting the index is **not the clean path here**, despite the index powering `search/fields`:

- `ISearchIndex` exposes no faceting/term-enumeration API (3.8). Adding one means new Lucene facet
  fields (`SortedSetDocValuesFacetField`) or a `MultiFields.GetTerms` walk in `LuceneSearchIndex`, a
  full **reindex**, and a cross-cutting change to a shared search primitive — large blast radius for a
  scheduling feature. (See MEMORY: "Verify a shared primitive covers ALL variants.")
- Analyzed `TextField`s store **lowercased word tokens**, not whole values — the exact reason #434
  sourced typeahead from the DB, not the Lucene term dictionary. Faceting `genre` would yield
  `science` / `fiction`, not `Science Fiction`. So even with a facet API, counts/labels would be wrong
  for the multi-word values that matter most.
- The DB already has the exact joins, and #434 already curates the per-field distinct-value source.

**Proposed generic enumeration:** a new Application service `IAutoTuneFieldEnumerator` /
`AutoTuneFieldEnumerator` with

```csharp
Task<List<AutoTuneFieldValue>> Enumerate(AutoTuneGroupBy groupBy, int minItems, CancellationToken ct);
public record AutoTuneFieldValue(string Value, int Count);
```

It reuses the **shape** of `GetSearchFieldValuesHandler.GetSource` but returns `(value, count)` scoped
to `groupBy.MediaType`, via three strategy families keyed on how the field is stored:

1. **Joined-table / metadata-collection fields** (genre, tag, network, studio, director, writer, actor,
   content_rating, collection, video_codec, album, artist, style, mood, …): a `GroupBy(name).Count()`
   over the metadata rows for the target media type. E.g. `genre` + `Episode` reproduces today's
   `EnumerateEpisodeGenres` exactly; `genre` + `Movie` reproduces `EnumerateMovieGenres`; `studio` +
   `Movie` is `MovieMetadata.SelectMany(m => m.Studios).GroupBy(s => s.Name)`. The per-`(field,
   mediaType)` navigation path is the one piece that stays a lookup table — but it is a **data table**
   (field → metadata set + collection selector), not scattered bespoke handlers, and it is unit-testable
   as pure query composition.
2. **Denormalized scalar fields** (`show_title` for the TV-show axis — a scalar on the show, counted by
   episodes): the existing `EnumerateTvShows` special case (title collapse + episode count) is preserved
   as the `("show_title", Episode)` strategy. This is why the legacy `TvShow` axis stays a named preset:
   its "collapse shared titles" semantics are worth keeping explicit.
3. **Derived fields** (decade from `release_date`, year, added-year): see 4.4 — an extra strategy that
   buckets a date/number column.

`minItems` filters, ordering is case-insensitive by value (matching today). Media-type scope is
**required** and comes from `AutoTuneGroupBy.MediaType`, resolving the "genre exists for both TV and
movies" ambiguity that `GenerateName`'s "Movies" suffix papers over today.

**Limits to state honestly:** this keeps a per-`(field, mediaType)` navigation table — it is *not*
zero-config for a brand-new field. A field in the catalog with no enumeration strategy (e.g. `plot`
fulltext, `minutes` raw number without bucketing) simply isn't offer-able as a group-by; the API must
**allow-list** group-by-able fields (mirroring #434's 404-for-unsupported-field contract) rather than
accept any catalog field and fail late.

**Count exactness vs. the members roll-up.** Preview counts come from the DB (exact, cheap, matches
#69's `sched.auto-tune-foundation` decision), while the **members** endpoint (4.5) stays on the live
`ISearchIndex.Search` roll-up (matches #384). Keep that split: DB for the "how many channels / how big"
preview, index for "exactly what will this one channel contain."

### 4.3 Area 2 — Generic query / name / playback-order generation

**Membership query.** Because catalog field name == Lucene field name, the generic membership query is:

```
type:{luceneType(mediaType)} AND {field}:"{EscapeLuceneValue(value)}"
```

`GenerateQuery(AutoTuneGroupBy, value)` produces this directly; the three legacy templates fall out as
special cases (`show_title`/`genre` + `episode`/`movie`). `EscapeLuceneValue` is unchanged. Fields that
are analyzed `TextField`s already work as double-quoted phrase queries today (`genre:"Science Fiction"`,
`studio:"Warner Bros"`), which is exactly how the RuleBuilder emits them — so we inherit the
RuleBuilder's proven grammar subset (`spa.smartcollection-rule-builder`, #176) rather than inventing a
new one.

**Channel name.** Default = the raw `value`. To keep the anti-collision property `GenerateName` has
today (TV "Comedy" vs movie "Comedy Movies"), derive the suffix from **media type**, not a hard-coded
axis: `Episode → value`, `Movie → "{value} Movies"`, others → a per-media-type suffix (`"{value}
Music"`, etc.), configurable. The user can already override per channel via the DetailPanel's `Name`
field, so this is only the default. The 50-char guard and pre-allocation drop stay.

**Default playback order.** Generalize the "single item plays in order, a group shuffles" heuristic:

- If the group-by is a **single-parent** grouping (today: `show_title`, where every value is one show's
  episodes) → `SeasonEpisode`.
- Every other field (genre, studio, director, decade, …) → `Shuffle`.

Encode this as a property of the strategy (`bool IsSingleParentGrouping`) rather than another `switch`.
The user override path is **unchanged**: `AutoTunedChannelRequest.Advanced.PlaybackOrder` already wins
over the axis/field default (see `CreateAutoTunedChannelsHandler` — `selection.Advanced?.PlaybackOrder
?? axisOrder`), and `WeightedShuffle` still overrides everything when a weighted plan is built.

### 4.4 Area 2b — Derived fields (decade)

`decade` is **not** a stored column and **not** in `SearchFieldCatalog`. It is a bucketing of
`release_date` (indexed as `yyyyMMdd` `StringField`; the query parser already supports range queries and
`released_inthelast` macros — `CustomMultiFieldQueryParser`). Proposal:

- **Enumeration:** an EF query over the target metadata's `ReleaseDate`, `GroupBy(d => d.Year / 10 * 10)`,
  producing values like `"2000s"` / `"1990s"` with counts.
- **Membership query:** a range query, e.g. `type:movie AND release_date:[20000101 TO 20091231]` — valid
  classic Lucene the parser already handles (`base.GetRangeQuery`), and safe against the ParseException
  literal-fallback trap `AutoTuneAxisMap` already documents.
- **Name:** the bucket label directly (`"2000s"`), no media-type suffix needed (decades don't collide
  across TV/movie the way genre does — though the same suffix rule can apply if desired).

This is the concrete example proving derived fields need **their own strategy** on all three axes
(enumerate / query / name), not just a field-name passthrough — hence the strategy interface in 4.2
rather than a single generic query string builder. **Open question 4.4-a:** does `decade` (and friends
like `release_year`) get added to the shared `SearchFieldCatalog` (so the RuleBuilder gets it too — but
the RuleBuilder would then need to compile it, which today it cannot), or does Auto-Tune carry an
**extension catalog** of derived group-by fields that is a superset of `search/fields`? Recommend the
latter for the first cut (Auto-Tune-only `GET …/auto-tune/fields`, §4.6) to avoid forcing a RuleBuilder
grammar change, and revisit unifying once the RuleBuilder can compile range/bucket operators.

### 4.5 Area 3 — API shape (additive-only)

Per `docs/api-conventions.md` (§2 "evolving a frozen DTO: deprecate-in-place, add the richer field,
never remove"; `/api/v1` frozen-additive since #286), **nothing is removed**. Changes:

**Preview request** — add an optional parallel selection; keep `Axes`:

```csharp
public record PreviewAutoTuneChannelsRequest(
    List<AutoTuneAxis> Axes,                 // DEPRECATED-in-place: legacy 3-axis presets, still honored
    int MinItems,
    int StartingNumber,
    List<AutoTuneGroupBySelection> GroupBy = null); // NEW, optional

public record AutoTuneGroupBySelection(string Field, string MediaType);
// Field  = a group-by-able catalog/derived field name; MediaType = enum name ("Episode"/"Movie"/…).
```

Handler resolves the union of `Axes` (via `Resolve`) and `GroupBy`, de-duplicated, preserving order.
An empty union stays the existing "at least one axis is required" 422. An unknown/`non-group-by-able`
field is a **422** (`BaseError.New`) — same fail-closed posture as #434's 404, but 422 here because it
rides a POST body, not a `{name}` route segment.

**Proposal response** — `AutoTuneProposalResponseModel` currently carries `Axis` (string). Add the
richer fields, keep `Axis` populated for legacy proposals (deprecate-in-place):

```csharp
public record AutoTuneProposalResponseModel(
    string Axis,          // DEPRECATED: "TvShow"/"TvGenre"/"MovieGenre" for legacy-axis proposals; "" for field-based
    string Value,
    string Name,
    string Number,
    int ItemCount,
    bool AlreadyExists,
    string Field = "",    // NEW: the group-by field name
    string MediaType = ""); // NEW: the resolved media type enum name
```

**Create request** — `AutoTunedChannelRequest` currently keys each channel on `Axis`. Add optional
`Field`/`MediaType`; when present they supersede `Axis` (which may be sent as a legacy default). Keep
`Axis` required-in-shape but treated as ignored when `Field` is set, OR — cleaner — make `Field`
present ⇒ the server ignores `Axis`. All other per-channel overrides (`TemplateId`, `Logo`, `Advanced`,
`Sources`) are unchanged:

```csharp
public record AutoTunedChannelRequest(
    AutoTuneAxis Axis,               // still first (positional back-compat); ignored when Field is set
    string Value,
    string Name,
    string Number,
    int? TemplateId = null,
    ArtworkContentTypeModel Logo = null,
    CreateChannelFromLineupAdvancedOptionsRequest Advanced = null,
    List<AutoTuneSourceWeightRequest> Sources = null,
    string Field = null,             // NEW: when set, drives generation via AutoTuneGroupBy
    string MediaType = null);        // NEW
```

Internally, `AutoTuneChannelSelection` (command) gains the same optional `Field`/`MediaType` (or, better,
a resolved `AutoTuneGroupBy? GroupBy`), and `CreateAutoTunedChannelsHandler` resolves
`selection.GroupBy ?? Resolve(selection.Axis)` at the top of `CreateOne`, then feeds the generic
`AutoTuneAxisMap` overloads. **No new endpoints, no route changes** for preview/create — schema-only
additions, so regenerate the OpenAPI trio (`v1.json` / `v1.d.ts` / `endpoint-index.md`) per §5, but no
`blazor-route-parity`/`domain-model` route rows change.

**Members endpoint** — `GET /api/v1/channels/auto-tune/members` currently binds `axis`. Add optional
`field`/`mediaType` query params (bind `[FromQuery] string field`, `[FromQuery] string mediaType`);
when `field` is present it supersedes `axis`. Same paging clamp. This keeps the DetailPanel working for
field-based proposals. (See Area 4b open question on whether "sources/weights" generalize.)

**New surface for the field list** (additive GET, mirrors `search/fields`):

| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/v1/channels/auto-tune/fields` | `GetAutoTuneFields` | List group-by-able fields (catalog subset + derived) with their supported media types |

Returns `List<AutoTuneFieldResponseModel(string Field, string Label, string[] MediaTypes, string
Kind)>` where `Kind ∈ {catalog, derived}`. This is the field-picker's data source and is
MCP-introspectable. It is the honest expression of the 4.2 "allow-list" — the SPA/MCP only offer what
the enumerator actually supports, instead of offering all 26 catalog fields and failing on `plot`.

### 4.6 Area 4 — MCP tools

`ErsatzTV.Mcp/ToolCatalog.cs` currently exposes **no auto-tune tools** (a pre-existing parity gap) but
already exposes `ersatztv_create_smart_collection`, `ersatztv_search`, `ersatztv_create_channel`. Add,
in the existing declarative style (flat args over `/api/v1`, writes gated on `ERSATZTV_ALLOW_WRITES`):

**Read — the vocabulary (so the model can discover valid fields before calling preview):**

```csharp
Get(
    "ersatztv_list_search_fields",
    "List the filterable search fields (name, label, type, group, enum values) — the shared vocabulary for search, the rule builder, and auto-tune.",
    "/api/v1/search/fields"),
Get(
    "ersatztv_list_auto_tune_fields",
    "List the fields you can group auto-tuned channels by, with each field's supported media types. Call this first to discover valid {field, mediaType} pairs.",
    "/api/v1/channels/auto-tune/fields"),
Get(
    "ersatztv_get_search_field_values",
    "List distinct values for one text search field (typeahead), optionally prefix-filtered.",
    "/api/v1/search/fields/{name}/values",
    [Str("name", "Field name from ersatztv_list_search_fields.", required: true, arg: In.Path)],
    [Str("q", "Optional case-insensitive prefix filter.", arg: In.Query),
     Int("limit", "Max values (1..50, default 50).", arg: In.Query)]),
```

**Read — preview (no writes; safe to run to plan a batch):**

```csharp
Get(   // note: the API is POST; MCP wrapper posts a body. If ToolCatalog only wraps GET-as-read,
       // expose as a Post but document it as non-mutating (it creates nothing).
    // Recommended: Post(...) with a clear "previews only, creates nothing" description.
    "ersatztv_preview_auto_tune",
    "Preview auto-tuned channels: one channel per distinct value of a group-by field, filtered to a media type. Creates nothing — returns proposed {name, number, itemCount, alreadyExists}. Discover fields via ersatztv_list_auto_tune_fields.",
    "/api/v1/channels/auto-tune/preview",
    ObjArray("groupBy", "Group-by selections; each { field: string, mediaType: string }.", required: true),
    Int("minItems", "Minimum items for a value to become a channel.", required: true),
    Int("startingNumber", "First channel number to allocate.", required: true)),
```

**Write — create (gated):**

```csharp
Post(
    "ersatztv_create_auto_tuned_channels",
    "Bulk-create auto-tuned channels from previewed selections. Each channel is independent (per-channel Created/Skipped/Failed). Get proposals from ersatztv_preview_auto_tune first, then pass the ones to create.",
    "/api/v1/channels/auto-tune",
    Int("templateId", "Batch channel template id (per-channel override allowed).", required: true),
    Str("group", "Channel group name (default \"Auto-Tuned\")."),
    ObjArray(
        "channels",
        "Channels to create; each { field: string, mediaType: string, value: string, name: string, number: string, templateId?: number }. Advanced/logo/per-source weights are omitted here (use the SPA/API for those).",
        required: true)),
```

Notes: (1) the create tool deliberately exposes the **field-based** shape (`field`/`mediaType`), not
the legacy `axis`, keying MCP on the shared vocabulary. (2) Complex per-channel overrides
(`advanced`/`logo`/`sources`) are intentionally out of the MCP body for v1 (same "deferred complex
writes" posture the ToolCatalog header already documents for schedule-item/playout writes). (3) These
tools require the OpenAPI/route additions in 4.5 to exist first (phased below). (4) Preview is
non-mutating; expose it as a read even though the HTTP verb is POST, and say so in the description so
the model doesn't gate it behind writes.

### 4.7 Field-catalog throughline (explicit)

The single unifying fact: **`SearchFieldCatalog.Name == Lucene field name == RuleBuilder field ==
`search/fields` field == the Auto-Tune group-by key**. One vocabulary now spans: Lucene index →
`search/fields` (#176) → facet typeahead `search/fields/{name}/values` (#434) → RuleBuilder
(`fieldCatalog.ts`) → MCP `ersatztv_search` → **Auto-Tune group-by (this spec)**. Generalizing Auto-Tune
onto it means a user picks a group-by field from the *same* dropdown they filter a SmartCollection by,
and a membership query generated by Auto-Tune is the *same* grammar a hand-written SmartCollection uses.
Derived fields (decade) are the one place this throughline stretches — handled as an Auto-Tune extension
(4.4) until the RuleBuilder can compile bucket/range operators.

---

## 5. Concrete API DTOs (collected)

See 4.5 for context; consolidated for implementers:

- `PreviewAutoTuneChannelsRequest` + new optional `List<AutoTuneGroupBySelection> GroupBy`.
- `AutoTuneGroupBySelection(string Field, string MediaType)` — request DTO, `ErsatzTV/Controllers/Api/Requests/`.
- `AutoTuneProposalResponseModel` + new `Field`, `MediaType` (`#nullable enable`, defaults `""`).
- `AutoTunedChannelRequest` + new optional `string Field`, `string MediaType`.
- `AutoTuneFieldResponseModel(string Field, string Label, string[] MediaTypes, string Kind)` — new,
  `ErsatzTV.Core/Api/Channels/`, `#nullable enable`, for `GET …/auto-tune/fields`.
- Command layer: `AutoTuneGroupBy(string Field, AutoTuneMediaType MediaType)`;
  `PreviewAutoTuneChannels` gains the resolved group-by list; `AutoTuneChannelSelection` gains the
  resolved `AutoTuneGroupBy? GroupBy`.
- `AutoTuneMediaType` enum — **define in `ErsatzTV.Core`** (not the Application command) so
  `Startup.UseStringEnumSchemas` renders it as a string enum in the spec (per §2 "clear-to-none" note:
  an Application-layer enum shows as bare `integer`).
- `AutoTuneAxis` stays; mark it deprecated in an XML comment. **Do not** remove or renumber it.

Every change above is schema-only ⇒ regenerate `ErsatzTV/wwwroot/openapi/v1.json`,
`web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` via `./scripts/update-openapi.sh` +
`npm run generate:api`, and add the `GET …/auto-tune/fields` row to `docs/api-conventions.md`'s
endpoint inventory (blocking `api-docs` CI job fires on `Controllers/Api/**` + `Core/Api/**`).

---

## 6. Backward compatibility

- **Wire:** `Axes` and the positional `AutoTunedChannelRequest(Axis, Value, Name, Number, …)` are
  preserved; every new field is optional with a back-compat default. An existing client sending only
  the 3-axis form gets byte-identical behavior (the `Resolve(axis)` wrappers reproduce today's exact
  query/name/order for `show_title`+Episode / `genre`+Episode / `genre`+Movie).
- **Response:** new `Field`/`MediaType` members default to `""` for legacy-axis proposals; `Axis` stays
  populated. Deprecate-in-place, never remove (`/api/v1` freeze).
- **Handlers/tests:** the internal `AutoTuneAxisMap` axis overloads remain as delegating wrappers, so
  existing handler and controller-route tests compile unchanged; the weights/remainder machinery
  (#425) is untouched for the axis path.
- **DB / migrations:** **none required** — no `TvContext` model change. `channel.origin-marker`
  (`ChannelOrigin.AutoTuned`) already stamps provenance and is orthogonal.

---

## 7. Testing strategy

- **Enumeration parity (characterization first):** before touching `PreviewAutoTuneChannelsHandler`,
  add golden tests asserting the 3 legacy axes' current `(value, count)` output on a seeded
  `InMemoryTvContext` fixture; then prove the generic `AutoTuneFieldEnumerator` reproduces them
  bit-for-bit via `Resolve`. (MEMORY: "Modernize with test-before/after guardrail.")
- **New strategies:** unit tests per `(field, mediaType)` strategy — `studio`+Movie, `director`+Movie,
  `network`+Episode, `tag`+Episode, and the derived `decade`+Movie (bucketing + range membership query).
- **Query generation goldens:** assert `GenerateQuery(groupBy, value)` for a set of fields incl. values
  with embedded quotes/backslashes (Lucene escaping) and a decade range query; assert the ParseException
  literal-fallback trap is avoided (parse the generated string through `SearchQueryParser`).
- **Name / order defaults:** genre TV vs movie suffix disambiguation; single-parent (`show_title`) →
  `SeasonEpisode`, others → `Shuffle`; Advanced override still wins; weighted plan forces
  `WeightedShuffle`.
- **API:** controller route-table test (`ShouldHaveActionRoute`) for the new `GET …/auto-tune/fields`;
  round-trip a field-based preview→create; assert unknown/non-group-by-able field → 422; assert legacy
  axis body still works.
- **Members:** field-based `members` returns a coherent source roll-up (or the documented "no
  weighting" behavior per open Q 4b).
- **MCP:** the ToolCatalog has a shape/registration test pattern — assert the new tools' schemas and
  that preview is not write-gated while create is.
- **OpenAPI sync:** `npm run check:api` + the `api-docs` job (mechanical, blocking).
- **Live E2E:** on `ersatztv-test`, preview + create "one channel per studio" and "one channel per
  decade"; verify each channel's SmartCollection resolves to the expected items (MEMORY: per-task
  reviews miss whole-feature defects; capture a real result).

---

## 8. Risks & open questions

1. **Per-source weights / remainder do not obviously generalize (highest risk).** The #425
   weights+remainder machinery (`BuildTvPlan`/`BuildMoviePlan`, `GenerateSourceQuery`,
   `SourceDiscriminatorClause`, `GenerateRemainderQuery`) is built entirely around "a source is a show
   (discriminator = `show_title`) or a movie (discriminator = `id`)." For a group-by on `studio` or
   `director`, what is a "source" within one channel, and does it have a stable rename-proof
   discriminator? For `decade`, the members are individual movies again (like MovieGenre), so it may
   generalize there; for `studio`/`network` there is no per-source id the way a show/movie has one.
   **Open question:** does field-based Auto-Tune ship **without** per-source weighting in v1 (single
   SmartCollection only, `Sources` rejected as 422 for field-based channels), deferring weights to a
   follow-up? Recommend **yes** — ship the generalization first, keep weights on the axis path only,
   and design a generic "source" model separately.
2. **Membership-query fidelity for arbitrary analyzed fields.** Catalog `text` fields are analyzed
   `TextField`s; a phrase query `field:"Some Value"` matches on tokenized terms and may mis-match values
   with punctuation, or values whose DB whole-form differs from the indexed tokens (the exact class #434
   avoided by sourcing typeahead from the DB, not the term dictionary). Enumeration (DB) and membership
   (Lucene) can therefore **disagree on the boundary** for some values — a channel could include
   slightly more/fewer items than its preview count. Needs a fidelity pass per field family; may force
   some fields (e.g. `content_rating`, which is split on `/`) to be excluded from group-by v1. Which
   fields are safe is an empirical open question.
3. **Derived-field catalog placement (decade).** 4.4-a: add `decade`/`release_year` to the shared
   `SearchFieldCatalog` (forces a RuleBuilder compile change it can't do today) vs. an Auto-Tune-only
   extension catalog (`GET …/auto-tune/fields`). Recommend the extension for v1; flag that this means
   the "one vocabulary" throughline has a documented seam until the RuleBuilder gains bucket/range
   operators.

**Lower-order open questions:** (a) `AutoTuneMediaType` granularity — do we support Music/OtherVideo/Song
group-bys in v1 or only Episode/Movie? (b) Should preview counts be capped/paged for a very
high-cardinality field (e.g. `actor` → thousands of proposals) before number allocation? (c) MCP: expose
preview as read (POST-but-non-mutating) vs. write — recommend read.

---

## 9. Phased delivery plan

**Phase 1 — Backend enumeration + generic generation + API (no SPA, no weights).**
`AutoTuneGroupBy` + `AutoTuneMediaType` (Core), `AutoTuneFieldEnumerator` with the joined-table +
denormalized + derived strategies, `AutoTuneAxisMap` generalized (axis overloads become wrappers), the
additive request/response DTO fields, `GET …/auto-tune/fields`, characterization + parity tests, OpenAPI
regen + doc updates + a `decisions.md` record (`sched.auto-tune-arbitrary-field`, supersedes/extends
`sched.auto-tune-foundation`). Field-based channels are single-SmartCollection only; `Sources` on a
field-based channel → 422 (open Q 8.1). **Shippable and testable via curl/MCP without any UI.**

**Phase 2 — SPA field-picker.** Replace the 3-axis picker with a field + media-type selector sourced
from `GET …/auto-tune/fields`, reusing the RuleBuilder's field-catalog UI patterns
(`web/src/builder/rules/fieldCatalog.ts`); DetailPanel/members wired for field-based proposals per
`spa.autotune-detailpanel-slideover`. Update `docs/spa-conventions.md`.

**Phase 3 — MCP tools.** `ersatztv_list_search_fields`, `ersatztv_list_auto_tune_fields`,
`ersatztv_get_search_field_values`, `ersatztv_preview_auto_tune` (read), `ersatztv_create_auto_tuned_channels`
(write-gated) in `ToolCatalog.cs`; update `docs/mcp.md`.

**Phase 4 (follow-up, separate issue) — generalized per-source weighting** for field-based channels,
once a generic "source + discriminator" model is designed (open Q 8.1).

---

## 10. Decision-record stub (for `docs/decisions.md` when Phase 1 lands)

```
key: sched.auto-tune-arbitrary-field
status: proposed
since: 2026-07-23
supersedes: (extends) sched.auto-tune-foundation
summary: Auto-Tune generalizes from the fixed 3-axis enum to an arbitrary (field, mediaType) group-by
  keyed on the shared SearchFieldCatalog vocabulary. Distinct-value+count enumeration is DB-sourced
  (extended #434 GetSource), NOT Lucene faceting (ISearchIndex has no facet API; analyzed text fields
  store tokens not whole values). Membership queries are generic `type:M AND field:"value"` reusing the
  RuleBuilder grammar; derived fields (decade) carry their own enumerate/query/name strategy via an
  Auto-Tune extension field list. The legacy AutoTuneAxis path is deprecated-in-place (additive-only),
  resolving to a group-by internally. Per-source weights (#425) stay on the axis path in v1.
```
