Merge pull request 'feat(71): per-playout reshuffle + shuffle-state surfacing' (#387) from feat/71-reshuffle-playout 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 / 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
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled

Merge pull request '#387' (feat(71): per-playout reshuffle + shuffle-state surfacing) from feat/71-reshuffle-playout into main

fixes #71
This commit was merged in pull request #387.
This commit is contained in:
2026-07-16 22:19:03 +00:00
26 changed files with 1514 additions and 25 deletions
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record ReshufflePlayout(int PlayoutId) : IRequest;
@@ -0,0 +1,40 @@
using System.Threading.Channels;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class ReshufflePlayoutHandler(
IMediator mediator,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ReshufflePlayout>
{
public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> 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)
{
// Roll a new play order. BuildPlayout(Reset) only reseeds Playout.Seed for CLASSIC playouts
// (PlayoutBuilder); Block/Sequential/Scripted rebuild deterministically from the existing seed.
// ErasePlayoutHistory is the one primitive that reseeds + clears the derived per-collection
// enumerator anchors for ALL four kinds — run it first, then rebuild from scratch.
await mediator.Send(new ErasePlayoutHistory(playout.Id), cancellationToken);
await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
}
}
}
@@ -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<Validation<BaseError, Playout>> Validate(
@@ -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<UpdatePlayout, Either<BaseEr
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Version,
playout.Seed);
}
private static Task<Validation<BaseError, Playout>> Validate(
@@ -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<Validation<BaseError, Playout>> Validate(
@@ -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<Validation<BaseError, Playout>> Validate(
+3 -2
View File
@@ -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(
@@ -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<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
@@ -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<TvContext> dbContextFactory
p.BuildStatus,
p.DecoId,
p.DecoId == null ? null : p.Deco.Name,
p.Version));
p.Version,
p.Seed));
}
}
@@ -12,4 +12,5 @@ public record PlayoutListItemResponseModel(
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus,
ChannelPlayoutMode PlayoutMode,
bool IsLocked);
bool IsLocked,
int Seed);
@@ -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);
}
@@ -0,0 +1,132 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
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<IBackgroundServiceRequest> _worker = null!;
private IMediator _mediator = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
_mediator = Substitute.For<IMediator>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private ReshufflePlayoutHandler CreateHandler() => new(_mediator, _worker.Writer, _db.Factory);
private async Task<int> SeedPlayout(PlayoutScheduleKind kind, int? seed = null)
{
await using TvContext context = _db.CreateContext();
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
if (seed.HasValue)
{
playout.Seed = seed.Value;
}
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_Erase_History_Then_Enqueue_Reset_Build_For_Supported_Kind(
PlayoutScheduleKind kind)
{
int id = await SeedPlayout(kind);
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<ErasePlayoutHistory>(e => e.PlayoutId == id),
Arg.Any<CancellationToken>());
_worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
var build = request.ShouldBeOfType<BuildPlayout>();
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_Erase_History_Or_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
int id = await SeedPlayout(kind);
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
_worker.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task Handle_Should_Actually_Reseed_Block_Playout_Via_ErasePlayoutHistory()
{
// This is the C1 regression test: BuildPlayout(Reset) alone only reseeds Playout.Seed for
// Classic playouts (PlayoutBuilder). For Block/Sequential/Scripted it is a no-op reshuffle
// unless the handler routes through ErasePlayoutHistory first. Wire the substituted
// IMediator to actually invoke the real ErasePlayoutHistoryHandler against the in-memory DB
// so the reseed side effect is genuinely exercised, not merely asserted-as-called.
const int originalSeed = 12345;
int id = await SeedPlayout(PlayoutScheduleKind.Block, originalSeed);
// seed a PlayoutHistory row so we can also assert the deterministic "cleared" side effect
// (avoids relying solely on new Random().Next() != originalSeed, which is a ~1-in-2^31 flake)
await using (TvContext seedContext = _db.CreateContext())
{
seedContext.PlayoutHistory.Add(
new PlayoutHistory
{
PlayoutId = id,
Key = "test-collection",
When = DateTime.UtcNow,
Finish = DateTime.UtcNow
});
await seedContext.SaveChangesAsync();
}
_mediator.Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>())
.Returns(callInfo => new ErasePlayoutHistoryHandler(_db.Factory).Handle(
(ErasePlayoutHistory)callInfo[0],
(CancellationToken)callInfo[1]));
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await using TvContext context = _db.CreateContext();
Playout playout = await context.Playouts.SingleAsync(p => p.Id == id);
playout.Seed.ShouldNotBe(originalSeed);
List<PlayoutHistory> remainingHistory = await context.PlayoutHistory
.Where(h => h.PlayoutId == id)
.ToListAsync();
remainingHistory.ShouldBeEmpty();
}
}
@@ -484,6 +484,7 @@ public class ChannelControllerTests
null,
null,
null,
0,
0);
private static ChannelDetailResponseModel MakeDetailModel(int id) =>
@@ -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",
@@ -152,6 +153,30 @@ public class PlayoutControllerTests
result.Page.Single().IsLocked.ShouldBeTrue();
}
[Test]
public async Task GetById_Should_Surface_Seed()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));
IActionResult result = await _controller.GetById(9, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>()
.Value.ShouldBeOfType<PlayoutResponseModel>()
.Seed.ShouldBe(4242);
}
[Test]
public async Task GetAll_Should_Surface_Seed()
{
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { MakePlayout(9) with { Seed = 4242 } }));
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
result.Page[0].Seed.ShouldBe(4242);
}
// ----- Erase items / history -----
[Test]
@@ -237,6 +262,62 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
// ----- 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<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.ExternalJson)]
[TestCase(PlayoutScheduleKind.None)]
public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[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<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
await _mediator.Received(1).Send(
Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
// ----- Playout item scheduling context -----
[Test]
@@ -1341,6 +1422,7 @@ public class PlayoutControllerTests
new PlayoutBuildStatus(),
null,
null,
0,
0);
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
@@ -1361,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)
{
+43 -2
View File
@@ -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<IActionResult> Reshuffle(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> 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")]
@@ -786,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(
@@ -835,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
+121 -2
View File
@@ -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"
}
}
},
+1 -1
View File
@@ -146,7 +146,7 @@ SPA-ready are preserved below for the record.
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
> | Playout creation + alternate-schedules | PARITY-OK | — |
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215; per-playout **Reshuffle** action added alongside Reset/Erase/Delete — `POST /api/v1/playouts/{id}/reshuffle` reseeds + rebuilds via `PlayoutBuildMode.Reset`, 2026-07-16 #71) | — |
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — |
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
> | Channel editor | PARITY-OK (external logo URL mutual-exclusion, bare-create defaults, enumerated pickers — 2026-07-11) | #212 |
+19
View File
@@ -82,6 +82,7 @@ in-file entries.
- [2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)](#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299)
- [2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)](#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340)
- [2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)](#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69)
- [2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)](#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71)
---
@@ -1040,3 +1041,21 @@ name already exists is flagged and de-selected by default, and existing channels
Bulk create loops the #63 `CreateChannelFromLineup` primitive via `ISender` and returns a per-channel
Created/Skipped/Failed outcome. Known MVP limitation: the generated SmartCollection is named after the
channel; a name collision with an existing SmartCollection surfaces as a per-channel Failed outcome.
## 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` first runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears
anchors/rerun-history) and then enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` to rebuild. `Reset`
alone only reseeds the seed for **Classic** playouts (`PlayoutBuilder`); Block/Sequential/Scripted rebuild
deterministically from the existing seed, so `ErasePlayoutHistory` is the one primitive that reseeds +
clears the derived per-collection enumerator state for all four resettable kinds — reshuffle runs it
first so the reshuffle is never a no-op for non-Classic playouts. 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).
+1
View File
@@ -59,6 +59,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **Sequential playout** | Driven by a YAML file (`Playout.ScheduleFile`); validated via the Schedule Validator screen. | `Playout.ScheduleFile` | `/app/troubleshooting/yaml` (validate only; file itself is server-side) |
| **Scripted playout** | `PlayoutScheduleKind.Scripted`; backed by `ScriptedScheduleController`. | — | — |
| **External-JSON playout** | `PlayoutScheduleKind.ExternalJson = 20`; JSON-driven, same shape idea as Sequential but JSON instead of YAML. | `Playout.ScheduleFile` | — |
| **Reshuffle** | `Playout.Seed` (the master shuffle seed) is surfaced on the playout list + detail API so the SPA can show it; a per-playout **Reshuffle** action reseeds and rebuilds via `POST /api/v1/playouts/{id}/reshuffle` (enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)`), distinct from `reset-all`'s `Refresh` mode for Classic. | `Playout.Seed` | `/app/playouts` |
| **Deco** | Per-playout "decoration": one of 4 independently-modal sections — watermark, graphics elements, default filler, dead-air fallback — plus break content. Each mode section is `Inherit`/`Disable`/`Override`/`Merge` (`DecoMode`). Can attach directly to a Block playout via `Playout.DecoId`. | `Deco`, `DecoGroup`, `DecoBreakContent` | `/app/decos` |
| **DecoTemplate** | Time-of-day (`DecoTemplateItem.StartTime`/`EndTime`) calendar of `Deco`s, assigned to a playout via `PlayoutTemplate.DecoTemplateId` (same row as the Block-template assignment — one `PlayoutTemplate` entry carries both a `Template` and an optional `DecoTemplate`). | `DecoTemplate`, `DecoTemplateItem`, `DecoTemplateGroup` | `/app/deco-templates` |
| **Default deco vs deco templates** | `Playout.DecoId` = one static deco for the whole playout; `PlayoutTemplate.DecoTemplateId` = a time-varying deco schedule. Both are optional and independent. | `Playout`, `PlayoutTemplate` | `/app/playouts/{id}/templates` |
+2 -1
View File
@@ -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 |
@@ -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 <project>` on staged files only).
- Commit trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- 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<TvContext>`, 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<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private ReshufflePlayoutHandler CreateHandler() => new(_worker.Writer, _db.Factory);
private async Task<int> 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<BuildPlayout>();
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<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ReshufflePlayout>
{
public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> 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<Playout>` 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) <noreply@anthropic.com>"
```
> 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<IActionResult> 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<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.ExternalJson)]
[TestCase(PlayoutScheduleKind.None)]
public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[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<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
await _mediator.Received(1).Send(
Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
```
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<IActionResult> Reshuffle(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> 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) <noreply@anthropic.com>"
```
---
### 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<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));
IActionResult result = await _controller.GetById(9, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>()
.Value.ShouldBeOfType<PlayoutResponseModel>()
.Seed.ShouldBe(4242);
}
[Test]
public async Task GetAll_Should_Surface_Seed()
{
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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<void>` (from `./client`), the regenerated route (Task 4).
- Produces: `reshufflePlayout(playoutId: number): Promise<void>`.
- [ ] **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(<PlayoutsScreen />);
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<void> {
return request<void>(`/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') && (
<Button
disabled={mutating || selectedLocked}
onClick={reshuffleSelectedPlayout}
size="sm"
startIcon={<Dices aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="ghost"
>
Reshuffle
</Button>
)}
```
- [ ] **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) <noreply@anthropic.com>"
```
---
### 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(<PlayoutsScreen />);
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
<Input disabled label="Seed" value={String(playout?.seed ?? selectedSummary.seed)} />
```
Immediately after the closing `</div>` of `.ctv-playout-detail-grid`, add the help text:
```tsx
<p className="ctv-playout-seed-help">
The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order.
</p>
```
- [ ] **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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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: <MERGEABLE|BLOCKED> @ <head-sha>` 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.
@@ -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<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> 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<IBackgroundServiceRequest>`.
- **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.
+2
View File
@@ -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": {
+4
View File
@@ -133,6 +133,10 @@ export function erasePlayoutItemsAndHistory(playoutId: number): Promise<void> {
return request<void>(`/api/v1/playouts/${playoutId}/erase-items-and-history`, { method: 'POST' });
}
export function reshufflePlayout(playoutId: number): Promise<void> {
return request<void>(`/api/v1/playouts/${playoutId}/reshuffle`, { method: 'POST' });
}
export function getPlayoutItemSchedulingContext(itemId: number): Promise<PlayoutItemSchedulingContext> {
return request<PlayoutItemSchedulingContext>(`/api/v1/playouts/items/${itemId}/scheduling-context`);
}
+45
View File
@@ -24,6 +24,7 @@ function listPlayout(overrides: Record<string, unknown> = {}): Record<string, un
isLocked: false,
scheduleKind: 'Classic',
scheduleName: 'Prime Time Cartoons',
seed: 0,
...overrides
};
}
@@ -33,6 +34,7 @@ function playout(overrides: Record<string, unknown> = {}): Record<string, unknow
return listPlayout({
playoutMode: 'Continuous',
scheduleFile: null,
seed: 0,
...overrides
});
}
@@ -183,6 +185,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: 202 }));
}
if (/^\/api\/v1\/playouts\/\d+\/deco$/.test(path)) {
const failure = mutationFailures[path];
if (failure) {
@@ -600,6 +611,40 @@ 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(<PlayoutsScreen />);
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 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(<PlayoutsScreen />);
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByDisplayValue('424242')).toBeInTheDocument();
});
it('shows only erase-items-and-history for a Classic playout', async () => {
mockApi({
playoutItems: [playoutItem()],
+36
View File
@@ -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);
@@ -633,7 +645,16 @@ export function PlayoutsScreen() {
<Input disabled label="Schedule" value={playout?.scheduleName ?? selectedSummary.scheduleName} />
<Input disabled label="Kind" value={formatScheduleEnum(playout?.scheduleKind ?? selectedSummary.scheduleKind)} />
<Input disabled label="Rebuild" value={formatDailyRebuild(playout?.dailyRebuildTime ?? selectedSummary.dailyRebuildTime)} />
<Input disabled label="Seed" value={String(playout?.seed ?? selectedSummary.seed)} />
</div>
{(selectedSummary.scheduleKind === 'Classic' ||
selectedSummary.scheduleKind === 'Block' ||
selectedSummary.scheduleKind === 'Sequential' ||
selectedSummary.scheduleKind === 'Scripted') && (
<p className="ctv-playout-seed-help">
The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order.
</p>
)}
{selectedSummary.scheduleKind === 'Classic' && (
<div className="ctv-playout-detail-actions">
<Button
@@ -703,6 +724,21 @@ export function PlayoutsScreen() {
Erase items and history
</Button>
)}
{(selectedSummary.scheduleKind === 'Classic' ||
selectedSummary.scheduleKind === 'Block' ||
selectedSummary.scheduleKind === 'Sequential' ||
selectedSummary.scheduleKind === 'Scripted') && (
<Button
disabled={mutating || selectedLocked}
onClick={reshuffleSelectedPlayout}
size="sm"
startIcon={<Dices aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="ghost"
>
Reshuffle
</Button>
)}
<Button
disabled={mutating || selectedLocked}
onClick={deleteSelectedPlayout}