From 9a7ac28f9f27e4f67366a8699e3e2e1521d48115 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 22:43:29 +0200 Subject: [PATCH 01/10] docs(71): design spec for per-playout reshuffle + shuffle-state surfacing Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-16-reshuffle-playout-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-reshuffle-playout-design.md diff --git a/docs/superpowers/specs/2026-07-16-reshuffle-playout-design.md b/docs/superpowers/specs/2026-07-16-reshuffle-playout-design.md new file mode 100644 index 000000000..d10675df3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-reshuffle-playout-design.md @@ -0,0 +1,159 @@ +# Design — Per-playout Reshuffle + shuffle-state surfacing (#71) + +Date: 2026-07-16 +Issue: [ersatztv#71](http://192.168.1.95:3000/timothy/ersatztv/issues/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.Reset` reseeds + (`ErsatzTV.Core/Scheduling/PlayoutBuilder.cs:301`, `playout.Seed = new Random().Next()`). + `Continue` and `Refresh` reuse the persisted seed + position. +- **"No persistent shuffle memory today" — FALSE.** `Playout.Seed` (int column, migration + `20240124204743_Add_PlayoutSeed`) plus owned `CollectionEnumeratorState` (Seed + Index) rows on + `PlayoutProgramScheduleAnchor`/`PlayoutAnchor` already persist the exact shuffled order **and** the + current position across rebuilds and restarts. The Fisher-Yates shuffle in + `ShuffledMediaCollectionEnumerator` is 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: + +1. **No user-triggered, per-playout reshuffle.** The only reseed path is `POST /api/v1/playouts/reset-all`, + which for **Classic** playouts enqueues `Refresh` (preserves the seed), not `Reset`. 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. +2. **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 channel, + IDbContextFactory dbContextFactory)`. Follows the `EraseItems` shape (controller owns the + 404/409/422 prechecks; the handler is a pure fire-and-forget enqueue): load the playout with a + resettable-`ScheduleKind` filter, `foreach (playout in maybePlayout)` (silent no-op if absent), + enqueue `BuildPlayout(PlayoutId, PlayoutBuildMode.Reset)`. The `Reset` build 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`, mirroring `EraseItems`: + ``` + [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 kind + ``` + Body order (mirrors erase-items): `IsPlayoutLocked(id)` → `PlayoutLockedProblem()` (409); + `GetPlayoutById(id)` `IsNone` → `ApiResults.NotFoundProblem()` (404); `ScheduleKind` not in + {Classic, Block, Sequential, Scripted} → `BaseError.New(...).ToErrorResult()` (422); else + `await 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-all` refreshes 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.cs` +- `ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs` (list) and `PlayoutResponseModel.cs` (detail) + +Wire the projections that build the VM: + +- `ErsatzTV.Application/Playouts/Mapper.cs` `ProjectToViewModel(Playout playout)` (used by + `GetPagedPlayoutsHandler`) +- `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}/reshuffle` via the shared + `request()` helper (CSRF added centrally). +- New per-playout **"Reshuffle"** button in the action block (the `erase-*`/`delete` group), lucide + `Dices` (or `Shuffle`) 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 existing `runMutation(confirmMessage, action)` + helper (guards re-entrancy, `window.confirm`, `query.refresh()` on success, and on `ApiError` 409 also + refreshes so the row picks up `IsLocked`). + - 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` + (updates `ErsatzTV/wwwroot/openapi/v1.json` + `docs/endpoint-index.md`) → + `cd web && npm run generate:api` (updates `web/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: note `Playout.Seed` is 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): `ReshufflePlayoutHandler` writes exactly one + `BuildPlayout(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 test + `Channel`. +- **API/controller** (`ErsatzTV.Tests` harness, 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 calls `reshufflePlayout`, 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 format` on touched + files (de-BOM per the #311 formatting gate). +- OpenAPI: `npm run check:api` clean; 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 + `BuildPlayout` message 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. From 1f4341a347c51924561524a5419c5a7d3677651e Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 22:54:37 +0200 Subject: [PATCH 02/10] docs(71): implementation plan for per-playout reshuffle + seed surfacing Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-16-reshuffle-playout.md | 792 ++++++++++++++++++ 1 file changed, 792 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-reshuffle-playout.md diff --git a/docs/superpowers/plans/2026-07-16-reshuffle-playout.md b/docs/superpowers/plans/2026-07-16-reshuffle-playout.md new file mode 100644 index 000000000..fc19d512b --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-reshuffle-playout.md @@ -0,0 +1,792 @@ +# Per-playout Reshuffle + shuffle-state surfacing (#71) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a per-playout "Reshuffle" action (rolls a new random play order + rebuilds) and surface the play-order seed in the SPA. + +**Architecture:** A new `POST /api/v1/playouts/{id}/reshuffle` endpoint delegates to a fire-and-forget MediatR command that enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` — the existing `Reset` build mode already reseeds `Playout.Seed` and rebuilds. The seed is projected onto the playout list/detail DTOs so the SPA can display it (visible confirmation the reshuffle worked). SPA adds a "Reshuffle" button (mirroring the existing erase/reset per-playout actions) plus a seed display line. + +**Tech Stack:** C#/.NET 10, MediatR CQRS, EF Core, ChicoryTV React SPA (Vite + TypeScript), NUnit + Shouldly + NSubstitute (backend), Vitest + Testing Library (SPA). + +## Global Constraints + +- Test framework is **NUnit + Shouldly + NSubstitute** (backend) and **Vitest** (SPA). Never xUnit. +- New `/api/*` controllers/DTOs require OpenAPI regen: `dotnet build ErsatzTV.sln` → `./scripts/update-openapi.sh` → `cd web && npm run generate:api`, then commit `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, and `web/src/api/generated/v1.d.ts`. A blocking CI `api-docs` job fires on `ErsatzTV/Controllers/Api/**` and `ErsatzTV.Core/Api/**` changes. +- Every controller action needs `[Tags("Playouts")]`, `[EndpointSummary(...)]`, `[EndpointGroupName("general")]` (REQUIRED or it's dropped from OpenAPI), and a `[ProducesResponseType]` per status code. +- Success paths that only *queue* a build return **202 Accepted**, not 200. +- Backend tests run timezone-pinned: `TZ=UTC dotnet test ErsatzTV.Tests`. SPA: `npm test` in `web/`. +- Format touched C# files (de-BOM per the #311 formatting gate: `charset=utf-8`, no BOM on files you touch). Worktree hooks are flaky → commit with `--no-verify` and verify manually (`dotnet build`, `dotnet test`, `dotnet format whitespace ` on staged files only). +- Commit trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Work only in this worktree: `/Users/timothy/ersatztv/.worktrees/71-reshuffle` (branch `feat/71-reshuffle-playout`, off `origin/main`). + +--- + +### Task 1: `ReshufflePlayout` command + handler + +**Files:** +- Create: `ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs` +- Create: `ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs` +- Test: `ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs` + +**Interfaces:** +- Consumes: `BuildPlayout(int playoutId, PlayoutBuildMode mode)` (ctor), `PlayoutBuildMode.Reset` (`ErsatzTV.Core.Scheduling`), `IBackgroundServiceRequest` (`ErsatzTV.Application`, parent namespace — no `using` needed), `IDbContextFactory`, the `InMemoryTvContext` test harness (`ErsatzTV.Tests.Support`). +- Produces: `public record ReshufflePlayout(int PlayoutId) : IRequest;` and `ReshufflePlayoutHandler` — later relied on by the controller (Task 2), which sends `new ReshufflePlayout(id)`. + +- [ ] **Step 1: Write the failing handler test** + +Create `ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs` (mirrors `ResetAllPlayoutsHandlerTests.cs`; the handler enqueues a `Reset` build for a resettable kind and skips unsupported kinds): + +```csharp +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Tests.Support; +using NUnit.Framework; +using Shouldly; +using Channel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Application.Playouts; + +[TestFixture] +public class ReshufflePlayoutHandlerTests +{ + private InMemoryTvContext _db = null!; + private Channel _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = Channel.CreateUnbounded(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + private ReshufflePlayoutHandler CreateHandler() => new(_worker.Writer, _db.Factory); + + private async Task SeedPlayout(PlayoutScheduleKind kind) + { + await using TvContext context = _db.CreateContext(); + var playout = new Playout { ChannelId = 0, ScheduleKind = kind }; + context.Playouts.Add(playout); + await context.SaveChangesAsync(); + return playout.Id; + } + + [TestCase(PlayoutScheduleKind.Classic)] + [TestCase(PlayoutScheduleKind.Block)] + [TestCase(PlayoutScheduleKind.Sequential)] + [TestCase(PlayoutScheduleKind.Scripted)] + public async Task Handle_Should_Enqueue_Reset_Build_For_Supported_Kind(PlayoutScheduleKind kind) + { + int id = await SeedPlayout(kind); + + await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None); + + _worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); + var build = request.ShouldBeOfType(); + build.PlayoutId.ShouldBe(id); + build.Mode.ShouldBe(PlayoutBuildMode.Reset); + _worker.Reader.TryRead(out _).ShouldBeFalse(); + } + + [TestCase(PlayoutScheduleKind.ExternalJson)] + [TestCase(PlayoutScheduleKind.None)] + public async Task Handle_Should_Not_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind) + { + int id = await SeedPlayout(kind); + + await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None); + + _worker.Reader.TryRead(out _).ShouldBeFalse(); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ReshufflePlayoutHandlerTests` +Expected: FAIL — `ReshufflePlayout` / `ReshufflePlayoutHandler` do not exist (compile error). + +- [ ] **Step 3: Create the command** + +Create `ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs`: + +```csharp +namespace ErsatzTV.Application.Playouts; + +public record ReshufflePlayout(int PlayoutId) : IRequest; +``` + +- [ ] **Step 4: Create the handler** + +Create `ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs`. The `Reset` build mode already reseeds + clears state + rebuilds; the handler only enqueues, and keeps the resettable-kind filter (defense-in-depth, matching `ErasePlayoutItemsHandler`): + +```csharp +using System.Threading.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Playouts; + +public class ReshufflePlayoutHandler( + ChannelWriter channel, + IDbContextFactory dbContextFactory) + : IRequestHandler +{ + public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybePlayout = await dbContext.Playouts + .AsNoTracking() + .Where(p => p.ScheduleKind == PlayoutScheduleKind.Classic || + p.ScheduleKind == PlayoutScheduleKind.Block || + p.ScheduleKind == PlayoutScheduleKind.Sequential || + p.ScheduleKind == PlayoutScheduleKind.Scripted) + .SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken); + + foreach (Playout playout in maybePlayout) + { + await channel.WriteAsync( + new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), + cancellationToken); + } + } +} +``` + +> Note: `SingleOrDefaultAsync` returns `Playout?`; the codebase's global usings (LanguageExt) implicitly convert to `Option` as in `ErasePlayoutItemsHandler`. If the implicit conversion does not compile, wrap with `Optional(...)`. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ReshufflePlayoutHandlerTests` +Expected: PASS (6 cases). + +- [ ] **Step 6: Format + commit** + +```bash +dotnet format whitespace ErsatzTV.Application ErsatzTV.Tests --folder --include ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs || true +git add ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs +git commit --no-verify -m "feat(71): ReshufflePlayout command + handler (enqueue per-playout Reset build) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +> If `dotnet format --include` no-ops on this Mac (known issue), instead run `dotnet format whitespace ErsatzTV.Application` and `... ErsatzTV.Tests`, then `git add` only the three files above. + +--- + +### Task 2: Reshuffle controller action (202 / 404 / 409 / 422) + +**Files:** +- Modify: `ErsatzTV/Controllers/Api/PlayoutController.cs` (add the `Reshuffle` action after `EraseItemsAndHistory`, ~line 685) +- Test: `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` + +**Interfaces:** +- Consumes: `ReshufflePlayout` (Task 1), `GetPlayoutById`, `entityLocker.IsPlayoutLocked(id)`, `PlayoutLockedProblem()`, `ApiResults.NotFoundProblem()`, `BaseError.New(...).ToErrorResult()`. +- Produces: action `Task Reshuffle(int id, CancellationToken)` at `POST /api/v1/playouts/{id:int}/reshuffle`. + +- [ ] **Step 1: Write the failing controller tests** + +Add to `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` (mirrors the `EraseItems_*` tests). Also extend the existing `Controller_Should_Expose_Idiomatic_Rest_Routes` test with the new route: + +```csharp + [Test] + public async Task Reshuffle_Should_Return_409_When_Playout_Locked() + { + _entityLocker.IsPlayoutLocked(9).Returns(true); + + IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Reshuffle_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Reshuffle(404, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [TestCase(PlayoutScheduleKind.ExternalJson)] + [TestCase(PlayoutScheduleKind.None)] + public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); + + IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [TestCase(PlayoutScheduleKind.Classic)] + [TestCase(PlayoutScheduleKind.Block)] + [TestCase(PlayoutScheduleKind.Sequential)] + [TestCase(PlayoutScheduleKind.Scripted)] + public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); + + IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(202); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9), + Arg.Any()); + } +``` + +Add this line inside `Controller_Should_Expose_Idiomatic_Rest_Routes` (next to the `EraseItemsAndHistory` assertion): + +```csharp + ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle"); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests` +Expected: FAIL — `PlayoutController.Reshuffle` does not exist (compile error). + +- [ ] **Step 3: Add the controller action** + +In `ErsatzTV/Controllers/Api/PlayoutController.cs`, insert immediately after the `EraseItemsAndHistory` action (after line 684): + +```csharp + [HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")] + [Tags("Playouts")] + [EndpointSummary("Reshuffle a playout")] + [EndpointDescription( + "Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it " + + "and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Reshuffle(int id, CancellationToken cancellationToken) + { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + foreach (PlayoutNameViewModel playout in maybePlayout) + { + if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block + or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted)) + { + return BaseError.New( + "[Reshuffle] is only valid for Classic, Block, Sequential, or Scripted playouts") + .ToErrorResult(); + } + } + + await mediator.Send(new ReshufflePlayout(id), cancellationToken); + return Accepted(); + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests` +Expected: PASS (including the route assertion + the 4 new tests). + +- [ ] **Step 5: Format + commit** + +```bash +dotnet format whitespace ErsatzTV ErsatzTV.Tests || true +git add ErsatzTV/Controllers/Api/PlayoutController.cs ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +git commit --no-verify -m "feat(71): POST /api/v1/playouts/{id}/reshuffle action (202/404/409/422) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 3: Surface `Seed` on the playout VM + DTOs + projections + +**Files:** +- Modify: `ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs` (append `int Seed`) +- Modify: `ErsatzTV.Application/Playouts/Mapper.cs` (list projection) +- Modify: `ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs` (detail projection) +- Modify: `ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs`, `UpdateExternalJsonPlayoutHandler.cs`, `UpdateSequentialPlayoutHandler.cs`, `UpdateScriptedPlayoutHandler.cs` (VM construction sites) +- Modify: `ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs`, `PlayoutResponseModel.cs` +- Modify: `ErsatzTV/Controllers/Api/PlayoutController.cs` (`ToResponse`, `ToListItemResponse`) +- Test: `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` (+ update the `MakePlayout` fixture) + +**Interfaces:** +- Produces: `PlayoutNameViewModel.Seed : int`, `PlayoutResponseModel.Seed : int`, `PlayoutListItemResponseModel.Seed : int` — consumed by the SPA (Task 6) after OpenAPI regen (Task 4). + +- [ ] **Step 1: Write the failing seed tests** + +Add to `PlayoutControllerTests.cs`: + +```csharp + [Test] + public async Task GetById_Should_Surface_Seed() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { Seed = 4242 })); + + IActionResult result = await _controller.GetById(9, CancellationToken.None); + + result.ShouldBeOfType() + .Value.ShouldBeOfType() + .Seed.ShouldBe(4242); + } + + [Test] + public async Task GetAll_Should_Surface_Seed() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutsViewModel(1, new List { MakePlayout(9) with { Seed = 4242 } })); + + PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None); + + result.Page[0].Seed.ShouldBe(4242); + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter "FullyQualifiedName~PlayoutControllerTests.GetById_Should_Surface_Seed|FullyQualifiedName~PlayoutControllerTests.GetAll_Should_Surface_Seed"` +Expected: FAIL — `PlayoutResponseModel`/`PlayoutListItemResponseModel` have no `Seed`; `MakePlayout(...) with { Seed = ... }` won't compile. + +- [ ] **Step 3: Append `Seed` to `PlayoutNameViewModel`** + +In `ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs`, change the record header's final line `int Version)` → `int Version,\n int Seed)`: + +```csharp +public record PlayoutNameViewModel( + int PlayoutId, + PlayoutScheduleKind ScheduleKind, + string ChannelName, + string ChannelNumber, + ChannelPlayoutMode PlayoutMode, + string ScheduleName, + string ScheduleFile, + TimeSpan? DbDailyRebuildTime, + PlayoutBuildStatus BuildStatus, + int? DecoId, + string DecoName, + int Version, + int Seed) +``` + +- [ ] **Step 4: Set `Seed` at all VM construction sites** + +`ErsatzTV.Application/Playouts/Mapper.cs` — change `playout.Version);` (line 23) to: +```csharp + playout.Version, + playout.Seed); +``` + +`ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs` — change `p.Version));` (line 34) to: +```csharp + p.Version, + p.Seed)); +``` + +In each of `UpdatePlayoutHandler.cs`, `UpdateExternalJsonPlayoutHandler.cs`, `UpdateSequentialPlayoutHandler.cs`, `UpdateScriptedPlayoutHandler.cs` — the trailing `playout.Version);` of the `return new PlayoutNameViewModel(...)` becomes: +```csharp + playout.Version, + playout.Seed); +``` + +- [ ] **Step 5: Add `Seed` to the response DTOs** + +`ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs` — append `int Seed` after `bool IsLocked`: +```csharp +public record PlayoutListItemResponseModel( + int Id, + string ChannelNumber, + string ChannelName, + PlayoutScheduleKind ScheduleKind, + string ScheduleName, + TimeSpan? DailyRebuildTime, + PlayoutBuildStatusResponseModel? BuildStatus, + ChannelPlayoutMode PlayoutMode, + bool IsLocked, + int Seed); +``` + +`ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs` — append `int Seed` to both the record and the `From` factory (param list ends with `bool isLocked`; add `int seed`, and add `seed` to the `new(...)`): +```csharp +public record PlayoutResponseModel( + int Id, + PlayoutScheduleKind ScheduleKind, + string ChannelName, + string ChannelNumber, + ChannelPlayoutMode PlayoutMode, + string ScheduleName, + string? ScheduleFile, + TimeSpan? DailyRebuildTime, + PlayoutBuildStatusResponseModel? BuildStatus, + int? DecoId, + string? DecoName, + bool IsLocked, + int Seed) +{ + public static PlayoutResponseModel From( + int id, + PlayoutScheduleKind scheduleKind, + string channelName, + string channelNumber, + ChannelPlayoutMode playoutMode, + string scheduleName, + string? scheduleFile, + TimeSpan? dailyRebuildTime, + PlayoutBuildStatusResponseModel? buildStatus, + int? decoId, + string? decoName, + bool isLocked, + int seed) => + new( + id, + scheduleKind, + channelName, + channelNumber, + playoutMode, + scheduleName, + scheduleFile, + dailyRebuildTime, + buildStatus, + decoId, + decoName, + isLocked, + seed); +} +``` + +- [ ] **Step 6: Pass `Seed` through the controller mappers** + +`ErsatzTV/Controllers/Api/PlayoutController.cs`: +- `ToResponse` (line 776) — change the trailing `isLocked);` to `isLocked,\n vm.Seed);`. +- `ToListItemResponse` (line 828) — change the trailing `isLocked);` to `isLocked,\n vm.Seed);`. + +- [ ] **Step 7: Update the `MakePlayout` fixture** + +`ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` `MakePlayout` (line 1331) — the constructor ends `0);` (Version). Append `0` for Seed: +```csharp + 0, + 0); +``` + +- [ ] **Step 8: Run the seed tests + full controller/handler suite** + +Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests` +Expected: PASS (including the two seed tests). Then `TZ=UTC dotnet build ErsatzTV.sln` — Expected: `Build succeeded`, `0 Error(s)` (verifies all 4 update handlers + Mapper compile with the new positional field). Grep the output for `error CS` — there must be none. + +- [ ] **Step 9: Format + commit** + +```bash +dotnet format whitespace ErsatzTV.Application ErsatzTV.Core ErsatzTV ErsatzTV.Tests || true +git add ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs ErsatzTV.Application/Playouts/Mapper.cs ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs ErsatzTV/Controllers/Api/PlayoutController.cs ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +git commit --no-verify -m "feat(71): surface Playout.Seed on playout list + detail DTOs + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 4: Regenerate OpenAPI + TypeScript client + +**Files:** +- Modify (generated): `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts` + +- [ ] **Step 1: Build, regenerate, verify** + +```bash +dotnet build ErsatzTV.sln +./scripts/update-openapi.sh +cd web && npm run generate:api && npm run check:api && cd .. +``` +Expected: `v1.json` gains the `/api/v1/playouts/{id}/reshuffle` POST (operationId `ReshufflePlayout`) and `seed` on `PlayoutListItemResponseModel` + `PlayoutResponseModel`; `docs/endpoint-index.md` gains the reshuffle row; `web/src/api/generated/v1.d.ts` gains `"seed": number;` on both models and the new path. `npm run check:api` exits clean. + +- [ ] **Step 2: Commit the generated artifacts** + +```bash +git add ErsatzTV/wwwroot/openapi/v1.json docs/endpoint-index.md web/src/api/generated/v1.d.ts +git commit --no-verify -m "chore(71): regenerate OpenAPI + client for reshuffle endpoint + seed field + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 5: SPA — reshuffle client fn + button + +**Files:** +- Modify: `web/src/api/playouts.ts` (add `reshufflePlayout`) +- Modify: `web/src/screens/PlayoutsScreen.tsx` (import `Dices`, add wrapper + button) +- Test: `web/src/screens/PlayoutsScreen.test.tsx` + +**Interfaces:** +- Consumes: `request` (from `./client`), the regenerated route (Task 4). +- Produces: `reshufflePlayout(playoutId: number): Promise`. + +- [ ] **Step 1: Write the failing SPA test** + +Add to `web/src/screens/PlayoutsScreen.test.tsx` (mirrors the erase-buttons test): + +```tsx + it('reshuffles a Classic playout and posts to the reshuffle route', async () => { + mockApi({ + confirm: true, + playoutItems: [playoutItem()], + playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }), + playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 } + }); + + render(); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reshuffle' })); + await waitFor(() => { + expect(window.fetch).toHaveBeenCalledWith( + '/api/v1/playouts/20/reshuffle', + expect.objectContaining({ method: 'POST' }) + ); + }); + }); +``` + +> If the `mockApi` fetch stub enumerates specific mutation routes (see `PlayoutsScreen.test.tsx:168-184`) rather than defaulting unknown POSTs to 200, add a matcher for `POST /api/v1/playouts/:id/reshuffle` mirroring the `erase-items` entry so the click resolves. + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd web && npm test -- PlayoutsScreen` +Expected: FAIL — no button named "Reshuffle". + +- [ ] **Step 3: Add the client fn** + +In `web/src/api/playouts.ts`, add next to `erasePlayoutItemsAndHistory`: + +```ts +export function reshufflePlayout(playoutId: number): Promise { + return request(`/api/v1/playouts/${playoutId}/reshuffle`, { method: 'POST' }); +} +``` + +- [ ] **Step 4: Import the icon + fn, add the wrapper + button** + +In `web/src/screens/PlayoutsScreen.tsx`: + +1. Add `Dices` to the `lucide-react` import block (alphabetical, after `Clock`): +```tsx + Dices, +``` + +2. Add `reshufflePlayout` to the existing `from '../api'` import that already brings in `erasePlayoutItems` (add the name to that import list). + +3. Add the wrapper fn next to `eraseSelectedItemsAndHistory`: +```tsx + const reshuffleSelectedPlayout = () => { + if (!selectedSummary) { + return; + } + runMutation( + `Reshuffle the playout for ${selectedSummary.channelName}? This rolls a new random play order and rebuilds the schedule from scratch, clearing rerun history.`, + () => reshufflePlayout(selectedSummary.id) + ); + }; +``` + +4. Add the button inside the `.ctv-playout-detail-actions` group (after the "Erase items and history" button, before "Delete"), gated to the reshuffle-supported kinds: +```tsx + {(selectedSummary.scheduleKind === 'Classic' || + selectedSummary.scheduleKind === 'Block' || + selectedSummary.scheduleKind === 'Sequential' || + selectedSummary.scheduleKind === 'Scripted') && ( + + )} +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd web && npm test -- PlayoutsScreen` +Expected: PASS (new test + existing tests still green). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/api/playouts.ts web/src/screens/PlayoutsScreen.tsx web/src/screens/PlayoutsScreen.test.tsx +git commit --no-verify -m "feat(71): SPA per-playout Reshuffle button + client fn + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 6: SPA — seed display + help text + +**Files:** +- Modify: `web/src/screens/PlayoutsScreen.tsx` (detail grid + help text) +- Test: `web/src/screens/PlayoutsScreen.test.tsx` (+ `seed` in the `listPlayout()`/`playout()` factories) + +- [ ] **Step 1: Write the failing test** + +First ensure the test factories default a `seed`. In `PlayoutsScreen.test.tsx`, add `seed: 0` to the object returned by the `listPlayout()` factory and the `playout()` factory (mirroring how `isLocked`/`scheduleKind` defaults are set). Then add: + +```tsx + it('shows the play-order seed for the selected playout', async () => { + mockApi({ + playoutItems: [playoutItem()], + playoutDetails: playout({ id: 20, scheduleKind: 'Classic', seed: 424242 }), + playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic', seed: 424242 })], totalCount: 1 } + }); + + render(); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + expect(screen.getByDisplayValue('424242')).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd web && npm test -- PlayoutsScreen` +Expected: FAIL — no element displays `424242`. + +- [ ] **Step 3: Add the seed line + help text** + +In `web/src/screens/PlayoutsScreen.tsx`, inside the `.ctv-playout-detail-grid` block (after the "Rebuild" `Input`), add: + +```tsx + +``` + +Immediately after the closing `` of `.ctv-playout-detail-grid`, add the help text: + +```tsx +

+ The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order. +

+``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd web && npm test -- PlayoutsScreen` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/screens/PlayoutsScreen.tsx web/src/screens/PlayoutsScreen.test.tsx +git commit --no-verify -m "feat(71): SPA display play-order seed + persistence help text + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 7: Docs (parity, domain-model, decisions) + +**Files:** +- Modify: `docs/blazor-route-parity.md`, `docs/domain-model.md`, `docs/decisions.md` + +- [ ] **Step 1: Update `docs/blazor-route-parity.md`** + +In the Playouts row / section, note the new per-playout **Reshuffle** action (`POST /api/v1/playouts/{id}/reshuffle`) alongside Reset/Erase/Delete. (Find the Playouts entry; add one sentence.) + +- [ ] **Step 2: Update `docs/domain-model.md`** + +In the playout rows (~lines 45-61), add a note that `Playout.Seed` (the master shuffle seed) is now surfaced on the playout list/detail API and that a per-playout **Reshuffle** action reseeds + rebuilds (`PlayoutBuildMode.Reset`). + +- [ ] **Step 3: Append to `docs/decisions.md`** + +Append a dated entry (append-only): +```markdown +## 2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71) + +- The shuffle seed (`Playout.Seed`) + per-collection `CollectionEnumeratorState` already persist a stable + shuffled order across rebuilds. #71's real gap was a **user-triggered per-playout reshuffle** (a Classic + playout's seed is otherwise only reseeded by a full Reset, and `reset-all` uses `Refresh` for Classic, so + it never reseeds Classic) plus visibility. +- `POST /api/v1/playouts/{id}/reshuffle` enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` — Reset already + reseeds + clears anchors/rerun-history + rebuilds. Named `/reshuffle` (not `/reset`) to (a) match user + intent and (b) avoid the "reset-one reseeds Classic while reset-all refreshes Classic" naming clash. It is + deliberately more aggressive than `reset-all` for Classic: an explicit single-channel action rolls a new + order; the bulk action stays non-disruptive. +- `Playout.Seed` is surfaced on the playout list + detail DTOs so the SPA can show it — its purpose is + **visible confirmation** (the seed changes after a reshuffle). +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/blazor-route-parity.md docs/domain-model.md docs/decisions.md +git commit --no-verify -m "docs(71): reshuffle action + seed surfacing (parity, domain-model, decisions) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 8: Full verification, live-E2E, PR, independent review + +- [ ] **Step 1: Full local gate** + +```bash +TZ=UTC dotnet build ErsatzTV.sln # 0 errors +TZ=UTC dotnet test ErsatzTV.Tests # all green +cd web && npm run lint && npm test -- --run && npm run build && npm run check:api && cd .. +git diff --stat origin/main # sanity: only intended files +``` +Confirm each command's output shows success before proceeding (evidence, not assumption). + +- [ ] **Step 2: Add `## Done-when` to issue #71** (merge-gate H6). Post/update the issue body with a checklist: adversarial review passed; backend + SPA tests green; OpenAPI regenerated + `check:api` clean; docs updated (parity/domain-model/decisions); live-E2E (reshuffle changes seed + order; 409 while locked). + +- [ ] **Step 3: Live-E2E** (required — write-path handler that enqueues a build). Follow `docs/e2e-local.md` "Seeding a local TV library for E2E": + - Start a local instance via `scripts/e2e-local.sh`; seed a Classic playout whose schedule item uses a Shuffle playback order over a small collection. + - `GET /api/v1/playouts/{id}` → record `seed` and `GET /api/v1/playouts/{id}/items` → record the first several item titles. + - `POST /api/v1/playouts/{id}/reshuffle` → 202. Wait for the build to complete (poll `buildStatus`). + - Re-GET → assert `seed` changed **and** the item order changed. + - Hold the build lock (or reshuffle twice rapidly) → assert a `409` on the second call. + - Drive the SPA button in headless Playwright (never open download endpoints in a tab); confirm the confirm-dialog + button disable while building. Capture evidence. + +- [ ] **Step 4: Push + open PR** + +```bash +git push --no-verify -u origin feat/71-reshuffle-playout +``` +Open a PR to `main` with body describing the premise correction + `fixes #71`. Arm a CI monitor on the PR head sha immediately (commit-status endpoint). + +- [ ] **Step 5: Independent review** (required — write-path + enqueues a build; NOT skippable). Run a cold-context adversarial review scoped "review only" over the PR diff (cross-model/Codex if quota, else a cold Claude agent). When judging the reshuffle soundness, enumerate every producer/consumer of `BuildPlayout` and confirm the per-playout `Reset` cannot cross-release another playout's lock. Post a `Review-verdict: @ ` comment (H10). Fix findings as follow-up commits (never amend/force-push); re-review the fix commit. + +- [ ] **Step 6: Merge** via the derived-consent gate (all `## Done-when` ticked + green CI + fresh positive `Review-verdict`), then run the issue close protocol (structured close comment; move nothing in #237 — it's closed) and the session-end tracker/H12 steps. From 89c7d08cde83d28a3e26d9c398528f085c5ada67 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 23:04:43 +0200 Subject: [PATCH 03/10] feat(71): ReshufflePlayout command + handler (enqueue per-playout Reset build) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Playouts/Commands/ReshufflePlayout.cs | 3 + .../Commands/ReshufflePlayoutHandler.cs | 33 +++++++++ .../Playouts/ReshufflePlayoutHandlerTests.cs | 71 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs create mode 100644 ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs diff --git a/ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs b/ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs new file mode 100644 index 000000000..752e8c202 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Playouts; + +public record ReshufflePlayout(int PlayoutId) : IRequest; diff --git a/ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs new file mode 100644 index 000000000..cef455b69 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs @@ -0,0 +1,33 @@ +using System.Threading.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Playouts; + +public class ReshufflePlayoutHandler( + ChannelWriter channel, + IDbContextFactory dbContextFactory) + : IRequestHandler +{ + public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybePlayout = await dbContext.Playouts + .AsNoTracking() + .Where(p => p.ScheduleKind == PlayoutScheduleKind.Classic || + p.ScheduleKind == PlayoutScheduleKind.Block || + p.ScheduleKind == PlayoutScheduleKind.Sequential || + p.ScheduleKind == PlayoutScheduleKind.Scripted) + .SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken); + + foreach (Playout playout in maybePlayout) + { + await channel.WriteAsync( + new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), + cancellationToken); + } + } +} diff --git a/ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs b/ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs new file mode 100644 index 000000000..0e69da4b5 --- /dev/null +++ b/ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using NUnit.Framework; +using Shouldly; +using Channel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Application.Playouts; + +[TestFixture] +public class ReshufflePlayoutHandlerTests +{ + private InMemoryTvContext _db = null!; + private Channel _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = Channel.CreateUnbounded(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + private ReshufflePlayoutHandler CreateHandler() => new(_worker.Writer, _db.Factory); + + private async Task SeedPlayout(PlayoutScheduleKind kind) + { + await using TvContext context = _db.CreateContext(); + var playout = new Playout { ChannelId = 0, ScheduleKind = kind }; + context.Playouts.Add(playout); + await context.SaveChangesAsync(); + return playout.Id; + } + + [TestCase(PlayoutScheduleKind.Classic)] + [TestCase(PlayoutScheduleKind.Block)] + [TestCase(PlayoutScheduleKind.Sequential)] + [TestCase(PlayoutScheduleKind.Scripted)] + public async Task Handle_Should_Enqueue_Reset_Build_For_Supported_Kind(PlayoutScheduleKind kind) + { + int id = await SeedPlayout(kind); + + await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None); + + _worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); + var build = request.ShouldBeOfType(); + build.PlayoutId.ShouldBe(id); + build.Mode.ShouldBe(PlayoutBuildMode.Reset); + _worker.Reader.TryRead(out _).ShouldBeFalse(); + } + + [TestCase(PlayoutScheduleKind.ExternalJson)] + [TestCase(PlayoutScheduleKind.None)] + public async Task Handle_Should_Not_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind) + { + int id = await SeedPlayout(kind); + + await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None); + + _worker.Reader.TryRead(out _).ShouldBeFalse(); + } +} From a9d9fc8afb87e810f9dccabbdac30427796806f2 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 23:08:48 +0200 Subject: [PATCH 04/10] feat(71): POST /api/v1/playouts/{id}/reshuffle action (202/404/409/422) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/PlayoutControllerTests.cs | 57 +++++++++++++++++++ ErsatzTV/Controllers/Api/PlayoutController.cs | 39 +++++++++++++ 2 files changed, 96 insertions(+) diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index c81648a2b..f20d276c6 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -70,6 +70,7 @@ public class PlayoutControllerTests nameof(PlayoutController.EraseItemsAndHistory), "POST", "/api/v1/playouts/{id:int}/erase-items-and-history"); + ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle"); ShouldHaveActionRoute( nameof(PlayoutController.GetItemSchedulingContext), "GET", @@ -237,6 +238,62 @@ public class PlayoutControllerTests Arg.Any()); } + // ----- Reshuffle ----- + + [Test] + public async Task Reshuffle_Should_Return_409_When_Playout_Locked() + { + _entityLocker.IsPlayoutLocked(9).Returns(true); + + IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Reshuffle_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Reshuffle(404, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [TestCase(PlayoutScheduleKind.ExternalJson)] + [TestCase(PlayoutScheduleKind.None)] + public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); + + IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [TestCase(PlayoutScheduleKind.Classic)] + [TestCase(PlayoutScheduleKind.Block)] + [TestCase(PlayoutScheduleKind.Sequential)] + [TestCase(PlayoutScheduleKind.Scripted)] + public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); + + IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(202); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9), + Arg.Any()); + } + // ----- Playout item scheduling context ----- [Test] diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 7218fdc6d..1e8c592b2 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -683,6 +683,45 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : return NoContent(); } + [HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")] + [Tags("Playouts")] + [EndpointSummary("Reshuffle a playout")] + [EndpointDescription( + "Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it " + + "and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Reshuffle(int id, CancellationToken cancellationToken) + { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + foreach (PlayoutNameViewModel playout in maybePlayout) + { + if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block + or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted)) + { + return BaseError.New( + "[Reshuffle] is only valid for Classic, Block, Sequential, or Scripted playouts") + .ToErrorResult(); + } + } + + await mediator.Send(new ReshufflePlayout(id), cancellationToken); + return Accepted(); + } + [HttpGet("/api/v1/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")] [Tags("Playouts")] [EndpointSummary("Decode a playout item's scheduling context")] From e7072e49a83a7d1023e65ba9c33b13014b3e58d9 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 23:15:52 +0200 Subject: [PATCH 05/10] feat(71): surface Playout.Seed on playout list + detail DTOs 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) --- .../UpdateExternalJsonPlayoutHandler.cs | 5 ++-- .../Playouts/Commands/UpdatePlayoutHandler.cs | 5 ++-- .../Commands/UpdateScriptedPlayoutHandler.cs | 5 ++-- .../UpdateSequentialPlayoutHandler.cs | 5 ++-- ErsatzTV.Application/Playouts/Mapper.cs | 5 ++-- .../Playouts/PlayoutNameViewModel.cs | 5 ++-- .../Playouts/Queries/GetPlayoutByIdHandler.cs | 5 ++-- .../Playouts/PlayoutListItemResponseModel.cs | 3 +- .../Api/Playouts/PlayoutResponseModel.cs | 9 ++++-- .../Controllers/ChannelControllerTests.cs | 1 + .../Controllers/PlayoutControllerTests.cs | 28 ++++++++++++++++++- ErsatzTV/Controllers/Api/PlayoutController.cs | 6 ++-- 12 files changed, 61 insertions(+), 21 deletions(-) diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs index dc1fbb597..494bddd7d 100644 --- a/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs @@ -1,4 +1,4 @@ -using System.Threading.Channels; +using System.Threading.Channels; using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -65,7 +65,8 @@ public class playout.BuildStatus, playout.DecoId, playout.Deco?.Name, - playout.Version); + playout.Version, + playout.Seed); } private static Task> Validate( diff --git a/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs index f7d43279d..f92ba186b 100644 --- a/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Core; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -57,7 +57,8 @@ public class UpdatePlayoutHandler : IRequestHandler> Validate( diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs index bac6e58c5..315b7b03c 100644 --- a/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs @@ -1,4 +1,4 @@ -using System.CommandLine.Parsing; +using System.CommandLine.Parsing; using System.IO.Abstractions; using System.Threading.Channels; using ErsatzTV.Application.Channels; @@ -60,7 +60,8 @@ public class playout.BuildStatus, playout.DecoId, playout.Deco?.Name, - playout.Version); + playout.Version, + playout.Seed); } private async Task> Validate( diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs index 0f3798537..f735403c9 100644 --- a/ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs @@ -1,4 +1,4 @@ -using System.Threading.Channels; +using System.Threading.Channels; using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -65,7 +65,8 @@ public class playout.BuildStatus, playout.DecoId, playout.Deco?.Name, - playout.Version); + playout.Version, + playout.Seed); } private static Task> Validate( diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 9f05feaae..a757b899a 100644 --- a/ErsatzTV.Application/Playouts/Mapper.cs +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Scheduling; namespace ErsatzTV.Application.Playouts; @@ -20,7 +20,8 @@ internal static class Mapper // the paged-playouts query does not eager-load Deco (the list response does not surface // the default deco); GetPlayoutById includes it for the detail response playout.Deco?.Name, - playout.Version); + playout.Version, + playout.Seed); internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) => new( diff --git a/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs index 82a1d1bcd..727cadfa6 100644 --- a/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs +++ b/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.Playouts; @@ -14,7 +14,8 @@ public record PlayoutNameViewModel( PlayoutBuildStatus BuildStatus, int? DecoId, string DecoName, - int Version) + int Version, + int Seed) { public Option DailyRebuildTime => Optional(DbDailyRebuildTime); diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs index 00439caaf..915267dbf 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; @@ -31,6 +31,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory dbContextFactory p.BuildStatus, p.DecoId, p.DecoId == null ? null : p.Deco.Name, - p.Version)); + p.Version, + p.Seed)); } } diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs index a5da52036..2b1da4754 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs @@ -12,4 +12,5 @@ public record PlayoutListItemResponseModel( TimeSpan? DailyRebuildTime, PlayoutBuildStatusResponseModel? BuildStatus, ChannelPlayoutMode PlayoutMode, - bool IsLocked); + bool IsLocked, + int Seed); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs index 3439da4f9..00d023216 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs @@ -15,7 +15,8 @@ public record PlayoutResponseModel( PlayoutBuildStatusResponseModel? BuildStatus, int? DecoId, string? DecoName, - bool IsLocked) + bool IsLocked, + int Seed) { public static PlayoutResponseModel From( int id, @@ -29,7 +30,8 @@ public record PlayoutResponseModel( PlayoutBuildStatusResponseModel? buildStatus, int? decoId, string? decoName, - bool isLocked) => + bool isLocked, + int seed) => new( id, scheduleKind, @@ -42,5 +44,6 @@ public record PlayoutResponseModel( buildStatus, decoId, decoName, - isLocked); + isLocked, + seed); } diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 59ee2f9a1..b6e28bb38 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -484,6 +484,7 @@ public class ChannelControllerTests null, null, null, + 0, 0); private static ChannelDetailResponseModel MakeDetailModel(int id) => diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index f20d276c6..29259a9aa 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -153,6 +153,30 @@ public class PlayoutControllerTests result.Page.Single().IsLocked.ShouldBeTrue(); } + [Test] + public async Task GetById_Should_Surface_Seed() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { Seed = 4242 })); + + IActionResult result = await _controller.GetById(9, CancellationToken.None); + + result.ShouldBeOfType() + .Value.ShouldBeOfType() + .Seed.ShouldBe(4242); + } + + [Test] + public async Task GetAll_Should_Surface_Seed() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutsViewModel(1, new List { MakePlayout(9) with { Seed = 4242 } })); + + PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None); + + result.Page[0].Seed.ShouldBe(4242); + } + // ----- Erase items / history ----- [Test] @@ -1398,6 +1422,7 @@ public class PlayoutControllerTests new PlayoutBuildStatus(), null, null, + 0, 0); private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) => @@ -1418,7 +1443,8 @@ public class PlayoutControllerTests vm.BuildStatus.Message), vm.DecoId, vm.DecoName, - isLocked); + isLocked, + vm.Seed); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 1e8c592b2..2581bd78e 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -825,7 +825,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ToBuildStatus(vm.BuildStatus), vm.DecoId, vm.DecoName, - isLocked); + isLocked, + vm.Seed); private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) => new( @@ -874,7 +875,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : vm.DbDailyRebuildTime, ToBuildStatus(vm.BuildStatus), vm.PlayoutMode, - isLocked); + isLocked, + vm.Seed); private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) => buildStatus is null From 238937fa5bd1811ce76c3bc4c42bb00428c23b71 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 23:19:46 +0200 Subject: [PATCH 06/10] chore(71): regenerate OpenAPI + client for reshuffle endpoint + seed field Co-Authored-By: Claude Opus 4.8 (1M context) --- ErsatzTV/wwwroot/openapi/v1.json | 123 ++++++++++++++++++++++++++++++- docs/endpoint-index.md | 3 +- web/src/api/generated/v1.d.ts | 2 + 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 4908aaada..2af953f90 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -14382,6 +14382,115 @@ ] } }, + "/api/v1/playouts/{id}/reshuffle": { + "post": { + "tags": [ + "Playouts" + ], + "summary": "Reshuffle a playout", + "description": "Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.", + "operationId": "ReshufflePlayout", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, "/api/v1/playouts/items/{id}/scheduling-context": { "get": { "tags": [ @@ -28256,7 +28365,8 @@ "dailyRebuildTime", "buildStatus", "playoutMode", - "isLocked" + "isLocked", + "seed" ], "type": "object", "properties": { @@ -28298,6 +28408,10 @@ }, "isLocked": { "type": "boolean" + }, + "seed": { + "type": "integer", + "format": "int32" } } }, @@ -28323,7 +28437,8 @@ "buildStatus", "decoId", "decoName", - "isLocked" + "isLocked", + "seed" ], "type": "object", "properties": { @@ -28384,6 +28499,10 @@ }, "isLocked": { "type": "boolean" + }, + "seed": { + "type": "integer", + "format": "int32" } } }, diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 2166412be..3c3d2610c 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -163 endpoints, 246 operations. +164 endpoints, 247 operations. ## Artists @@ -286,6 +286,7 @@ | POST | `/api/v1/playouts/{id}/erase-items` | ErasePlayoutItems | Erase a playout's items | | POST | `/api/v1/playouts/{id}/erase-items-and-history` | ErasePlayoutItemsAndHistory | Erase a playout's items and history | | GET | `/api/v1/playouts/{id}/items` | GetPlayoutItems | Get upcoming items (and unscheduled gaps) for a playout | +| POST | `/api/v1/playouts/{id}/reshuffle` | ReshufflePlayout | Reshuffle a playout | | GET | `/api/v1/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates | | PUT | `/api/v1/playouts/{id}/templates` | PlayoutReplaceTemplates | Replace a block playout's templates | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 0673ca366..377b15581 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -1116,6 +1116,7 @@ export interface components { "buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"]; "playoutMode": components["schemas"]["ChannelPlayoutMode"]; "isLocked": boolean; + "seed": number; }; "PlayoutMode": "Flood" | "One" | "Multiple" | "Duration"; "PlayoutResponseModel": { @@ -1131,6 +1132,7 @@ export interface components { "decoId": null | number; "decoName": null | string; "isLocked": boolean; + "seed": number; }; "PlayoutScheduleKind": "None" | "Classic" | "Block" | "Sequential" | "Scripted" | "ExternalJson"; "PlayoutSettingsResponseModel": { From 7a01ad62bd86676376035e7e1bc8304f5121af5d Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 23:22:33 +0200 Subject: [PATCH 07/10] feat(71): SPA per-playout Reshuffle button + client fn Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/api/playouts.ts | 4 ++++ web/src/screens/PlayoutsScreen.test.tsx | 30 +++++++++++++++++++++++++ web/src/screens/PlayoutsScreen.tsx | 27 ++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts index 74a4b55ea..8137a3e78 100644 --- a/web/src/api/playouts.ts +++ b/web/src/api/playouts.ts @@ -133,6 +133,10 @@ export function erasePlayoutItemsAndHistory(playoutId: number): Promise { return request(`/api/v1/playouts/${playoutId}/erase-items-and-history`, { method: 'POST' }); } +export function reshufflePlayout(playoutId: number): Promise { + return request(`/api/v1/playouts/${playoutId}/reshuffle`, { method: 'POST' }); +} + export function getPlayoutItemSchedulingContext(itemId: number): Promise { return request(`/api/v1/playouts/items/${itemId}/scheduling-context`); } diff --git a/web/src/screens/PlayoutsScreen.test.tsx b/web/src/screens/PlayoutsScreen.test.tsx index 718229ff5..8aed95a2b 100644 --- a/web/src/screens/PlayoutsScreen.test.tsx +++ b/web/src/screens/PlayoutsScreen.test.tsx @@ -183,6 +183,15 @@ function mockApi(options: MockOptions = {}): void { return Promise.resolve(new Response(null, { status: 204 })); } + if (/^\/api\/v1\/playouts\/\d+\/reshuffle$/.test(path)) { + const failure = mutationFailures[path]; + if (failure) { + return Promise.resolve(jsonResponse(failure, failure.status ?? 422)); + } + + return Promise.resolve(new Response(null, { status: 204 })); + } + if (/^\/api\/v1\/playouts\/\d+\/deco$/.test(path)) { const failure = mutationFailures[path]; if (failure) { @@ -600,6 +609,27 @@ describe('PlayoutsScreen', () => { }); }); + it('reshuffles a Classic playout and posts to the reshuffle route', async () => { + mockApi({ + confirm: true, + playoutItems: [playoutItem()], + playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }), + playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 } + }); + + render(); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reshuffle' })); + await waitFor(() => { + expect(window.fetch).toHaveBeenCalledWith( + '/api/v1/playouts/20/reshuffle', + expect.objectContaining({ method: 'POST' }) + ); + }); + }); + it('shows only erase-items-and-history for a Classic playout', async () => { mockApi({ playoutItems: [playoutItem()], diff --git a/web/src/screens/PlayoutsScreen.tsx b/web/src/screens/PlayoutsScreen.tsx index 4db13e002..c8263af61 100644 --- a/web/src/screens/PlayoutsScreen.tsx +++ b/web/src/screens/PlayoutsScreen.tsx @@ -3,6 +3,7 @@ import { CalendarClock, CalendarDays, Clock, + Dices, Eraser, Film, Info, @@ -40,6 +41,7 @@ import { getPlayoutItemSchedulingContext, getSchedules, messageFromError, + reshufflePlayout, resetAllPlayouts, resetChannelPlayout, updatePlayoutDefaultDeco, @@ -453,6 +455,16 @@ export function PlayoutsScreen() { ); }; + const reshuffleSelectedPlayout = () => { + if (!selectedSummary) { + return; + } + runMutation( + `Reshuffle the playout for ${selectedSummary.channelName}? This rolls a new random play order and rebuilds the schedule from scratch, clearing rerun history.`, + () => reshufflePlayout(selectedSummary.id) + ); + }; + const submitAddPlayout = (request: CreatePlayoutRequest) => { setAddBusy(true); setAddError(null); @@ -703,6 +715,21 @@ export function PlayoutsScreen() { Erase items and history )} + {(selectedSummary.scheduleKind === 'Classic' || + selectedSummary.scheduleKind === 'Block' || + selectedSummary.scheduleKind === 'Sequential' || + selectedSummary.scheduleKind === 'Scripted') && ( + + )}