Compare commits

...
Author SHA1 Message Date
timothy f9bb230673 chore: retrigger PR checks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #99
2026-07-04 20:10:26 +02:00
timothy 6350845101 fix(core): harden direct session tracking
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #99
2026-07-04 20:05:19 +02:00
timothy 9cd107bc8d feat(core): track direct stream sessions
refs #99
2026-07-04 19:57:50 +02:00
timothyandClaude Fable 5 3e8cfa6288 docs: advance ChicoryTV issue queue past the merge pass (PR #120); next prompt = #109 Dashboard
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m45s
Records: main=1b6de047, PR #122 hotfix pending consent, PR #121 (#99) FIX-FIRST
review state, new baselines (364/488), worktree cleanup status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:52:50 +02:00
timothy 692a71fd13 Merge pull request 'fix(docker): use valid node:22-bookworm-slim base for web-build stage' (#122) from fix/dockerfile-node-tag into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m13s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m50s
2026-07-04 17:42:16 +00:00
timothyandClaude Fable 5 2952aceb5b fix(docker): use valid node:22-bookworm-slim base for web-build stage
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
node:22-noble does not exist on Docker Hub (no noble variant of the
official node image; the -noble suffix was carried over from the MS
dotnet image tags). Broke the main image-build job (run 482) after
PR #120 landed, since PR runs skip the build job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:19:12 +02:00
timothy 1b6de047a0 Merge pull request 'feat(web): ChicoryTV SPA foundation (#59 stack)' (#120) from feat/59-spa-foundation into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m8s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 13s
2026-07-04 06:11:14 +00:00
11 changed files with 547 additions and 126 deletions
@@ -0,0 +1,98 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Streaming;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Streaming;
[TestFixture]
public class DirectStreamSessionTrackerTests
{
[Test]
public void Should_Track_Concurrent_Viewers_Per_Channel()
{
var tracker = new DirectStreamSessionTracker();
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
using IDisposable session2 = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
using IDisposable session3 = tracker.Register("2", StreamingMode.TransportStream);
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(2);
tracker.GetViewerCount("2").ShouldBe(1);
tracker.GetActiveSessions().Count.ShouldBe(3);
}
[Test]
public void Should_Remove_Only_Disposed_Session()
{
var tracker = new DirectStreamSessionTracker();
IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
IDisposable session2 = tracker.Register("1", StreamingMode.TransportStream);
session1.Dispose();
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(1);
session2.Dispose();
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public void Should_Dispose_Registration_Only_Once()
{
var tracker = new DirectStreamSessionTracker();
IDisposable session = tracker.Register("1", StreamingMode.TransportStream);
session.Dispose();
session.Dispose();
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public void Should_Filter_Active_Sessions_By_Channel()
{
var tracker = new DirectStreamSessionTracker();
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
using IDisposable session2 = tracker.Register("2", StreamingMode.HttpLiveStreamingDirect);
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("2");
sessions.Count.ShouldBe(1);
sessions.Single().ChannelNumber.ShouldBe("2");
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
}
[Test]
public void Should_Not_Orphan_Session_When_Last_Registration_Is_Removed_During_Register()
{
DirectStreamSessionTracker tracker = null;
IDisposable existingSession = null;
tracker = new TestDirectStreamSessionTracker(() => existingSession?.Dispose());
existingSession = tracker.Register("1", StreamingMode.TransportStream);
using IDisposable newSession = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(1);
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("1");
sessions.Count.ShouldBe(1);
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
}
private sealed class TestDirectStreamSessionTracker(Action onRegisteringSession) : DirectStreamSessionTracker
{
protected override void OnRegisteringSession() => onRegisteringSession();
}
}
@@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Streaming;
namespace ErsatzTV.Core.Interfaces.Streaming;
public interface IDirectStreamSessionTracker
{
IDisposable Register(string channelNumber, StreamingMode streamingMode);
bool IsActive(string channelNumber);
int GetViewerCount(string channelNumber);
IReadOnlyCollection<DirectStreamSession> GetActiveSessions();
IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber);
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Streaming;
public record DirectStreamSession(
Guid Id,
string ChannelNumber,
StreamingMode StreamingMode,
DateTimeOffset StartedAt);
@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Core.Streaming;
public class DirectStreamSessionTracker : IDirectStreamSessionTracker
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, DirectStreamSession>> _sessions = new();
public IDisposable Register(string channelNumber, StreamingMode streamingMode)
{
var session = new DirectStreamSession(Guid.NewGuid(), channelNumber, streamingMode, DateTimeOffset.Now);
ConcurrentDictionary<Guid, DirectStreamSession> channelSessions =
_sessions.GetOrAdd(channelNumber, _ => new ConcurrentDictionary<Guid, DirectStreamSession>());
OnRegisteringSession();
channelSessions.TryAdd(session.Id, session);
return new Registration(this, session);
}
protected virtual void OnRegisteringSession()
{
}
public bool IsActive(string channelNumber) => GetViewerCount(channelNumber) > 0;
public int GetViewerCount(string channelNumber) =>
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
? channelSessions.Count
: 0;
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions() =>
_sessions.Values.SelectMany(s => s.Values).ToList();
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber) =>
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
? channelSessions.Values.ToList()
: [];
private void Remove(DirectStreamSession session)
{
if (!_sessions.TryGetValue(session.ChannelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions))
{
return;
}
channelSessions.TryRemove(session.Id, out _);
}
private sealed class Registration(DirectStreamSessionTracker tracker, DirectStreamSession session) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
tracker.Remove(session);
}
}
}
}
@@ -0,0 +1,183 @@
using ErsatzTV.Controllers;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Streaming;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class TrackedFileStreamResultTests
{
[Test]
public async Task Should_Track_Session_Only_While_Result_Executes()
{
var tracker = new DirectStreamSessionTracker();
var stream = new BlockingReadStream();
TrackedFileStreamResult result = new(
stream,
"video/mp2t",
tracker,
"1",
StreamingMode.TransportStream);
ActionContext context = GetActionContext();
tracker.IsActive("1").ShouldBeFalse();
Task execute = result.ExecuteResultAsync(context);
await stream.WaitForRead();
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(1);
stream.Complete();
await execute;
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public async Task Should_Remove_Session_When_Response_Stream_Fails()
{
var tracker = new DirectStreamSessionTracker();
TrackedFileStreamResult result = new(
new ThrowingReadStream(new IOException("stream failed")),
"video/mp2t",
tracker,
"1",
StreamingMode.HttpLiveStreamingDirect);
Func<Task> execute = () => result.ExecuteResultAsync(GetActionContext());
await execute.ShouldThrowAsync<IOException>();
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public async Task Should_Remove_Session_When_Response_Stream_Is_Aborted()
{
var tracker = new DirectStreamSessionTracker();
TrackedFileStreamResult result = new(
new ThrowingReadStream(new OperationCanceledException("client aborted")),
"video/mp2t",
tracker,
"1",
StreamingMode.HttpLiveStreamingDirect);
await result.ExecuteResultAsync(GetActionContext());
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public async Task Should_Not_Track_Session_For_Head_Request()
{
IDirectStreamSessionTracker tracker = Substitute.For<IDirectStreamSessionTracker>();
var stream = new BlockingReadStream();
TrackedFileStreamResult result = new(
stream,
"video/mp2t",
tracker,
"1",
StreamingMode.TransportStream);
await result.ExecuteResultAsync(GetActionContext(HttpMethods.Head));
tracker.DidNotReceive().Register(Arg.Any<string>(), Arg.Any<StreamingMode>());
}
private static ActionContext GetActionContext(string method = "GET")
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = method;
httpContext.RequestServices = new ServiceCollection()
.AddLogging()
.AddControllers()
.Services
.BuildServiceProvider();
httpContext.Response.Body = new MemoryStream();
return new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor());
}
private sealed class BlockingReadStream : Stream
{
private readonly TaskCompletionSource _continue = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _readStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => 0;
public override long Position { get; set; }
public Task WaitForRead() => _readStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
public void Complete() => _continue.SetResult();
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
_readStarted.TrySetResult();
return ReadAfterContinue();
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
private async ValueTask<int> ReadAfterContinue()
{
await _continue.Task;
return 0;
}
}
private sealed class ThrowingReadStream(Exception exception) : Stream
{
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => 0;
public override long Position { get; set; }
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
ValueTask.FromException<int>(exception);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
public override int Read(byte[] buffer, int offset, int count) => throw exception;
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
+17 -3
View File
@@ -25,6 +25,7 @@ namespace ErsatzTV.Controllers;
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
public class IptvController : StreamingControllerBase
{
private readonly IDirectStreamSessionTracker _directStreamSessionTracker;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator;
@@ -33,9 +34,11 @@ public class IptvController : StreamingControllerBase
IMediator mediator,
IGraphicsEngine graphicsEngine,
ILogger<IptvController> logger,
IFFmpegSegmenterService ffmpegSegmenterService)
IFFmpegSegmenterService ffmpegSegmenterService,
IDirectStreamSessionTracker directStreamSessionTracker)
: base(graphicsEngine, logger)
{
_directStreamSessionTracker = directStreamSessionTracker;
_mediator = mediator;
_logger = logger;
_ffmpegSegmenterService = ffmpegSegmenterService;
@@ -150,7 +153,14 @@ public class IptvController : StreamingControllerBase
}
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
return mode == "ts-legacy"
? new TrackedFileStreamResult(
process.StandardOutput.BaseStream,
"video/mp2t",
_directStreamSessionTracker,
channelNumber,
StreamingMode.TransportStream)
: new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},
error => BadRequest(error.Value)));
}
@@ -353,7 +363,11 @@ public class IptvController : StreamingControllerBase
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
return GetProcessResponse(result, channelNumber, StreamingMode.HttpLiveStreamingDirect);
return GetProcessResponse(
result,
channelNumber,
StreamingMode.HttpLiveStreamingDirect,
_directStreamSessionTracker);
}
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
@@ -16,7 +16,8 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
protected IActionResult GetProcessResponse(
Either<BaseError, PlayoutItemProcessModel> result,
string channelNumber,
StreamingMode mode)
StreamingMode mode,
IDirectStreamSessionTracker directStreamSessionTracker = null)
{
foreach (BaseError error in result.LeftToSeq())
{
@@ -30,14 +31,18 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
{
return StartPlayout(processModel);
return StartPlayout(processModel, channelNumber, mode, directStreamSessionTracker);
}
// this will never happen
return new NotFoundResult();
}
private FileStreamResult StartPlayout(PlayoutItemProcessModel processModel)
private FileStreamResult StartPlayout(
PlayoutItemProcessModel processModel,
string channelNumber,
StreamingMode mode,
IDirectStreamSessionTracker directStreamSessionTracker)
{
// for process counter
var ffmpegProcess = new FFmpegProcess();
@@ -86,6 +91,10 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
null,
TaskScheduler.Default);
return new FileStreamResult(pipe.Reader.AsStream(), "video/mp2t");
Stream stream = pipe.Reader.AsStream();
return directStreamSessionTracker is null
? new FileStreamResult(stream, "video/mp2t")
: new TrackedFileStreamResult(stream, "video/mp2t", directStreamSessionTracker, channelNumber, mode);
}
}
@@ -0,0 +1,27 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers;
public class TrackedFileStreamResult(
Stream fileStream,
string contentType,
IDirectStreamSessionTracker directStreamSessionTracker,
string channelNumber,
StreamingMode streamingMode) : FileStreamResult(fileStream, contentType)
{
public override async Task ExecuteResultAsync(ActionContext context)
{
if (HttpMethods.IsHead(context.HttpContext.Request.Method))
{
await base.ExecuteResultAsync(context);
return;
}
using IDisposable registration = directStreamSessionTracker.Register(channelNumber, streamingMode);
await base.ExecuteResultAsync(context);
}
}
+2
View File
@@ -44,6 +44,7 @@ using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Core.Search;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Core.Trakt;
using ErsatzTV.Core.Troubleshooting;
using ErsatzTV.FFmpeg.Capabilities;
@@ -819,6 +820,7 @@ public class Startup
services.AddSingleton<IScannerProxyService, ScannerProxyService>();
services.AddSingleton<IScriptedPlayoutBuilderService, ScriptedPlayoutBuilderService>();
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
services.AddSingleton<IDirectStreamSessionTracker, DirectStreamSessionTracker>();
services.AddSingleton<ITempFilePool, TempFilePool>();
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();
services.AddSingleton<RecyclableMemoryStreamManager>();
+1 -1
View File
@@ -1,6 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-amd64 AS dotnet-runtime
FROM node:22-noble AS web-build
FROM node:22-bookworm-slim AS web-build
WORKDIR /source
COPY web/package*.json ./web/
WORKDIR /source/web
+118 -118
View File
@@ -4,135 +4,135 @@ Paste the prompt below into a fresh session to work the next item. Each session
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
filed backend gap issues #100#111; a 79-way parallel workflow build once exhausted RAM, so
builds are limited to 23 concurrent, never wide fan-outs. Done so far: #97 (PR #112, still
stacked on docs/59-ui-redesign-brief), #105 (PR #113), #108 (PR #114), #100+#101+#107+#110
(PR #116), #103+#106 (PR #115, Codex), #104 (PR #117), #111 (PR #118), #102 (PR #119 — the
last backend gap issue). All backend issues merged to main and closed as of 2026-07-04.
builds are limited to 23 concurrent, never wide fan-outs. Backend gaps all landed by
2026-07-04 (#105/#108/#100+#101+#107+#110/#103+#106/#104/#111/#102 → PRs #113#119). The
MERGE PASS (2026-07-04) then landed the whole SPA stack on main via PR #120.
**Session state (2026-07-04, post-#102)**: Codex usage is EXHAUSTED — single prompt per
session. main is at 6f6f37b7 (post-#119): FULL backend read API + artwork upload + JSON guide.
ErsatzTV.Tests on main = 323 tests; ErsatzTV.Core.Tests = 488 (+1 skipped) — all green.
No pre-seeded WIP branches remain. Worktree .worktrees/issue-97-channel-state-api still
exists for open PR #112 (targets docs/59-ui-redesign-brief); .worktrees/feat-102 is merged
(remove it). The docs/59-ui-redesign-brief branch (this file's branch) carries the SPA
foundation (web/) + #96/#98 + PR-#112-pending #97 — none of it on main yet → MERGE PASS is
the next item.
**Session state (2026-07-04, post-merge-pass)**: main = 1b6de047 (PR #120 "ChicoryTV SPA
foundation (#59 stack)"): design-system/ + web/ SPA foundation (#78#83) + backend #96/#98/#97
all on main; #97 closed (its universal-onAir remainder = #99). Reconciliation was ONE merge
commit (06355b75) + regen (7bd69439) + nullable-DTO fix (8d89ab16); Fable review: no dropped
work, no duplicated routes/tests. Test baselines on main: ErsatzTV.Tests **364**, Core.Tests
**488** (+1 skip); web/: `npm run typecheck` + `build` clean (outputs to ErsatzTV/wwwroot/app/,
gitignored; SPA types = web/src/api/generated/v1.d.ts, regen via `npm run generate:api`).
- **Main image build**: post-#120 run 482 FAILED in "Build & push image" only —
`node:22-noble` doesn't exist (tests+migrations green). Hotfix **PR #122**
(node:22-bookworm-slim, one line) is CI-green and awaiting "merge" consent. After merging,
confirm the next main run is fully green INCLUDING the image job before deploying anything.
- **PR #121 (#99, Codex, direct-stream session tracking)**: OPEN, CI green, base main
(pre-#120 — needs a main merge/rebase, confirmed conflict-free with #120). Fable review =
FIX-FIRST: (1) Register/Remove race in DirectStreamSessionTracker can orphan a live
session's registration; (2) HEAD probes register phantom viewers on ts-legacy. Round-2
Codex prompt (findings verbatim) was handed to the user; full text also in the PR #121
review comment. #99 stays open (final /api/channels/state wiring comes after #121 merges).
- Worktrees: .worktrees/issue-99-session-tracking (Codex, keep); .worktrees/merge-pass
(stack landed — REMOVE after this doc lands); issue-97/feat-102 removed. The main checkout
still sits on docs/59-ui-redesign-brief — fully merged now, safe to switch to main.
**Lessons for all remaining prompts** (accumulated from #116/#104/#111/#102 reviews):
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — with the project
default Nullable=disable the regenerated OpenAPI documents every string as `["null","string"]`
(breaks SPA typegen with needless `| null`); with it, non-`?` properties emit plain `string`.
Precedent: ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs. NOTE (#102): ErsatzTV.Application
has NO nullable context — `string?` there trips CS8632; returning null from a plain `string`
method is fine and the DTO's `string?` param keeps the spec nullable.
- NSubstitute + ConfigElementKey (#102): `ConfigElementKey.X` returns a FRESH instance per
access (reference equality) — stubbing `GetValue<T>(ConfigElementKey.X, …)` never matches.
Use `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>` (precedent:
FFmpegProfileHandlerTests).
- `Option<T>.ToNullable()` does NOT exist in this LanguageExt version; Option→nullable is
`MatchUnsafe(v => (T?)v, () => null)` (idiom: ErsatzTV.Application/Health/Mapper.cs). Plain
`Match` throws ResultIsNullException on a null-returning branch.
- ./scripts/update-openapi.sh runs `dotnet build -t:GenerateOpenApiDocuments` ONLY — after
editing code, run a normal `dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or the script fails
with MSB3680.
- Id-taking child-collection GETs 404 on unknown parent via a pre-check + ApiResults
(precedent: ScheduleController.GetItems, PlayoutController.GetItems) + an
OpenApiErrorResponseContractTests [TestCase] entry.
- Backlog nits (not filed as issues yet): pageSize is unclamped on all paged endpoints;
PlayoutController Create/Delete lack route Name=; PlayoutController.GetItems' existence
pre-check reuses GetPlayoutById (3 Includes) instead of a lightweight exists query;
uploads >30 MB hit Kestrel's default cap and return a bare 413 (no ProblemDetails);
artwork content-type is trusted from the client header (magic-byte sniffing = #66);
schedule duration estimator: expression-based `Count` and `Count="0"` both yield null
estimates, and it fully materializes each referenced collection per GET.
NEW from #102 (also commented on #85): GET /api/guide runs the full 21-include
eager-load per channel per request (loads artwork/genres/guids the JSON never uses) —
trim the projection or add caching before the EPG grid polls it; and `fillerKind` in the
JSON guide is almost always `None` because the shared projector merges filler into
adjacent programmes (the no-drift requirement) — discrete filler entries need a JSON-only
projection mode (follow-up if #85 wants them).
**Lessons for all remaining prompts** (accumulated):
- The image-build job runs ONLY on main pushes — PR CI cannot catch Dockerfile breakage.
Any Dockerfile change: verify base tags exist (hub.docker.com API) and, where docker is
available, `docker build --target <stage>` locally before merging. (`node:22-noble` cost
a red main run; node official images have no `-noble` variant.)
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — else the spec
emits `["null","string"]` unions and SPA types get needless `| null`. #96/#98 DTOs were
retrofitted in 8d89ab16; ChannelStateResponseModel complied from the start. NOTE:
ErsatzTV.Application has NO nullable context — `string?` there trips CS8632.
- NSubstitute + ConfigElementKey: `ConfigElementKey.X` is a fresh instance per access —
stub with `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
- `Option<T>.ToNullable()` doesn't exist here; use `MatchUnsafe(v => (T?)v, () => null)`.
- ./scripts/update-openapi.sh only runs `-t:GenerateOpenApiDocuments` — do a normal
`dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or it fails with MSB3680.
- Id-taking child-collection GETs 404 on unknown parent via pre-check + ApiResults
(precedent: ScheduleController.GetItems) + an OpenApiErrorResponseContractTests entry.
- Backlog nits (unfiled): unclamped pageSize on paged endpoints; PlayoutController
Create/Delete lack route Name=; PlayoutController.GetItems existence pre-check is heavy;
>30 MB uploads return bare Kestrel 413; artwork content-type trusted from client (#66);
schedule duration estimator: expression/zero Count → null estimate, materializes each
referenced collection per GET. From #102 (also on #85): GET /api/guide runs the full
21-include eager-load per channel per request — trim projection or add caching before the
EPG grid polls it; `fillerKind` is almost always `None` (projector merges filler into
adjacent programmes) — discrete filler entries need a JSON-only projection mode.
---
# PROMPT — MERGE PASS: land the docs/59-ui-redesign-brief SPA stack on main
# PROMPT FOR CODEX — #109: Dashboard follow-up — replace stubbed data with real sources
You are Codex working solo in /Users/timothy/ersatztv (ErsatzTV fork; the React SPA
"ChicoryTV" lives in web/ — Vite + TS, typed client in web/src/api/, generated types
web/src/api/generated/v1.d.ts). Read CLAUDE.md and docs/contributing.md first; follow the
SPA foundation's existing patterns (#78#83) — match, don't invent.
You are the ORCHESTRATOR in the main conversation (Fable). Fable is EXPENSIVE — delegate bulk
work to cheaper models; use ONE fable subagent only for the final read-only review. This
session is mostly git surgery + verification, so much of it is fine inline.
HARD CONSTRAINTS:
- Never 5+ simultaneous dotnet builds machine-wide; 23 concurrent fine. No Workflow tool.
- Do NOT regenerate XMLTV/M3U goldens (ETV_UPDATE_GOLDENS) — a golden diff means broken code.
- The user may be working in the main checkout (/Users/timothy/ersatztv, currently on
docs/59-ui-redesign-brief). Do git surgery in a WORKTREE, and coordinate before checking
out branches in the main tree.
- Merging PRs needs a fresh one-word "merge" consent from the user per PR.
- End by updating this handoff file (see "On completion").
- Work in a NEW git worktree: `git worktree add .worktrees/issue-109-dashboard -b
feat/109-dashboard-data origin/main` (branch from origin/main; never touch the main
checkout or other .worktrees/*).
- Max 23 concurrent builds machine-wide; keep to ONE dotnet build at a time here.
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
- Do NOT merge anything. Open the PR, get CI green, and stop.
- Backend scope guard: this is a FRONTEND issue. If a widget needs data no endpoint
provides, stub that widget's source cleanly and note it in the PR — do not add endpoints.
## Project context (read CLAUDE.md first)
- Repo: /Users/timothy/ersatztv — ErsatzTV fork (.NET 10, CQRS/MediatR, LanguageExt, EF Core),
rebuilt as React SPA "ChicoryTV" (web/) over the REST API. Gitea:
http://192.168.1.95:3000/timothy/ersatztv (API auth: basic timothy:ded89Lm4).
- main = 6f6f37b7 (post PR #119/#102): complete backend API. ErsatzTV.Tests 323 green;
ErsatzTV.Core.Tests 488 green (+1 skipped). Branch protection: PRs need "Build & test
(.NET)" green.
- docs/59-ui-redesign-brief (a867eeca before this session's doc commits) carries: the #59
brief/design docs, design-system/, the SPA foundation web/ (#78#83), and backend bits
#96/#98 that were later ALSO landed on main via the feat/* PRs — expect overlap/conflicts.
- PR #112 (issue #97, channel state API) targets docs/59-ui-redesign-brief and is OPEN;
its worktree is .worktrees/issue-97-channel-state-api. #97's endpoints may ALSO overlap
with what later landed on main — diff before merging.
## Context
- main = post-#120/#122: full backend read API + SPA foundation. Baselines: ErsatzTV.Tests
364, Core.Tests 488(+1 skip); web `npm run typecheck` + `npm run build` clean.
Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- Issue #109: the Dashboard screen (#83) shipped with stubbed data. Replace the stubs with
the real sources now on main: /api/health (system health), /api/channels +
/api/channels/state (channel counts / on-air), /api/playouts (playout status),
/api/version. Read the issue body for the agreed widget list, and locate the stub layer
from #83 in web/src/ before writing anything.
- CRITICAL (#108 lesson): GET /api/health re-runs all ~14 checks per request (only the
warn/error summary is cached) — fetch health ON DEMAND (mount + manual refresh), NOT on a
fast poll. If live health is wanted, that's a backend TTL-cache follow-up issue, not this
PR. Other widgets may poll gently (≥30s) if the design calls for it.
- Note: /api/channels/state `onAir` is segmenter-session-based until #99/PR #121 lands —
render it as-is; no workarounds.
## Plan
1. RECON [Explore/haiku]: map the divergence — git log/diff main...docs/59-ui-redesign-brief
(which commits are docs/web-only, which touch backend files that main since changed);
same for PR #112's diff vs main (did #100/#116 already land equivalent channel-state
endpoints?). Product: a conflict forecast + recommendation (rebase vs merge main into the
stack; whether #112 still adds value or needs slimming to the delta).
2. MERGE #112 [inline, after consent]: if it still adds value, merge PR #112 into
docs/59-ui-redesign-brief (ask "merge"); else close it with an explanatory comment and
cherry-pick any residual delta.
3. REBASE/RECONCILE [worktree]: git worktree add .worktrees/merge-pass docs/59-ui-redesign-brief
(after user OK, since the main checkout sits on that branch — safer: do the work on a NEW
branch, e.g. feat/59-spa-foundation, from the same head). Rebase or merge onto main
(recon decides; a single merge commit is acceptable for a long-lived stack). v1.json:
take main's, then re-run ./scripts/update-openapi.sh at the end (regen is authoritative).
web/ SPA types: regen from the final v1.json (web/ has the typegen script — check
web/package.json; main's v1.json now includes everything through /api/guide).
4. VERIFY [inline]: dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests +
ErsatzTV.Core.Tests; web/: npm ci + typecheck/build if the stack has them.
5. REVIEW [fable subagent, read-only]: the RECONCILIATION diff only (what changed vs both
parents) — dropped commits, double-applied backend code, stale SPA types, v1.json drift.
6. PR the stack → main [inline]: title "feat(web): ChicoryTV SPA foundation (#59 stack)",
body listing the SPA issues it closes (#78#83, #96, #98, #97 if #112 merged), poll CI by
head_sha, ask "merge" consent, then verify main's post-merge run. Comment on/close the
covered issues per the Task Completion Protocol.
7. Cleanup: remove merged worktrees (.worktrees/feat-102, .worktrees/issue-97-* once #112
is resolved).
## Process
1. Comment on issue #109 with findings + approach before coding.
2. Implement: swap stubs for typed client calls (follow #81's client/query patterns);
loading/error/empty states per the design-system components. No `any` casts around
generated types.
3. Verify: `cd web && npm ci && npm run typecheck && npm run build`; plus
`dotnet build ErsatzTV.sln` and TZ=UTC dotnet test of ErsatzTV.Tests + Core.Tests
(sequentially) to prove no backend regression (expect 364 / 488+1skip). If feasible, run
the SPA against a live backend (dotnet run + vite dev proxy) and sanity-check the
Dashboard renders real data.
4. Push, open PR → main: `feat(web): Dashboard real data sources (#109)`, body lists each
widget → endpoint mapping and the health fetch policy; `closes #109`. Poll CI by head SHA
(/api/v1/repos/timothy/ersatztv/commits/<sha>/status) until green. Do not merge.
5. Comment progress on #109 as you go.
## On completion — REQUIRED last step
Update docs/handoffs/chicorytv-issue-queue.md in place: pop the merge pass, write the next
prompt (#109 Dashboard follow-up — see queue item 2 and its /api/health polling note), record
PR numbers + the new main SHA + where web/ lives now. Commit+push the doc update (to main if
the stack landed, else to docs/59-ui-redesign-brief). Print the new prompt in a fenced code
block.
## On completion — REQUIRED final output
Print a fenced handoff prompt addressed to Claude (Fable) asking it to:
- Review the PR diff READ-ONLY in one Fable pass (endpoint usage correctness, health
polling discipline, type safety, design-system adherence, scope).
- Classify findings: NITS Fable may fix directly on the branch; SUBSTANTIAL issues (wrong
data contracts, polling violations, state-management flaws) go back to Codex — Fable
composes a follow-up Codex prompt with the findings verbatim.
- After review: comment the verdict on the PR and #109; on approval + user "merge" consent,
merge, verify main's post-merge run (image job included!), then update THIS handoff file
(pop #109, next prompt = #84 Channels screen for Codex, record PR number + main SHA +
baselines) and push it to main.
Include: PR number, branch, head SHA, files changed, widget→endpoint mapping, test/web
results, anything deferred or uncertain.
---
## Issue queue (work top-down)
Single-session (Codex usage exhausted). No pre-seeded WIP branches remain.
1. MERGE PASS: PR #112 (#97) + docs/59-ui-redesign-brief stack → main ← PROMPT above
(validates SPA issues #78#83/#96/#98 on main; unblocks all frontend work).
2. #109 Dashboard follow-up (frontend; needs merge pass). NOTE from #108: GET /api/health
re-runs all ~14 checks per request (only the warn/error summary is cached) — the SPA
footer/Dashboard must load on demand or poll gently; add a TTL cache first if it needs to poll.
3. Back to UX conversion: #84 Channels (deps #96/#98/#97 available post-merge-pass) → #86
Schedule editor (duration estimates from #111) → #87 Playouts → #88 Libraries#85 EPG
(JSON guide from #102 available — mind the fillerKind + per-request-cost notes above) →
#89 Channel Builder (artwork upload from #104; also needs #62: #63/#64/#65) → #93 Settings
#90 rebrand → #91 cutover.
Cross-refs: #99 (TS/HLS-Direct sessions) stays backlog; "Definition of Ready" for screen issues
lives in the #59 epic body. Done: #105 (PR #113), #108 (PR #114), #100+#101+#107+#110 (PR #116),
#103+#106 (PR #115), #104 (PR #117, artwork upload — unblocks #89), #111 (PR #118, schedule item
duration estimates — unblocks #86), #102 (PR #119, GET /api/guide + shared ChannelGuideProjector —
unblocks #85). Languages-list endpoint from #105 still unimplemented — open a follow-up when
#86/#89 need it; filler/watermark lists return DB order — SPA should client-sort.
0. HOUSEKEEPING (carry into next session): merge PR #122 if still open (one-word consent),
verify main run fully green incl. image job; remove .worktrees/merge-pass; Codex round-2
on PR #121 (#99) — prompt already with the user — then Fable re-review of the delta.
1. #109 Dashboard follow-up ← CODEX PROMPT above (review/merge/doc-update falls to the
Fable session that Codex's end-of-run handoff spawns).
2. #84 Channels screen (deps #96/#98/#97 on main; live treatment via /api/channels/state —
note its onAir is segmenter-only until #99/#121 lands and gets wired).
3. UX conversion order: #86 Schedule editor (#111 durations) → #87 Playouts → #88 Libraries
#85 EPG (#102 JSON guide; mind fillerKind + per-request-cost notes above) → #89 Channel
Builder (#104 artwork upload; also needs #62: #63/#64/#65) → #93 Settings#90 rebrand
#91 cutover.
Cross-refs: #99/PR #121 in flight (see state). Done this pass: MERGE PASS → PR #120 (closed
#97; #78#83/#96/#98 validated on main), hotfix PR #122 (Dockerfile node tag). Languages-list
endpoint from #105 still unimplemented — open a follow-up when #86/#89 need it;
filler/watermark lists return DB order — SPA should client-sort.