Cold adversarial review found a crafted numeric `?axis=5` binds past
[ApiController]'s auto-400 (ModelState valid), then AutoTuneAxisMap.GenerateQuery's
`_ => throw` surfaces as a 500 (no global exception filter). Short-circuit an
undefined axis to an empty result in the handler — matching #69's EnumerateAxis
`_ => []` graceful-empty pattern. Adds a regression test asserting no search runs.
Also simplifies the redundant pageSize lower clamp (review N4): the `<= 0 ? 100`
guard already floors it, so `Math.Clamp(_, 1, 200)` -> `Math.Min(_, 200)`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/v1/channels/auto-tune/members lists the distinct content sources a
proposed auto-tune channel's server-generated SmartCollection resolves to —
parent shows for the TV axes (ItemCount = query-matching episodes), movies for
the movie-genre axis — reusing the existing PagedLibraryBrowseItemsResponseModel
(no new schema). The handler runs the server-owned AutoTuneAxisMap.GenerateQuery
through ISearchIndex (client never sends Lucene, per #69 PR1) and rolls matching
leaf items up to their distinct sources, mirroring GetSmartCollectionItems so the
DetailPanel preview matches what the built playout will contain.
Backend child of #383 (Auto-Tune DetailPanel milestone); read-only, cold review
acceptable. Handler + controller tests (9 new). OpenAPI + endpoint-index
regenerated; d.ts unchanged (reuses existing schema). Docs: api-conventions §5,
decisions.md 2026-07-17, domain-model.
fixes#384
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pin bump for the image rebuilt from the review-fix commit (the two-step working
as designed — the new ci-image-pin guard demanded it).
Also fixes a real bug in that guard, caught locally before CI: it compared
abbreviated shas, but git auto-scales abbreviation length with object count, so
the tag ci-image.yml builds from a fetch-depth:1 shallow clone is 7 chars while
%h on the guard's full clone is 8 — it would have failed on every run. Now
resolves both sides to full shas via rev-parse, which also catches a pin that
names no commit at all.
Verified non-vacuous locally: PASS on the real pin, FAIL on a stale pin, FAIL on
an unresolvable one.
Refs #390
Cold adversarial review findings:
- MEDIUM (Renovate generates pin drift): add a blocking ci-image-pin job. Renovate
manages docker/ci/Dockerfile's base pins but cannot bump an opaque :<sha> in
container.image, so a base bump would publish a new image, test the OLD one, and
merge with the Dockerfile disagreeing with the pin. The guard fails when the pin
isn't the last commit to touch docker/ci, or when the 5 jobs pin different tags —
making the documented two-step enforced rather than remembered.
- MEDIUM (cron was a no-op): the weekly rebuild updated nothing (jobs pin :<sha>) and
buildcache would have restored the apt layer verbatim, collecting none of the base
updates it existed for. Added no-cache on the schedule path and rewrote the comment
to state what it actually is: a build canary + a fresh :latest for the next bump.
- LOW: FFMPEG_TAG was referenced in the Dockerfile + docs but never existed (the FROM
is hardcoded); reworded both.
- LOW: paths: filtered the exact file while the docs claimed docker/ci/** — use **.
- NIT: docs oversold ENTRYPOINT reset as a gotcha; act overrides it anyway. Marked
defensive.
Refs #390
The migrations job died in 0.13s with 'set: Illegal option -o pipefail'. Root
cause: inside a container: act_runner does not default to bash, it falls back to
'sh -e {0}' (dash), since it cannot assume bash exists in an arbitrary image.
Every multi-line script in this workflow is bash (set -o pipefail, arrays,
shopt, mapfile), so dash fails them immediately.
The format job passed only because it already declared shell: bash explicitly.
Declare it once at the workflow level instead of per step; non-container jobs
already defaulted to bash, so nothing changes for them.
Documented in docs/ci-cd.md as a container gotcha — it surfaces only once a job
is containerized and does not look like a shell problem.
Refs #390
The #73 selector packet reported #385/#386 as deps:clear when both were
blocked, and missed #383 (the only priority:medium in tier 1) by ranking on
issue number. Either error alone produces a bad pickup, and the kickoff's
winner-only recheck cannot catch a wrong winner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fixes#73#73's premise ("ErsatzTV has no native date-conditional scheduling today")
is false. The predicate (IAlternateScheduleItem) is shipped on both engines,
evaluated by AlternateScheduleSelector, reachable from the SPA, and already
unit-tested — so the only real gap was discoverability, and it was a docs gap:
the mechanism was documented as a mechanism, never as a task.
- channels.md: task-shaped "Recipe: seasonal / holiday programming" for both
engines, plus the gotchas — chiefly that blank StartYear/EndYear is what
makes a range repeat every year (AlternateScheduleSelector.cs:32-40), that
the override branch needs BOTH years, and that explicit years disable
wrap-around detection.
- domain-model.md: glossary row (mirrors the #77 "already exists" row).
- decisions.md: entry recording the verdict and the rejected asks — notably
that "prioritize collection X during a date range" is #70's weighting
problem, not a second primitive built here while #70 is mid-flight.
No production code changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Converts test/migrations/functional-e2e/api-docs/format to run inside
ersatztv-ci:6a9e27c via container:, removing per-run setup-dotnet, setup-node,
apt-get ffmpeg and dotnet tool install. NuGet/npm caches stay (project deps);
setup-node's implicit npm cache is replaced with an explicit actions/cache.
Rolled-up wins found while auditing every job's steps (evidence in #390):
- Drop the apt-ffmpeg step (110s/run). It was never load-bearing: the app's only
startup ffmpeg touch is a LogWarning from FFmpegLocatorService, and the harness
drives curl-only contracts that never transcode. The image ships prod-identical
ffmpeg 8.1.2 anyway, which #299's seeded-media follow-ups will need.
- Bake dotnet-ef + reportgenerator into the image (2 per-run tool installs gone;
versions now live in one tracked place).
- Move api-docs (5s) and format (37s) to the idle 'small' lane — they were each
queueing ~29min in the saturated ubuntu-latest lane. Also drops that lane from
5 jobs to 3. Only possible because container: makes them self-contained.
Measured, not assumed: the image itself is worth only ~15-40s/job. The dominant
costs are queue wait (server-management#604) and 742s of redundant compilation
(#398) — both filed rather than silently bundled here.
Refs #390
The build-time smoke test failed the image build even though the toolchain was
healthy: reportgenerator prints its version banner then exits 1 ("No report
files specified"), so it has no clean version probe. Probe the dotnet-tool shim
on PATH instead. dotnet --info confirmed the SDK + .NET 10.0.10 runtimes resolve
correctly on the ffmpeg base (ICU/glibc fine).
Refs #390
Layers the .NET 10 SDK + Node 22 onto our own ersatztv-ffmpeg base (the same
COPY --from=.../usr/share/dotnet pattern docker/Dockerfile uses for prod), and
bakes the dotnet-ef + reportgenerator global tools the jobs installed per run.
Builds/pushes 192.168.1.95:3000/timothy/ersatztv-ci:<sha> via a separate
workflow so a ~2GB toolchain image isn't rebuilt on every commit.
Refs #390
Post-rebase onto main (which merged #77's Classic_clock_padded golden): both fixtures
seed the shared in-memory DB and independently picked Channel Number "3"/GUID ...0003,
tripping a UNIQUE constraint. Move the shuffle fixture to "4"/...0004. Golden output is
unchanged (it snapshots times/titles; shuffle determinism comes from playout.Seed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the one substantive nit from the cold adversarial review of PR #391: the
true branch of ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle (routing through
MultiPartEpisodeGrouper) had no direct regression net — the goldens and existing unit
tests all use false. Add a test that observably distinguishes the branches: four
episodes where two are a "(1)"/"(2)" multi-part pair collapse to 3 groups under
keepMultiPartEpisodesTogether=true vs 4 groups under false. Test-only; no production
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eliminate the one cross-engine reach-in in the scheduler: PlaylistEnumerator called
PlayoutBuilder.GetGroupedMediaItemsForShuffle / GetCollectionItemsForShuffleInOrder as
statics (one engine reaching into another engine's class). Move both helpers verbatim to
a new public static ShuffleSourceBuilder in ErsatzTV.Core/Scheduling (sibling to the
also-static MultiCollectionGrouper / MultiPartEpisodeGrouper; deps passed as parameters,
not DI). Classic (PlayoutBuilder) and Playlist (PlaylistEnumerator) now share this one
place to build shuffle sources.
One intentional signature change: GetGroupedMediaItemsForShuffle takes
(bool keepMultiPartEpisodesTogether, bool treatCollectionsAsShows) instead of a
ProgramSchedule (verified those are the only two properties it read). This deletes
PlaylistEnumerator's fake `new ProgramSchedule { KeepMultiPartEpisodesTogether = false }`
(its TODO becomes an honest false, false) and gives callers without a ProgramSchedule
(#176, #70) a schedule-entity-free entry point.
Scope is deliberately (a)-only: engine separation preserved, no god-factory. Block stays
its own family; the Scripted/YAML construction duplication is a separate follow-up gated
on #381. See docs/decisions.md 2026-07-17.
Behavior-preserving: the characterization net added in the previous commit (classic-shuffle
golden byte-identical, PlaylistEnumerator reach-in sequence unchanged) plus new direct
ShuffleSourceBuilder unit tests (multi-collection vs fake-multi-collection lookup;
multi-part grouping on/off) all green. Full Core.Tests: 546 passed. PlayoutBuilder.cs
also de-BOM'd + whitespace-normalized per the fix-as-you-touch convention (#311).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #163 golden net (PlayoutBuildGoldenTests) only pins PlaybackOrder.Chronological
on Classic + Block. The paths #380 refactors — the two PlayoutBuilder shuffle-source
statics and PlaylistEnumerator's cross-engine reach-in into them — had zero coverage,
so "goldens green before & after" would be a false safety signal. Extend the net to
exactly the moved paths:
- PlayoutBuildGoldenTests.Classic_shuffle: Classic builder + PlaybackOrder.Shuffle,
pinned playout.Seed via Continue mode (Reset randomizes the seed) so the shuffle is
deterministic and TZ-independent. Exercises GetGroupedMediaItemsForShuffle + the
ShuffledMediaCollectionEnumerator wiring.
- PlaylistEnumeratorTests.ReachIn_Shuffle_And_ShuffleInOrder_Items_Are_Characterized:
a playlist with a Shuffle item and a ShuffleInOrder item, pinned seed, asserting the
emitted id sequence. Covers both PlaylistEnumerator.Create reach-in call sites
(:174 GetGroupedMediaItemsForShuffle, :185 GetCollectionItemsForShuffleInOrder) that
the extraction rewires.
No production changes. Verified deterministic across repeated runs and under TZ=UTC /
Asia/Kolkata.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bulk mirror sweep (#388): author/rebuild a prototype mockup for every shipped SPA screen so
the Claude Design project (eb3b6122 / local design-system/) faithfully reflects prod.
- 40 screens authored (mockups: visual/layout parity, no logic) via a fan-out workflow;
8 stale prototypes rebuilt (Dashboard, ChannelBuilder, Guide, Schedules, Playouts, Settings, …),
32 net-new (media/sources/system/auth). Retired Epg/ScheduleEditor/ScheduleLibrary.
- New screens.js = single source of truth for the inventory; Shell.jsx nav + app.html screen map
both build from it (nav groups mirror web/src/app/routes.tsx). app.html is now hash-routed.
- All 42 views verified rendering headlessly across the 3 themes (126/126, 0 real errors).
- design-sync-reminder.sh hook (+ settings.json): mechanical, fail-open, once/session nudge to
pull-first (PreToolUse Write|Edit on web/src/**.{tsx,css}) and mirror/push-back (Stop) — keeps
the design system from drifting from prod going forward.
- docs/design-sync.md: document the bulk-sweep structure + the reminder hook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the MERGEABLE cold review:
- Golden: document that TZ-independence relies on PadToNearestMinute=15
dividing every IANA offset; warn against regenerating with a non-15 divisor.
- decisions.md: include DecoDefault in the ProjectFlood coalesce list.
[decisions-edit] corrects a factual omission in the same-PR entry added this session.
Refs #77
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#77's core (pad/snap schedules to :00/:15/:30 via filler) already exists —
FillerMode.Pad + PadToNearestMinute (Classic), pad_to_next/pad_until
(Sequential); Block is inherently time-anchored. No production code change;
this locks the behaviour end-to-end and documents that it exists.
- PlayoutBuildGoldenTests.Classic_clock_padded: a PostRoll FillerMode.Pad(15)
preset through the real PlayoutBuilder snaps content to :15 (golden +
explicit quarter-hour assertion). Splits Verify -> CompareGolden for reuse.
- ChannelGuideProjectorClockPadTests: the guide projection coalesces trailing
filler so programmes STOP on the padded boundary (XMLTV half).
- docs: decisions.md (2026-07-17 entry), domain-model.md (clock-boundary row),
testing.md (test map + count 540->542).
Deferred (UI, blocked on #388): one-click per-channel/schedule clock-align
toggle + 60-min increment option.
Refs #77
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `?milestones=<name>` filter returns the whole open-issue list (not a
filtered set) when the milestone title contains special chars, mis-tiering
issues during queue selection. Recipe: fetch open issues once, filter locally
on `.milestone.title`. Also notes the dependencies-API owner/repo/index form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backlog grooming (2026-07-17): the kickoff cascade now excludes any issue with an
open blocked-by dependency (Gitea GET /issues/{n}/dependencies; auto-clears when the
blocker closes) so a blocked issue is never picked ahead of its blocker. Adds the
parked->closed convention and the bundle model: related work is grouped via active-arc
milestones or cross-reference comments and detected on pickup via milestone + backlinks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the cold-review findings on PR #389:
- Medium: selection was keyed by proposal name, coupling two same-named
proposals (e.g. a show titled "Comedy" and the "Comedy" genre) so toggling
one flipped both. Now keyed by axis+value (unique); adds a regression test.
- Low: zero channel templates now shows a hint on Configure and the Create
tooltip explains the missing template instead of a misleading positive label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New `/app/auto-tune` screen driving the PR1 endpoints: pick metadata axes
(TV Shows / TV Genres / Movie Genres) + defaults, preview proposed channels
grouped by axis with per-row/per-group selection and already-exists dedup,
then bulk-create with a Created/Skipped/Failed summary. Additive/non-destructive.
The per-channel DetailPanel from the design iteration is deferred to #383
(new endpoints backlogged) — this ships the 3-step wizard only.
- web/src/api/autoTune.ts (+test), web/src/screens/AutoTuneScreen.tsx (+test)
- route/nav (routes.tsx, ScreenContent.tsx), api barrel, App nav-smoke test
- docs: domain-model (route), blazor-route-parity (net-new SPA screen)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the cold adversarial review of PR #382 (MERGEABLE-WITH-NITS):
- Block TZ guard: BaseUtcOffset -> GetUtcOffset(Start). BaseUtcOffset is zero
year-round for DST zones like Europe/London, so it would pass in a summer-dated
fixture where London != UTC; GetUtcOffset pins the actual build instant and is
correct regardless of fixture date. (Safe today — mid-Jan fixture — but removes
the latent fixture-date dependency the reviewer flagged.)
- Document the determinism invariants the fixture relies on: golden captures raw
builder output (pre-trim AddedItems), Classic is TZ-independent for the captured
fields (no guard needed), and ResetPlayout's random Seed can't perturb the
Chronological fixture (RandomStartPoint/ShuffleScheduleItems false, distinct
release dates).
Re-verified: TZ=UTC both goldens pass; TZ=America/New_York Block skips, Classic
passes; no golden drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reset only reseeds Playout.Seed for Classic playouts (PlayoutBuilder); Block/
Sequential/Scripted rebuild deterministically from the existing seed, so
reshuffle was a silent no-op for 3 of the 4 supported kinds (C1). Fix:
ReshufflePlayoutHandler now sends ErasePlayoutHistory (reseed + clear anchors/
history, the only primitive that reseeds all four kinds) before enqueueing
BuildPlayout(Reset).
Also: correct docs/decisions.md's false "Reset already reseeds..." claim,
fix the SPA reshuffle test mock to return 202 (matches the real endpoint),
and gate the seed-help text to the resettable kinds (was showing even for
ExternalJson/None where no Reshuffle button exists).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Iterated Auto-Tune prototype pulled back from the Claude Design working copy
(project 4eb64b16) into design-system/. The 3-step wizard (Configure → Preview
→ Create) is PR2; the per-channel DetailPanel it also contains is deferred to
the #383 arc (#384/#385/#386) per design-first — new endpoints backlogged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs — a
golden-file net that snapshots the PlayoutItems each builder produces over a
pinned build window; the scheduling counterpart to the M3U (#11) / XMLTV (#28)
goldens. This is the regression net that de-risks the scheduling-refactor
chain (#380 -> #70/#71/#176).
Coverage this slice:
- Classic (PlaybackOrder.Chronological)
- Block (TimeZoneInfo.Local-guarded to UTC via Assume: runs in CI, skips
gracefully under a non-UTC TZ; a real TZ seam is #380's scope)
Determinism: the builders read no wall clock (time enters only via the
caller-supplied start), so a pinned start is fully deterministic. Snapshots the
raw UTC Start/Finish, not the *Offset properties (which .ToLocalTime()). Regen
via ETV_UPDATE_PLAYOUT_GOLDENS (deliberately separate from ETV_UPDATE_GOLDENS).
Both goldens proven non-vacuous.
Sequential (YAML) + Scripted goldens tracked as a follow-up in #381.
Docs: docs/testing.md updated (third golden net + env var + Block TZ guard).
fixes#163
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 3 of ersatztv#71 (reshuffle-playout). Appends Seed as the last
positional field on PlayoutNameViewModel, threading it through the
Mapper list projection, GetPlayoutByIdHandler detail projection, and
the four Update*PlayoutHandler VM constructions, then exposing it on
PlayoutListItemResponseModel/PlayoutResponseModel via the controller's
ToResponse/ToListItemResponse mappers.
ChannelControllerTests.MakePlayout was an additional construction site
not listed in the task brief (positional-record break); updated to
append 0 for Seed alongside the brief's ErsatzTV.Tests changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The go-live arc (#237) is closed; add a 'Current phase' section stating the
terminal transition to backlog mode + structural guidance (empty arc/gate/review
tiers are the expected steady state; fall through to the priority-label cascade).
Drift-proof: terminal fact + structure only, no named frontier issue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
- 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
Consolidate docs/decisions.md (1923 -> 1028) by extracting four cohesive
topic clusters into docs/decisions/ (optimistic-concurrency,
api-auth-security, release-ci-governance, spa-modularization) — content
relocated verbatim (lossless; all rationale + reversals preserved), main
Index rebuilt to reference the topic files plus the remaining in-file
entries, docs/README.md points back at the decisions Index. Add the
v26.9.0 row to the ci-cd.md version table.
refs #340
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>