Commit Graph
254 Commits
Author SHA1 Message Date
timothyandClaude Opus 4.8 d2d678aae8 test/docs(404): add unrelated-edit round-trip test; correct MultiCollection-only rationale
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m10s
Review + live-E2E follow-ups (no behavior change):
- Add a test proving the canonical silent-reset trap directly: a rename-only save
  round-trips both weights untouched (the prior test only edited the weight it
  asserted). Cold review nit.
- Correct the rationale in itemRules.ts + decisions.md: WeightedShuffle is
  MultiCollection-only in the SPA for *meaningfulness* (per-source weights need
  2+ sources), NOT because the classic write path rejects it — live-E2E confirmed
  the classic engine ACCEPTS it on a plain Collection (200) and degrades to
  fair-share. The rejection is on the separate playlist/block write paths, whose
  editors keep their own order lists and already omit it.

Live-E2E (real API): weighted multi-collection create + read round-trips weights;
rename-only PUT preserves them (no silent reset); WeightedShuffle persists on a
classic MultiCollection schedule item. Ratio itself is pinned by the existing
PlayoutBuildGoldenTests.Classic_weighted (3:1), untouched by this SPA change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:39:29 +02:00
timothyandClaude Opus 4.8 d639946b5c feat(404): weighted-distribution SPA — per-source weight inputs + WeightedShuffle order
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m39s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 24m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The UI half of #70 (backend + API shipped in PR #402). Pure SPA + docs — no new
endpoint/DTO (`weight` was already on MultiCollectionItemRequest/Response, and
`WeightedShuffle` already in the PlaybackOrder enum).

- Multi-collection editor (`/app/multi-collections`): per-source weight input
  (1..1000, mirroring the API validator), a computed % share (3:1 shows 75/25),
  and a "Reset to fair share" action (fair-share = all weights 1, decisions.md
  2026-07-17 — a reset, not a separate mode). Weight round-trips through the
  draft (read in itemsFromMultiCollection, written in toItemRequest) so the
  replace-all PUT never silently resets it.
- Classic schedule editor: `WeightedShuffle` offered as a Playback Order ONLY
  for MultiCollection sources (itemRules `MULTI_COLLECTION_ORDERS`) — it needs
  per-source weights and the write path rejects it elsewhere. Excluded from
  fillWithGroup like ShuffleInOrder (PlayoutBuilder schedules fill-groups
  per-group, incompatible with whole-collection weighted share).
- `Input` gained min/max/inputMode/onBlur passthroughs for bounded numeric
  fields (reusable by #425's weight UI); weight held as a string for smooth
  editing, clamped on blur and at save so an out-of-range value never 400s.
- Docs: domain-model + spa-conventions (replace-all round-trip trap, bounded
  numeric input pattern) + decisions.md entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:28:12 +02:00
timothyandtimothy df8c5202d6 fix(409): report never-scanned LastScan as null for API/MCP parity (migration + read coercion) (#459)
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build CI Toolchain Image / Build & push CI image (push) Failing after 10m41s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m58s
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 19:39:25 +00:00
timothyandtimothy b1521f9047 fix(409,447): LibrariesScreen 'Never scanned' label + stabilize flaky scan-poll test (#451)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:18:45 +00:00
timothyandtimothy 11ec07ee1b fix(367): correct Plex budget-exhausted guidance when no servers exist (#450)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m10s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m38s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20m18s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:15:37 +00:00
timothyandClaude Opus 4.8 8cef07a673 fix(396): mark active route in a default-collapsed group; fix mobile brand bar
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Has been cancelled
Cold-review findings:
- #1 (CONFIRMED): deep-linking/reloading into a route inside a default-collapsed
  labeled group (e.g. /app/settings, /app/libraries) left the expanded sidebar
  with no active indicator, violating "active route marked in both states". Now
  the group that contains the active route is force-shown (and its header shows
  expanded) WITHOUT persisting — navigating away reverts to the stored preference.
  Regression test added.
- #2 (mobile): a persisted-collapsed state hid the brand logo+wordmark while the
  toggle is also hidden at <=980px, leaving an empty header. Restore the brand in
  the mobile query (collapse is desktop-only).

Verified live (Playwright): /app/settings deep-link shows Settings active +
aria-current, System auto-revealed, Media stays collapsed, nothing persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:43:33 +02:00
timothyandClaude Opus 4.8 eb47aed767 feat(396): collapsible sidebar + nav-group accordions
Add two independent, persisted collapse states to the shell sidebar:
- a header toggle that collapses it to a 60px icon rail
- collapsible accordions per labeled nav group (Media, System);
  the unlabeled Primary group is always open, default-collapsed groups

State + persistence live in web/src/app/sidebarState.ts (useSidebarState);
AppShell stamps ctv-app-shell-collapsed on the shell root and the rail look
is CSS-driven. Two namespaced localStorage keys (ctv-sidebar-collapsed,
ctv-sidebar-groups) per the persisted-UI-state convention. In the rail,
accordions are ignored (all items shown icon-only, label kept in the a11y
tree + surfaced as a title tooltip, badges as a corner dot); active-route
indicator works in both states; grid-column transition respects
prefers-reduced-motion.

Tests: colocated sidebarState.test.ts + a new describe in App.test.tsx
(default-collapsed, accordion toggle+persist, rail, reload persistence).
Docs: spa-conventions §13 + decisions.md 2026-07-18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:43:33 +02:00
timothyandClaude Opus 4.8 ac7965dee4 feat(293): paginate GET /api/v1/search/all-items to cap DoS exposure
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m10s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m39s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14m31s
The all-items endpoint fired ten index searches with limit:0 (every hit), so a
broad authenticated query materialized the whole index into one response. Add
optional pageNum/pageSize (clamped 1..1000; pageNum 0..2_000_000 so skip can't
overflow int) and an additive per-kind Totals on the response; the SPA add-all
flow now pages to completeness instead of a single unbounded fetch.

- SearchController.SearchAllItems: clamp params (Logs §1 precedent), map Totals
- QuerySearchIndexAllItemsHandler: skip=pageNum*pageSize, limit=pageSize, read
  SearchResult.TotalCount per kind
- SearchResultAllItemsResponseModel: additive Totals (frozen-v1-safe)
- web/src/api/search.ts: getSearchAllItems paging params + getAllSearchItemIds
  (pages until each kind hits its total; empty-page safety break)
- tests: controller clamp/thread/totals, handler skip/limit/totals, SPA paging
- docs: decisions.md 2026-07-18 (#293), api-conventions.md §5; regenerated OpenAPI

Design: issue option (a) full pagination, operator-confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:09:06 +02:00
timothyandClaude Opus 4.8 e364b338e6 feat(425): per-source rotation weights + query corrections for auto-tune channels
Auto-tune channels can now carry per-content-source rotation weights (weighted
round-robin, e.g. 3x Show A / 1x Show B) and query corrections (exclude /
add-untagged), supplied at bulk-create time via an optional
`sources: [{sourceId, weight, excluded}]` on each AutoTunedChannelRequest.

Design (Option A, reuse #70): when a source is customized the channel is backed
by a system-owned MultiCollection of per-source SmartCollections carrying the
weights, with PlaybackOrder.WeightedShuffle -- the exact path
WeightedShuffleCollectionEnumerator already consumes. All-default weights keep
the #69 single-SmartCollection fair-share shape.

- Discriminators: TV -> live show_title:"X" (episodes carry no parent-show id in
  the index); movies -> stable id:{mediaItemId}.
- Materialization is axis-dependent: TV materializes every base show individually
  (un-weighted shows keep per-show fair-share) + a live remainder at weight 1;
  MovieGenre materializes only touched movies + one count-weighted remainder.
- Remainder = (base) AND NOT (materialized union excluded) -- a partition.
- New nullable OwnedByChannelId on SmartCollection + MultiCollection
  (dual-provider migration); owned rows are hidden from the collection lists and
  cascade-cleaned on channel delete.

Tests: AutoTuneAxisMap query/partition units; DB-backed weighted-path handler
tests (TV materialize-all, movie count-remainder, exclusion, no-customization
fallback); delete-cleanup. Docs: decisions.md, domain-model.md, api-conventions.md;
OpenAPI trio regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:52:06 +02:00
timothy 3b5a78a08f fix(176): compile date values to index yyyyMMdd format; guard Advanced toggle
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17m57s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 20m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 25m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 44m33s
2026-07-18 02:43:54 +02:00
timothy ea6b509016 fix(176): guard Builder toggle for out-of-subset queries; resync query on reseed 2026-07-18 02:41:50 +02:00
timothy 8483af41fc docs(176): plan Task 8 — guard Builder toggle + resync query (matches shipped fix) 2026-07-18 02:41:50 +02:00
timothy 60e8655a00 feat(176): Builder|Advanced toggle in the SmartCollection dialog 2026-07-18 02:41:50 +02:00
timothy 2763f65b35 test(176): cover RuleBuilder field-change operator/value reset 2026-07-18 02:41:50 +02:00
timothy ceb9069bae feat(176): RuleBuilder component 2026-07-18 02:41:50 +02:00
timothy 7d553ef00c refactor(176): expose clean non-null RuleField from useSearchFields 2026-07-18 02:41:50 +02:00
timothy 30a8f67e8e feat(176): field-catalog api + useSearchFields hook
Task 6: getSearchFields() client fn + SearchField type in api/search.ts,
and the useSearchFields() hook in builder/rules/fieldCatalog.ts that
reshapes the catalog into fieldTypes (name -> FieldType) and byGroup
views for the rule builder (Tasks 7-8).

Generated SearchFieldResponseModel has all-nullable fields (name/label/
type/group/values), unlike the brief's non-null assumption -- adapted by
dropping fields with a null name and defaulting group to 'Other' / type
to 'text' rather than indexing under undefined.
2026-07-18 02:41:50 +02:00
timothy 03646a2ed9 test(176): compile/parse round-trip property test 2026-07-18 02:41:50 +02:00
timothy 218e1810cb fix(176): total escaping so wildcard/quoted values round-trip (parse review) 2026-07-18 02:41:50 +02:00
timothy 0b5258533a feat(176): Lucene-subset parser (exact inverse of compiler) 2026-07-18 02:41:50 +02:00
timothy bc524d0b53 fix(176): split fulltext matches/notMatches from text contains 2026-07-18 02:41:50 +02:00
timothy fd786da649 feat(176): rule model + Lucene-subset compiler 2026-07-18 02:41:50 +02:00
timothy ae22107d98 feat(176): GET /api/v1/search/fields endpoint + regenerated api artifacts 2026-07-18 02:41:50 +02:00
timothy 56fffacfd4 fix(386): address cold-review findings on the DetailPanel
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m16s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 17m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cold adversarial review (no blockers, 3 real Mediums):
- Query&size "Order" row ignored the Shuffle toggle (dead ?? fallback showing
  the static axis order); now reflects shuffleOn, consistent with the subtitle.
- Clearing Name/Number to '' flagged the row Edited + armed the unsaved-changes
  guard, but the payload reverted to the proposal default — the two "edited"
  derivations disagreed. overrideEdited now ignores an empty (inherited) value.
- runPreview did not clear per-channel overrides, so edits (incl. pinned numbers)
  silently carried into a re-generated batch → collision risk. Fresh preview now
  resets overrides/detailKey/logo cache.
Tests: empty-name-not-Edited + re-preview-clears-overrides.

Live-E2E (local instance, seeded TV library): preview → Configure → toggle
Shuffle → Create verified end-to-end; the created channel's schedule shows
PlaybackOrder=Shuffle (overridden) vs SeasonEpisode (axis default), confirming
the per-channel override flows UI → SPA → backend → playout.

Refs #386
2026-07-18 01:31:40 +02:00
timothy f44eee85c5 feat(386): Auto-Tune per-channel DetailPanel slide-over (SPA)
Adds a right-hand "Configure" slide-over to each Auto-Tune Preview row, making
a proposed channel editable before bulk-create — against the shipped #384/#385
backend only, so no control lacks a wire target.

- New reusable SlideOver primitive (components/overlay.tsx), sharing a
  useOverlayBehavior hook (focus/scroll-lock/Escape/scrim) with Dialog.
- Extract the Channel Builder's advanced-options model to builder/advancedOptions.tsx
  (enum catalogs, ADVANCED_KEYS, effectiveValue, INHERIT/omit useAdvancedOverrides
  hook); ChannelBuilder imports it unchanged (its tests pass byte-for-byte). The
  DetailPanel writes its own field JSX over the same hook — shared logic, per-screen
  layout.
- Panes: identity (name/number + logo upload), Playback (Shuffle/Always-playing →
  advanced.playbackOrder/playoutMode), per-channel template picker, Advanced
  disclosure, lean read-only Query&size, read-only Content-sources via GET /members.
- getAutoTuneChannelMembers API client (#384 read endpoint) + tests.
- Screen-scoped §8 unsaved-changes guard + "Edited" row badge.
- Dropped as backend-less decoration: MiniEpg, bug-initials generator, query text.
  Deferred to #425 with an in-pane hint: per-source weight steppers + corrections.
- Docs: spa-conventions §11 (SlideOver + shared advanced-options), decisions.md.

Refs #386
2026-07-18 01:31:40 +02:00
timothyandClaude Opus 4.8 ed6c43065f feat(164): guided remediation for health checks (server-declared {Kind, Target})
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m48s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m49s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m35s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13m23s
Make the ~14 health checks actionable: each check that has a fix now declares
where to go, and the SPA acts on it.

Backend:
- Widen domain HealthCheckLink (string Link) -> (string Target, HealthCheckLinkKind
  Kind) with ExternalDoc|AppRoute + factories; only the 4 link-building checks and
  the API mapper touched .Link.
- Evolve HealthCheckResponseModel additively (/api/v1 frozen-additive): keep
  deprecated string? Link (still populated), add Brief (the BriefMessage the mapper
  was silently dropping) and nested Remediation {Kind, Target}. Kind is a mapped
  string, not a wire enum.
- Make Mapper.GetStatus total: NotApplicable no longer throws (defensive; handler
  still filters it). InternalsVisibleTo(ErsatzTV.Tests) added to unit-test totality.
- Fix 2 stale Blazor route links (media/trash -> /app/trash, search?query ->
  /app/search); add AppRoute remediation to actionable checks that had none
  (libraries / schedules / ffmpeg-profiles / settings).

SPA:
- DashboardScreen health panel renders remediation: AppRoute -> client-side nav
  button, ExternalDoc -> new-tab anchor; detail text truncates with title-hover.
- Remove the dead "Open Classic UI" -> /system/health row from SettingsScreen
  (a #91b leftover that just 302'd to /app); update its regression test.

Docs: decisions.md (#164), api-conventions.md (deprecate-in-place DTO evolution),
blazor-route-parity.md (Section 4 correction); v1.json/v1.d.ts/endpoint-index
regenerated.

fixes #164

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 00:51:40 +02:00
timothyandClaude Opus 4.8 8f61ad6530 feat(385): per-channel overrides in auto-tune bulk-create
Auto-Tune DetailPanel backend (#385), additive half. The create request
`AutoTunedChannelRequest` gains three optional per-channel fields, all
backward-compatible (omit = PR1 behavior):

- `templateId` — overrides the batch template per channel
- `advanced` — reuses the manual Channel Builder's
  `CreateChannelFromLineupAdvancedOptionsRequest` verbatim (24-field override
  set, `advanced.X ?? template.X` stamp contract). Axis default fills
  `PlaybackOrder` only when the caller leaves it null.
- `logo` — uploaded channel image, `Sanitized()` at the request boundary
  (#283 stored-XSS defense), forwarded to `CreateChannelFromLineup.Logo`

Resolved per channel inside `CreateAutoTunedChannelsHandler.CreateOne`, so one
channel's bad override still yields a per-channel Failed/Skipped without
aborting the batch.

Per-source rotation weights + query corrections are split out to #425 (they
need a MultiCollection-of-per-source-SmartCollections redesign — #70's
WeightedShuffle reads weights only off MultiCollection join rows, and an
auto-tuned channel is one SmartCollection). Bug-initials/colour generated logo
also deferred (needs persisted Channel state + FFmpeg-pipeline wiring).

Tests: handler override-threading (per-channel wins, axis default preserved,
no-override baseline) + request `ToCommand()` logo sanitization. OpenAPI trio
regenerated. Docs: decisions.md, api-conventions.md, domain-model.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:33:10 +02:00
ef9bba8a52 feat(70): accept per-source weight on the multi-collection API
Without this the weight is only reachable by editing the database, so the
enumerator has nothing to distribute by.

Weight is threaded through create and update (all four handler branches: add and
update, plain and smart) and defaults to 1, so it is optional on the wire and
/api/v1 stays additive under the freeze.

It is returned on the read path too, which is load-bearing rather than symmetry:
the update replaces the item list, so a client that GETs, edits a name, and PUTs
back would silently reset every weight to the default if the GET didn't carry it.

Weight edits ride the existing MultiCollection Version token, so If-Match/412
concurrency needs no new design.

Regenerated v1.json + v1.d.ts + endpoint-index via update-openapi.sh and
generate:api (never hand-edited). The spec picks up weight on both request and
response models and WeightedShuffle in the PlaybackOrder enum; weight is emitted
optional.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
timothy 7e9c52b035 fix(72): name the filter for the one fault it can prove (review)
Adversarial review, Medium: the "Problems" filter over-promised. The badge is
honestly named ("No playout"), but the filter claimed a taxonomy that does not
exist — decisions.md enumerates three fault classes this deliberately does NOT
compute (empty schedule behind a playout, broken source, origin). A user whose
Classic schedule is empty would read "Problems 0" as "lineup healthy" and ship
a dead channel. A false all-clear is worse than no affordance.

Renamed the filter to "No playout" so badge and filter both name exactly the
one fault the API can prove, leaving "Problems" free for when the taxonomy
behind it actually exists. Rationale recorded at the predicate so the next
person doesn't "improve" the label back.

Also from the review:
- The zero-playout test's comment claimed include coverage it does not provide
  (it passes with or without the include — 0 == 0). Re-stated as what it is: a
  mapper boundary check. Its two siblings are the include coverage.
- UpdateChannelHandler:179 is a fourth call site the "shared by all three"
  framing excluded. Harmless (the controller discards the view model and
  re-projects through GetChannelByIdForApi), but a trap: its query lacks the
  MirrorSourceChannel include, so swapping in the shared helper would report 0
  for a working mirror. Documented that the fix there is a QUERY change.
- Mirrored the rename into the design-system prototype.

The rename collided the badge and filter labels, so the screen tests now scope
the badge assertion to the table (spa-conventions §6). This also fixed a real
weakness: the mirror test's unscoped queryByText would have matched the filter
button and asserted nothing.

Review note: the reviewer's strongest hypothesis — that PUT and GET could
disagree on a mirror channel's count — was investigated and does NOT hold.

Refs #72
2026-07-17 17:51:43 +02:00
timothy 6cd5471500 feat(72): flag channels that will never play in the lineup
Surfaces the per-channel playout count the previous commit put on the API as
the fault state #72 calls "the important one": a channel with no playout can
never play, and until now the lineup gave no sign of it.

- "No playout" renders as a Badge with visible text rather than another
  single-letter marker like D/H. D/H flag states the user chose deliberately;
  this is a fault they need to notice and act on, and a tooltip-only glyph is
  invisible on touch and easy to miss in a long lineup.
- A "Problems" segmented filter answers the issue's actual goal — seeing the
  health of a LARGE lineup at a glance — by collapsing it to just the faults.
- `willNeverPlay` is one predicate shared by the badge, the count and the
  filter, so the three cannot disagree.

Mirror channels relay their source's playouts and the server folds those into
playoutCount, so a working mirror is not false-flagged (covered by a test).

Deliberately not approximated here, per the design pass: an empty schedule
behind an existing playout (EmptyScheduleHealthCheck only understands Classic
ProgramSchedule playouts — a badge driven by it would silently lie on Block,
Sequential, Scripted and ExternalJson channels) and broken/missing sources
(FileNotFound/Unavailable are server-wide counts with no channel attribution).
Both stay open on #72 rather than shipping a signal that is wrong for four of
the five schedule kinds.

The test fixture now sends playoutCount, matching the API, so the fault state
is opt-in per test instead of depending on an absent field.

Refs #72
2026-07-17 17:51:26 +02:00
timothy 1a40b4a8e6 fix(72): report a real per-channel playout count on the channels API
The channels API could not answer "will this channel play?", which #72 needs
to flag a broken channel in the lineup at a glance.

Two defects, one root cause each:

1. `ChannelRepository.GetChannel` never included `Playouts`. The read is
   AsNoTracking with no lazy-loading proxies, so the navigation came back
   empty and `GetChannelByIdForApiHandler`'s `channel.Playouts?.Count ?? 0`
   could only ever evaluate to 0 — `GET /api/v1/channels/{id}` reported
   `playoutCount: 0` for every channel on the system. That silently disabled
   the channel editor's playout-source guard (ChannelEditScreen:820, gated on
   `playoutCount > 0`), so the "Cannot be changed once a generated channel has
   a playout" control was always live. The server still enforces the invariant
   (UpdateChannelHandler coerces Mirror back to Generated), so nothing was
   corrupted — but the user's change was silently discarded. That silent
   coercion is filed separately as #401.

2. The detail path counted only the channel's own playouts, never the mirror
   source's, so a working Mirror channel would read as "no playout" even once
   the include landed.

Both call sites now share `Mapper.GetPlayoutsCount` (previously private to
GetAllChannelsHandler), which handles the Mirror case. `ChannelResponseModel`
gains `PlayoutCount` so the list — #72's actual surface — can render it; the
count is free there, since `GetAll` already includes `Playouts` and
`MirrorSourceChannel.Playouts` and simply discarded them.

Tests run the real repository against a real context on purpose: a handler
test with a substituted IChannelRepository populates `Playouts` itself, so it
passes whether or not the query includes them. Proven non-vacuous — removing
the include again turns the 2-playout and mirror cases red (0 CS errors, so
no stale-dll false pass).

Refs #72
2026-07-17 17:51:26 +02:00
timothy a5ac63dbd9 Merge pull request 'feat(69): Auto-Tune SPA wizard (Configure → Preview → Create) — PR2' (#389) from feat/69-auto-tune-spa into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m51s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m47s
2026-07-16 22:30:52 +00:00
timothyandClaude Opus 4.8 8e82673e6b fix(69): key Auto-Tune selection by axis+value; zero-templates UX (review)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 12m10s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-17 00:16:03 +02:00
timothyandClaude Opus 4.8 33ba3a0492 feat(69): Auto-Tune SPA wizard — Configure → Preview → Create (PR2)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m37s
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>
2026-07-17 00:04:42 +02:00
timothyandClaude Opus 4.8 81684411bd fix(71): reseed all playout kinds on reshuffle (erase-history + rebuild); docs + minors
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 4m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m24s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 6m58s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 11m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16m11s
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>
2026-07-16 23:47:18 +02:00
timothyandClaude Opus 4.8 13584f5daf feat(71): SPA display play-order seed + persistence help text
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:25:33 +02:00
timothyandClaude Opus 4.8 7a01ad62bd feat(71): SPA per-playout Reshuffle button + client fn
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:22:33 +02:00
timothyandClaude Opus 4.8 238937fa5b chore(71): regenerate OpenAPI + client for reshuffle endpoint + seed field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:19:46 +02:00
timothy 0320735f47 docs(69): auto-tune OpenAPI regen + api/domain/decisions docs
Refs #69
2026-07-16 22:18:52 +02:00
timothy b17a70be1b Merge pull request 'fix(spa): body margin + nav box-sizing resets (#373, #377)' (#378) from fix/373-377-spa-css-resets into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m11s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-16 20:12:52 +00:00
timothyandClaude Opus 4.8 1575b9b537 fix(spa): add body margin + nav box-sizing resets (fixes #373, fixes #377)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m4s
Two missing-base-reset defects in the ChicoryTV SPA shell, both confirmed by
rendering the real app (not source-reading):

- #373: the browser-default `body { margin: 8px }` was never reset, so it framed
  every full-viewport layout — the app shell and the shell-less `.ctv-auth-page`
  boot pages (login/setup/checking/error), both `min-height:100vh` — with a light
  border on all four edges. Fix: `html, body { margin: 0 }` + paint the app surface
  on `body` so any residual gap/overscroll stays dark (matches the design-system
  reset in forms.card.html / chicorytv-admin templates). Verified body margin 0 and
  no edge border on the boot page and the shell, warm theme, 1280px and 900px.

- #377: the SPA ships no global `box-sizing` reset (default content-box), so
  `.ctv-nav-item { width:100%; padding:0 10px }` overflowed `.ctv-nav`
  (`overflow:auto`) by 20px → a horizontal scrollbar in the sidebar. Fix: scope
  `box-sizing: border-box` to `.ctv-nav-item`. Verified nav scrollWidth==clientWidth
  (216==216) and no horizontal scroll after the fix; the intended vertical nav scroll
  is unaffected.

Docs: spa-conventions.md §1 records the base reset and the deliberate absence of a
global box-sizing reset (set border-box locally when combining width:100% + padding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:57:29 +02:00
timothyandClaude Opus 4.8 3bc8192d3b feat(iptv): add configurable advertised base URL for M3U/XMLTV (fixes #340)
ErsatzTV built every absolute M3U/XMLTV URL from the incoming request's
Scheme/Host/PathBase, so a client fetching via a host that downstream
consumers can't resolve (e.g. Dispatcharr over Docker DNS → Kodi) baked
that internal host into programme-image/stream URLs.

Add an optional advertised IPTV base URL, backed by the existing
ConfigElement key/value store (key `iptv.base_url`, no EF migration):

- Central pure Core helper `AdvertisedBaseUrl` (TryParse/Resolve):
  validates absolute http(s), no credentials/query/fragment, preserves
  port + path prefix, normalizes trailing slash. Blank/invalid falls
  back to the request-derived values, so unset output is byte-identical.
- Resolved inside `GetChannelPlaylistHandler` (M3U guide/logo/stream) and
  `GetChannelGuideHandler` (both XMLTV {RequestBase} sites) — controllers
  stay thin, golden tests untouched.
- New `iptv` settings group: GET/PUT /api/v1/settings/iptv (blank clears,
  malformed → 422) + a new IPTV section on the SPA Settings screen.
- Scoped to M3U + XMLTV; HDHomeRun deliberately out of scope. Distinct
  from ETV_BASE_URL (which only sets ASP.NET PathBase).

Tests: AdvertisedBaseUrl unit tests (override/fallback/port/path/invalid),
handler override tests for both generators, settings controller + handler
tests, SPA client + screen tests. Docs: m3u-xmltv, decisions, domain-model,
regenerated OpenAPI v1.json + v1.d.ts + endpoint-index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:08:42 +02:00
timothy 6532929cf2 fix(web): gate Plex authorized-no-servers messaging on released lock
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 24s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m56s
Adversarial review of the initial fix found that authorizedNoServers =
isAuthorized && !hasServers ignored isLocked. Because boot.state updates on
every poll observation, the "no eligible servers — sign out and retry"
subtitle + summary rendered during the normal `finalizing` window (locked,
authorized, no servers yet) alongside the "discovering your Plex servers…"
status — and during `budget-exhausted` (large first sync still holding the
lock, polling stopped, Sign out enabled) it urged the user to sign out
mid-sync. That reintroduced the contradictory-state class the PR fixes.

Gate the empty-servers presentation on !isLocked so it only appears at the
genuine released-lock terminal. Add regression coverage: the pin-flow test
now passes through the finalizing cell asserting the empty-servers messaging
is absent while locked, plus a budget-exhausted-while-locked case. Both new
guards were confirmed to fail on the ungated code (negative control).

Refs #345
2026-07-16 14:29:47 +02:00
timothy 0f0a0d7fe5 fix(web): coherent, recoverable Plex authorized-but-no-servers state
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m26s
A real Plex pin flow reached isAuthorized:true, isLocked:false, servers:[]
(a Plex account that owns/has been granted no eligible servers). The SPA
then showed contradictory state: the pin-flow status claimed "Connected to
Plex." while the Connection card said "Not signed in.", offered Sign in, and
hid Sign out — so the stored authorization could not be removed through the UI.

Root cause: PlexSourceScreen conflated authorization (a stored token,
isAuthorized) with connection (a discovered server, servers.length > 0) by
deriving everything from `connected = servers.length > 0`, and evaluatePinFlow
returned `success` ("Connected to Plex.") for any released-lock authorized
observation regardless of server count.

Fix (pure SPA; sign-out endpoint already works with empty servers):
- pinFlowPoll: add a distinct `authorized-no-servers` terminal status and a
  `hasServers` observation field; the released-lock branch now yields success
  only when a server was discovered, else authorized-no-servers with an honest
  message that distinguishes authorization from discovery.
- PlexSourceScreen: gate Sign in vs Sign out on `hasPlexAccount`
  (isAuthorized || hasServers) instead of the server count, so an authorized
  account can always sign out; add a subtitle + explanatory summary for the
  authorized/empty state; keep the Servers card gated on actual servers.
- Regression tests: the authorized/unlocked/empty transition (pin-flow and
  fresh-reload paths) and sign-out from that state.

fixes #345
2026-07-16 14:15:45 +02:00
timothyandOpenAI Codex 947c8aacd8 test(web): restore App composition smokes
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 13:30:44 +02:00
timothyandOpenAI Codex bd84593cd1 test(web): colocate screen behavior coverage (#247)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 20s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 26s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m50s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:51:35 +02:00
timothyandOpenAI Codex 44f4db5a6d test(web): cover extracted shell contracts
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:51:23 +02:00
timothyandOpenAI Codex 30565145cd test(web): harden primary-action ownership regressions
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:37:36 +02:00
timothyandOpenAI Codex ce5aaa8706 refactor(web): extract app shell and routing
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:34:19 +02:00
timothyandOpenAI Codex 0416c00f96 refactor(web): replace primary-action events with context
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:33:16 +02:00