Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.7 KiB
Design — Per-playout Reshuffle + shuffle-state surfacing (#71)
Date: 2026-07-16 Issue: ersatztv#71 — "Persistent shuffle state + explicit 'Reroll' control"
Premise correction
The issue's stated premise is largely false against the current code (verified by a full shuffle/seed source map):
- "Shuffle is re-randomized every rebuild" — FALSE. Only
PlayoutBuildMode.Resetreseeds (ErsatzTV.Core/Scheduling/PlayoutBuilder.cs:301,playout.Seed = new Random().Next()).ContinueandRefreshreuse the persisted seed + position. - "No persistent shuffle memory today" — FALSE.
Playout.Seed(int column, migration20240124204743_Add_PlayoutSeed) plus ownedCollectionEnumeratorState(Seed + Index) rows onPlayoutProgramScheduleAnchor/PlayoutAnchoralready persist the exact shuffled order and the current position across rebuilds and restarts. The Fisher-Yates shuffle inShuffledMediaCollectionEnumeratoris fully deterministic given the seed. - "Anti-repeat exists but is implicit" — TRUE. Each item plays once per permutation before any reshuffle; the in-cycle reshuffle even rejects a seed whose new head equals the previous tail.
So the persistence #71 asks us to build already exists. The genuine, missing gaps are:
- No user-triggered, per-playout reshuffle. The only reseed path is
POST /api/v1/playouts/reset-all, which for Classic playouts enqueuesRefresh(preserves the seed), notReset. A Classic channel's shuffle seed is therefore effectively frozen after first build — there is no way to roll a new order for one channel. - No visibility into the shuffle state.
Scope (approved)
"Reshuffle + state surfacing." Out of scope: a separate lighter "reseed-only" mode that preserves rerun
history (rejected — YAGNI for the common Classic+Shuffle case, where reseeding inherently clears
per-collection anchors); a bulk "reshuffle all" (the existing reset-all covers bulk rebuild).
A. Backend — Reshuffle action
- Command:
ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs—public record ReshufflePlayout(int PlayoutId) : IRequest;(fire-and-forget, no result body). - Handler:
ReshufflePlayoutHandler(ChannelWriter<IBackgroundServiceRequest> channel, IDbContextFactory<TvContext> dbContextFactory). Follows theEraseItemsshape (controller owns the 404/409/422 prechecks; the handler is a pure fire-and-forget enqueue): load the playout with a resettable-ScheduleKindfilter,foreach (playout in maybePlayout)(silent no-op if absent), enqueueBuildPlayout(PlayoutId, PlayoutBuildMode.Reset). TheResetbuild mode already performs reseed → clear anchors/history → rebuild; the handler does not manually reseed and does not re-check the lock (the controller 409-guards and the build worker acquires its own lock). - Controller action in
ErsatzTV/Controllers/Api/PlayoutController.cs, mirroringEraseItems:Body order (mirrors erase-items):[HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")] [Tags("Playouts")] [EndpointGroupName("general")] [EndpointSummary("Reshuffle a playout — roll a new random play order and rebuild")] [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(typeof(ProblemDetails), 404)] // playout not found [ProducesResponseType(typeof(ProblemDetails), 409)] // build in progress [ProducesResponseType(typeof(ProblemDetails), 422)] // unsupported schedule kindIsPlayoutLocked(id)→PlayoutLockedProblem()(409);GetPlayoutById(id)IsNone→ApiResults.NotFoundProblem()(404);ScheduleKindnot in {Classic, Block, Sequential, Scripted} →BaseError.New(...).ToErrorResult()(422); elseawait mediator.Send(new ReshufflePlayout(id), ct)→Accepted()(202, no body — single playout).
Semantics (decisions.md entry)
- Named
/reshuffle, not/reset, to (a) match the user intent, (b) avoid the confusing "reset-one reseeds Classic while reset-all refreshes Classic" naming clash. - It is a full per-playout
Reset: reseed + clear per-collection anchors + clear rerun history + rebuild. For Classic+Shuffle this yields a genuinely new Fisher-Yates order; for Chronological it is a harmless rebuild; for Block it re-randomizes content selection and resets block rotation. The SPA confirm dialog states the rerun-history side effect explicitly. - Per-playout reshuffle deliberately reseeds Classic even though
reset-allrefreshes Classic: a targeted single-channel action is an explicit "give me a new order" request; the bulk action stays non-disruptive by design.
B. Backend — Seed surfacing
Add int Seed to:
ErsatzTV.Application/Playouts/PlayoutNameViewModel.csErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs(list) andPlayoutResponseModel.cs(detail)
Wire the projections that build the VM:
ErsatzTV.Application/Playouts/Mapper.csProjectToViewModel(Playout playout)(used byGetPagedPlayoutsHandler)ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs
and the controller mappers ToResponse / ToListItemResponse in PlayoutController.cs.
Purpose: the seed changes after a reshuffle, giving the user visible confirmation the action worked.
C. SPA
Files: web/src/api/playouts.ts, web/src/screens/PlayoutsScreen.tsx, regenerated
web/src/api/generated/v1.d.ts.
reshufflePlayout(playoutId: number)client fn →POST /playouts/{id}/reshufflevia the sharedrequest()helper (CSRF added centrally).- New per-playout "Reshuffle" button in the action block (the
erase-*/deletegroup), lucideDices(orShuffle) icon,disabled={mutating || selectedLocked},title={selectedLocked ? 'A build is in progress for this playout' : undefined}, kind-gated to {Classic, Block, Sequential, Scripted}. Wired via the existingrunMutation(confirmMessage, action)helper (guards re-entrancy,window.confirm,query.refresh()on success, and onApiError409 also refreshes so the row picks upIsLocked).- Confirm message: "Reshuffle this playout? This rolls a new random play order and rebuilds the schedule from scratch, clearing rerun history."
- Selected-playout card: show the seed (monospace, e.g.
Play-order seed: 1847362) plus one line of help text: "The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order." Seed comes from the already-loaded list row.
No new SPA convention is introduced (reuses runMutation + window.confirm + the action-button
pattern), so spa-conventions.md needs no change.
D. Docs (same PR)
- Regenerate OpenAPI + client:
dotnet build ErsatzTV.sln→./scripts/update-openapi.sh(updatesErsatzTV/wwwroot/openapi/v1.json+docs/endpoint-index.md) →cd web && npm run generate:api(updatesweb/src/api/generated/v1.d.ts). Commit all three. docs/blazor-route-parity.md— Playouts row: note the new reshuffle action.docs/domain-model.md— playout rows: notePlayout.Seedis now surfaced and the reshuffle action.docs/decisions.md— append the semantics entry above.docs/README.md— no change (no doc added/removed/retitled).
E. Tests
- Handler (Application test harness):
ReshufflePlayoutHandlerwrites exactly oneBuildPlayout(id, PlayoutBuildMode.Reset)to the channel for a resettable kind; no write for a locked playout / unsupported kind (whichever the handler is responsible for — controller owns 409/422, so the handler test asserts the enqueue for the happy path). Capture channel writes via a testChannel<IBackgroundServiceRequest>. - API/controller (
ErsatzTV.Testsharness, mirroring the erase-items / ChannelController tests): 202 on a valid resettable playout; 404 for a missing id; 409 when the build lock is held; 422 for an ExternalJson/None playout. - DTO: the playout list/detail responses include
Seed. - SPA (
PlayoutsScreen.test.tsx): the Reshuffle button callsreshufflePlayout, respects the confirm dialog, refreshes on success; the seed renders in the selected-playout card.
F. Verification gate
- Backend:
dotnet build ErsatzTV.sln,dotnet test(touched projects),dotnet formaton touched files (de-BOM per the #311 formatting gate). - OpenAPI:
npm run check:apiclean; the three generated artifacts committed. - SPA:
npm test,npm run lint,npm run build. - Live-E2E (required — write-path handler that enqueues a build): via
scripts/e2e-local.sh— seed a Classic playout with a Shuffle-order schedule, record the seed + first items, reshuffle, and assert the seed changed and the built order changed; verify a 409 while a build lock is held. Never exercise download endpoints via a browser tab. - Independent review (required — write-path + enqueues a build; not skippable): cold-context
adversarial review scoped "review only" over the PR diff, then re-review the fix commit. Cross-model
(Codex) if quota allows, else a cold Claude agent. Enumerate every producer/consumer of the
BuildPlayoutmessage and the reset path when judging soundness.
Risks / edge cases
- Rerun-history loss is intentional but user-visible; the confirm dialog must call it out.
- Concurrent build: the controller 409-guards, and the build worker acquires its own lock, so an enqueue that races a starting build is benign (the worker serializes). No new lock is introduced.
- Chronological / non-random playbacks: reshuffle is a harmless rebuild (no visible order change); the button is still offered per-kind, not per-playback-order, since a playout can mix items.
- Seed = 0 (never-reset playout) still shuffles deterministically; surfacing it is fine.