Files
ersatztv/docs/superpowers/specs/2026-07-16-auto-tuning-design.md
T
timothy f31476e012 fix(69): auto-tune review fixes — null channels, orphaned SmartCollection, oversized preview names, doc drift
- CreateAutoTunedChannelsRequest.ToCommand(): guard null Channels (was NREing on
  a request body that omits "channels", causing HTTP 500).
- CreateAutoTunedChannelsHandler.CreateOne: when CreateChannelFromLineup returns
  Left (Skipped/Failed), roll back the just-created SmartCollection via
  DeleteSmartCollection so retries don't fail on SmartCollection-name uniqueness.
  Best-effort; the delete result does not change the outcome.
- PreviewAutoTuneChannelsHandler: filter out proposals whose generated name
  exceeds the 50-char Channel.Name limit before number allocation, so numbers
  aren't wasted on proposals that can never be created.
- docs/superpowers/specs/2026-07-16-auto-tuning-design.md: fix field-name drift
  in JSON examples (proposedNumber -> number, error -> reason) to match the
  actual AutoTuneProposal/AutoTuneChannelOutcome DTOs.

Refs #69
2026-07-16 22:18:52 +02:00

9.6 KiB

Auto-Tuning: generate channels automatically from library metadata (#69)

Status: Design approved 2026-07-16. Implementation pending. Issue: ersatztv#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
}

Response — a list of proposed channels:

[
  { "axis": "TvShow", "value": "The Office", "name": "The Office",
    "number": 500, "itemCount": 201, "alreadyExists": false },
  { "axis": "MovieGenre", "value": "Action", "name": "Action Movies",
    "number": 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": <int>,
  "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", "reason": "…" }
  ],
  "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.