Compare commits

..
Author SHA1 Message Date
timothy a8d4979b0c fix(web): address Guide review feedback refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 23:06:08 +02:00
timothy 7d93688841 feat(web): add Guide EPG screen refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 22:31:30 +02:00
timothyandClaude Fable 5 d9ffbde79c docs: advance ChicoryTV issue queue past #88 (PR #128); next prompt = #85 EPG (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the #128 review outcome (percent-fraction + poll-start-race fixes,
bee686c8), main d63719ec, baselines (368/493+1skip/75 web), and new lessons
(numeric wire-field units, trigger≠started grace windows, no ref mutation in
updaters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:08:53 +02:00
timothy d63719ece9 Merge pull request 'feat(web): Libraries screen (#88)' (#128) from feat/88-libraries into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-05 17:06:50 +00:00
timothyandClaude Fable 5 bee686c846 fix(web): libraries scan-flow review fixes for #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Normalize scan-status percent at the API boundary: the wire value is a
  0-1 fraction despite the field name; the bar previously showed ~0% for
  the whole scan (fixtures now use fractional wire values incl. 0/1)
- Fix the poll-start race: a triggered library enters a pending set that
  survives status pruning until seen active or a 3-tick grace window
  expires; the poll runs while pending ∪ active is nonempty (previously
  the queue-to-start lag could mean polling never started and the button
  re-enabled mid-scan); grace ticks also drain on persistent status-fetch
  errors so the UI can't stick
- Keep setState updaters pure: pending/grace bookkeeping hoisted into
  pruneGraceExpiredPending() outside the updater (StrictMode-safe)
- Wire the prototype's per-source Scan button (one POST per library,
  disabled while any of its libraries is pending/active)
- Honest bare-500 media-sources failure test (backend has no exception
  middleware); loadScanStatuses returns its promise; poll effect depends
  on derived hasActiveScans; synchronous ref double-submit guard;
  --radius-xs fallback 3px; StatusDot label on source cards (75 tests)

Review: PR #128 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:01:05 +02:00
timothy 5c4acb9c7b feat(web): add Libraries screen refs #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 18:26:06 +02:00
timothyandClaude Fable 5 223ef70912 docs: advance ChicoryTV issue queue past #87 (PR #127); next prompt = #88 Libraries (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m1s
Records the #127 review outcome (filler blocker + undefined token), pre-merge
fixes (c4190e3a), main a8dd88ef, baselines (368/493+1skip/63 web), and new
lessons (fixture-vs-actual-query fidelity, pure setState updaters, responsive
collapse, visible deferrals, honest tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:53:07 +02:00
timothy a8dd88ef73 Merge pull request 'feat(web): Playouts screen (#87)' (#127) from feat/87-playouts into main
Build ErsatzTV Image / Build & push image (amd64) (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
2026-07-05 15:50:57 +00:00
timothyandClaude Fable 5 c4190e3a54 fix(web): playouts monitor review fixes for #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Filler unit: treat fillerKind 'None'/null as non-filler (real items no
  longer render the Filler badge); add the prototype's Show filler toggle
  wired to ?showFiller=true (the client previously never requested filler,
  making the filler UI dead code); fixtures now use real wire values
- Hoist selection fetch out of the setState updater (StrictMode-safe)
  via a baseRef snapshot; merge polled channel states functionally
  instead of clobbering with the captured snapshot
- Fix undefined --ctv-surface-1 token on the hero (→ --surface-card per
  prototype); correct --text-lg fallback; collapse the new playout grids
  in the 980px media block (no page-level horizontal scroll)
- Visible deferrals: disabled Reset / Schedule reset buttons with note
  (no per-playout reset endpoint yet)
- itemsLoading spinners on selection switch; time-window now-matching
  with title fallback; rail disabled while reset-all mutates
- Tests: mount-count delta isolation from the Dashboard prefetch;
  reset-all reframed as transport-failure (mock honors fixture status;
  backend is unconditionally 202); rail filter test; paging honesty
  ('shown of total' label); listPlayout() fixture matches the list DTO
  (63 tests)

Review: PR #127 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:45:01 +02:00
timothy 1840ef83b1 feat(web): add Playouts monitor screen refs #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 17:10:30 +02:00
timothyandClaude Fable 5 c5021e28da docs: advance ChicoryTV issue queue past #86 (PR #125); next prompt = #87 Playouts (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m23s
Records the #125 review outcome (refuted reorder blocker → #126), pre-merge
fixes (f18f2dc0), main 229f7cc1, baselines (368/493+1skip/52 web), and new
lessons (mount call-count assertions, no world-refetch, no list[0]-only UIs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:27:06 +02:00
timothy 229f7cc1b0 Merge pull request 'feat(web): Schedule editor screen (#86)' (#125) from feat/86-schedule-editor into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 10:22:16 +00:00
timothyandClaude Fable 5 f18f2dc058 fix(web): schedule editor review fixes for #86
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Add a schedule selector (previously only the first schedule was ever
  shown); switching loads items only, with a pane-level spinner
- Fix duplicated mount effect that fetched the whole screen twice
- Add a collection picker to the add-item flow (was silently using the
  alphabetically-first collection with no way to correct it)
- Reorder now reconciles from the PUT response; add/delete refetch items
  only — pickers are no longer refetched on every mutation
- Guard reorder against double-submit via a ref; remove the inert Save
  button; document the widened item type (OpenAPI gap → #126)
- Inspector fidelity: Tail filler row + Keep multi-part together
- a11y: aria-current for the active item, draggable role description
- CSS: correct --text-md fallbacks; replace nonexistent --text-faint
  with --text-disabled in new blocks
- Tests: single-fetch-on-mount assertion, chosen-collection POST body,
  picker-failure path, collection() fixture with all required fields;
  drop unreachable mock branch (52 tests)

Review: PR #125 findings; refs #126

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:16:53 +02:00
timothy 2e58fa1815 feat(web): add schedule editor screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 11:43:04 +02:00
timothyandClaude Fable 5 8f792d964e docs: advance ChicoryTV issue queue past #84 (PR #124); next prompt = #86 Schedule editor (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the new Fable-prompts/Fable-reviews process, PR #124 review outcome
and pre-merge fixes (ecf6cf75), main 7ee6e987, baselines (368/493+1skip/39 web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:29:53 +02:00
timothy 7ee6e9870c Merge pull request 'feat(web): Channels screen (#84)' (#124) from feat/84-channels-screen into main
Build ErsatzTV Image / Build & push image (amd64) (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
2026-07-05 09:26:22 +00:00
timothyandClaude Fable 5 ecf6cf7513 fix(web): scope channel bulk ops to visible rows, review fixes for #84
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Derive selectedChannels from visibleChannels and clear selection on
  filter change so bulk operations can never act on rows hidden by the
  active filter (reachable via the 30s state poll under the on-air filter)
- Disable the per-row delete button while a mutation is in flight
- Correct the --pad-cell-x fallback (token resolves to 16px)
- Add tests: bulk renumber request body + selection clearing, and
  selection reset across filter changes

Review: PR #124 findings 1, 2, 4, 8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:20:32 +02:00
timothy 15c6cdd086 feat(web): add Channels screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #84
2026-07-05 10:41:29 +02:00
timothyandClaude Fable 5 39d76080de docs: advance ChicoryTV issue queue past #109 (PR #123); next prompt = #84 Channels screen (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m21s
Records: main=4ffd777b, review-fix cycle lesson (fixture values must come from
backend serializers), health no-poll policy, baselines 368/493 + 31 web tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:23:24 +02:00
timothy 4ffd777b49 Merge pull request 'feat(web): Dashboard real data sources (#109)' (#123) from feat/109-dashboard-data into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m31s
2026-07-04 19:20:38 +00:00
timothyandClaude Fable 5 e6197d63ff fix(web): dashboard review fixes — real health status contract, dedupe version fetch, refresh guard (#109)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Match the backend's exact health status contract ('pass'|'fail'|'warn'|'info')
  instead of fictional strings; 'info' now renders as a neutral/idle presentation
  and is excluded from failing/warning counts in summarizeHealth.
- Drop the redundant /api/version fetch from getDashboardData/DashboardData;
  SidebarVersion's useDashboardVersionQuery remains the single source.
- Disable "Refresh health" (via Button's loading prop) while a health request
  is in flight to prevent concurrent double-click requests.
- Add the active-flag unmount guard to useDashboardHealthQuery for consistency
  with useDashboardQuery/useChannelsQuery.
- Remove dead .ctv-activity-*/.ctv-release-* CSS left over from the removed
  activity feed/release notes UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:14:44 +02:00
timothy 6a9f1e41a4 fix(web): satisfy dashboard lint checks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:55:38 +02:00
timothy a6056f73d8 feat(web): wire dashboard to real API data
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:52:32 +02:00
timothy da7636fdad docs: update ChicoryTV queue after PR 121
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m46s
2026-07-04 20:37:29 +02:00
timothy cc6ffcb8c4 Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m35s
Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
2026-07-04 18:24:15 +00:00
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
20 changed files with 7975 additions and 384 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>();
+146 -118
View File
@@ -4,135 +4,163 @@ 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 7–9-way parallel workflow build once exhausted RAM, so
builds are limited to 2–3 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 2–3 concurrent, never wide fan-outs. Backend gaps all landed by
2026-07-04 (PRs #113–#119); merge pass PR #120; live-data screens so far: #109 Dashboard
(PR #123), #84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127),
#88 Libraries (PR #128).
**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.
**PROCESS (2026-07-05, binding)**: Fable writes each Codex prompt AND performs the PR review
(via review subagents — parallel lenses: correctness fork + cheaper contract/tests +
design-system agents; plus a fork verification pass over any fix diff). Codex implements.
Every Codex prompt MUST mandate: (a) subagents where appropriate at fitting effort/model
levels, (b) `npm ci` in each fresh worktree. Review-driven fixes are applied by fitting
subagents too (NITS: Fable fixes on the branch; SUBSTANTIAL: back to Codex verbatim or fixed
in-session — user decides). Merges need explicit user consent (standing consent for this
stretch: merge when reviewers are happy and tests pass).
**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).
**Session state (2026-07-05, post-#88)**: main = d63719ec (PR #128 merge): Libraries screen
live — source cards (media-sources), per-library scan progress + triggers, per-source scan,
conditional 10s scan-status polling (pending∪active drives the interval; 3-tick grace window
covers the queue-to-start lag; drains on persistent errors; refetches sources once on
completion). Review found 2 production-visible substantials fixed pre-merge (bee686c8):
percent was a 0–1 wire fraction (bar showed ~0%), and the poll-start race. Fork verification
caught a StrictMode-unsafe ref mutation inside a setState updater — fixed via a pure hoist.
Baselines: ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web tests **75**,
lint/typecheck/build clean. #84/#86/#87/#88 CLOSED. Main checkout sits on
docs/59-ui-redesign-brief (fully merged, safe to switch).
**Lessons for all remaining prompts** (accumulated):
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
returns UNDER THE QUERY THE CLIENT SENDS. #128: `percent` is a 0–1 fraction despite its
name (bar showed ~0%; 0–100 fixtures masked it). Enum-with-None fields are never
truthiness-checked. Verify UNITS/scale of numeric wire fields against the producing code,
not the field name.
- Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; status endpoints may
lag by seconds. Pending-state with a grace window bridges the gap; poll while
pending∪active nonempty; drain grace on persistent errors so UIs can't stick.
- setState updaters must be PURE — no fetches (#127) and no ref mutations (#128); hoist
side-effects/bookkeeping out. StrictMode double-invokes updaters and the test renderer
doesn't, so tests won't catch it — reviewers must.
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback
= the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't).
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped
(#127 toggle = half a blocker; #128 per-source Scan).
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based
double-submit guards; mutations never refetch the world (#125/#127).
- A screen listing N entities lets the user pick among them (#125).
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures
are BARE 4xx/5xx, not ProblemDetails, unless the controller explicitly returns them);
mount call-counts assert the DELTA across navigation (Dashboard prefetches
/api/playouts AND /api/media-sources); paging labels don't lie; test the search/filter UI
you ship; status-dot state never color-only (StatusDot has a label prop).
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need `#nullable
enable`; Application has NO nullable context. NSubstitute+ConfigElementKey:
`Arg.Any<ConfigElementKey>()` + `<T>`. `Option<T>.ToNullable()` → `MatchUnsafe`.
update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check.
- Backlog nits (unfiled): unclamped pageSize; PlayoutController Create/Delete lack Name=;
heavy GetItems pre-check; >30 MB uploads → bare 413; artwork content-type trusted (#66);
schedule estimator materializes collections per GET; /api/health TTL cache; NEW (#128):
`LibraryScanStatusResponseModel.percent` is a 0–1 fraction under a percent name — rename
or ×100 server-side eventually; no Dialog/Modal component yet (likely needed by #89);
1 pre-existing --text-faint usage in shell.css. Filed: #126 (OpenAPI polymorphism gap).
---
# PROMPT — MERGE PASS: land the docs/59-ui-redesign-brief SPA stack on main
# PROMPT FOR CODEX — #85: Guide / EPG screen (channels × time grid)
You are Codex, the IMPLEMENTER, in /Users/timothy/ersatztv (ErsatzTV fork; React SPA
"ChicoryTV" in web/ — Vite + TS, typed client web/src/api/, generated types
web/src/api/generated/v1.d.ts). Fable (Claude) reviews your PR read-only afterwards — do NOT
merge. Read CLAUDE.md and docs/contributing.md first; follow the SPA patterns from Channels
(PR #124), Schedule editor (PR #125), Playouts (PR #127), Libraries (PR #128) — 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; 2–3 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").
- USE SUBAGENTS where appropriate, at fitting effort and model levels: cheap/fast agents for
mechanical work (fixtures, CSS transcription, test boilerplate); higher-effort agents only
for judgment work (grid virtualization/layout math, time-window state). Keep orchestration
and final assembly in your main session.
- Worktree: `git worktree add .worktrees/issue-85-epg -b feat/85-epg origin/main`.
Never touch the main checkout or other .worktrees/*.
- Run `npm ci` in web/ in the worktree BEFORE any lint/typecheck/test/build.
- Max 2–3 concurrent builds machine-wide; ONE dotnet build at a time here.
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
- FRONTEND-only: no new endpoints, no ErsatzTV.Core/Application changes. Missing data =
VISIBLE deferral (disabled + note) — never silently drop a prototype affordance.
- CONTRACT RULE: fixture values must be what the actually-called endpoint returns UNDER THE
QUERY THE CLIENT SENDS; verify units/scale of numeric fields against the producing backend
code, never the field name (#128's percent lesson). Enum-with-None fields are never
truthiness-checked (#127's fillerKind lesson — this endpoint HAS fillerKind!).
## 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.
## Task
Issue #85: recreate design-system/templates/chicorytv-admin/Epg.jsx wired to the JSON guide.
Dark channels×time grid, "now" marker, live focal color, jump-to-now. Acceptance: matches
prototype; live guide data; all 3 themes.
## 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).
## Context (main = d63719ec; baselines: ErsatzTV.Tests 368, Core.Tests 493+1skip, web 75)
- Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- API surface (verify in v1.d.ts; regen `npm run generate:api` if stale):
- GET /api/guide?start=<ISO>&end=<ISO> (both optional DateTimeOffset; start defaults to
now, end to start + configured XmltvDaysToBuild) → ChannelGuideResponseModel { start,
end, channels: [{ number, name, programmes: [{ start, stop, title, subTitle (nullable),
category (nullable), fillerKind }] }] }.
- CRITICAL COST NOTE (#102): the guide endpoint runs a heavy 21-include eager-load PER
CHANNEL PER REQUEST. Request a BOUNDED window (e.g. now-1h → now+12h), fetch ONCE on
mount + on explicit user navigation (prev/next window, jump-to-now beyond the loaded
range) — NEVER poll /api/guide. The "now" marker advances client-side on a timer
(minute-ish tick moving the marker/highlight; NO refetch).
- fillerKind in the JSON guide is almost always 'None' — the shared projector merges
filler into adjacent programmes. Do NOT truthiness-check it; discrete filler rows would
need a backend projection mode (out of scope — no UI toggle for it).
- GET /api/channels/state (onAir) may drive the live focal treatment; if used, poll ≥30s
(Channels precedent) — it's the only thing allowed on an interval besides the client-side
now-marker tick.
- Channel logos: check what the prototype uses; the SPA has ChannelLogo (initials-based).
/iptv logo URLs exist but check what Channels/Dashboard screens do — match precedent.
- Grid math: programmes can start before / end after the requested window — clip to the
window; overlapping/adjacent programmes must not produce negative/NaN widths; empty
channels (no programmes) render an empty row, not a crash; timezone = render in the
BROWSER's local time (the wire carries offsets; Date parsing is TZ-safe).
- No mutations on this screen. Read-only + navigation state only.
- CSS: design-system tokens only; verify each token EXISTS; fallback = resolved value; new
multi-column grids → the 980px collapse block; the grid scrolls in its OWN container
(both axes as needed) — never page-level horizontal scroll.
- Tests: mount fetch delta (guide fetched exactly once; no guide polling — assert interval
ticks don't refetch /api/guide); window navigation triggers exactly one fetch per step;
jump-to-now; now-marker position math (fixed fake now vs fixture times); programme
clipping at window edges; empty channels; error+retry; fixture fillerKind 'None' (real
wire value) with subTitle/category nulls exercised.
## 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.
## Process
1. Comment on issue #85 with findings + approach (prototype surface → endpoint/DTO mapping
table, scope decisions, chosen window size) BEFORE coding.
2. Implement; comment progress on #85 as you go.
3. Verify: cd web && npm ci && npm run lint && npm run typecheck && npm test -- --run &&
npm run build; then TZ=UTC dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests +
Core.Tests sequentially (expect 368 / 493+1skip — no backend regression).
4. Push, open PR → main: "feat(web): Guide / EPG screen (#85)", body lists the mapping table
and scope decisions; `closes #85`. Poll CI by head SHA until green. Do NOT merge.
## On completion — REQUIRED final output
Print a fenced handoff prompt addressed to Claude (Fable) asking it to review the PR
READ-ONLY (Fable runs its own review subagents). Include: PR number, branch, head SHA, base,
files changed, the mapping table, verification commands + results, deferred/uncertain list,
and the finding classification (NITS = Fable fixes on the branch; SUBSTANTIAL = back to
Codex verbatim or fixed in-session with subagents — user decides). After approval + user
merge consent, Fable merges, verifies main's post-merge run (image job included), updates
THIS handoff (pop #85, next prompt = #89 Channel Builder, record PR + main SHA + baselines),
and pushes it to main.
---
## 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: #99 stays open for the final /api/channels/state onAir wiring; #126
(OpenAPI polymorphism gap) is a good backend slot-filler between screens.
1. #85 Guide/EPG screen ← CODEX PROMPT above (Fable reviews via subagents, merges on
consent, updates this file).
2. UX conversion order: #89 Channel Builder (#104 artwork upload; also needs #62:
#63/#64/#65; likely needs the first Dialog/Modal component; languages endpoint follow-up
from #105 when needed) → #93 Settings → #90 rebrand → #91 cutover.
Cross-refs: #99 seam landed (PR #121), final wiring open. Done recently: PR #125 (#86),
PR #127 (#87), PR #128 (#88 Libraries — closed 2026-07-05, main d63719ec). Filed: #126.
+2220 -27
View File
File diff suppressed because it is too large Load Diff
+2255 -150
View File
File diff suppressed because it is too large Load Diff
+169 -1
View File
@@ -1,8 +1,176 @@
import { request } from './client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export interface ChannelsScreenData {
channels: ChannelSummary[];
channelStates: ChannelState[];
}
export type ChannelsScreenQueryState =
| { data: ChannelsScreenData; error: null; refresh: () => void; status: 'success' }
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ChannelsScreenState =
| { data: ChannelsScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getChannels(): Promise<ChannelSummary[]> {
return request<ChannelSummary[]>('/api/channels');
}
export function getChannelStates(): Promise<ChannelState[]> {
return request<ChannelState[]>('/api/channels/state');
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
const [channels, channelStates] = await Promise.all([
getChannels(),
getChannelStates()
]);
return { channels, channelStates };
}
export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/renumber', {
body,
method: 'POST'
});
}
export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise<void> {
return request<void>('/api/channels/bulk/group', {
body,
method: 'POST'
});
}
export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/delete', {
body,
method: 'POST'
});
}
export function deleteChannel(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}`, {
method: 'DELETE'
});
}
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
const [state, setState] = useState<ChannelsScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback((showLoading = true) => {
if (showLoading) {
setState({ data: null, error: null, status: 'loading' });
}
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (activeRef.current) {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
}
})
.catch(() => {
// Keep the current table visible on background polling failures.
});
}, []);
useEffect(() => {
const loadForEffect = () => {
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
};
loadForEffect();
const intervalId = window.setInterval(() => {
loadChannelStates();
}, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [loadChannelStates, pollMs]);
const refresh = useCallback(() => {
load();
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
export function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Unable to load channels';
}
+119 -16
View File
@@ -1,36 +1,58 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type DashboardChannel = components['schemas']['ChannelResponseModel'];
type DashboardCollection = components['schemas']['MediaCollectionViewModel'];
type DashboardSchedule = components['schemas']['ProgramScheduleViewModel'];
type DashboardSession = components['schemas']['HlsSessionModel'];
type DashboardVersion = components['schemas']['CombinedVersion'];
export type DashboardChannelState = components['schemas']['ChannelStateResponseModel'];
type DashboardHealthCheck = components['schemas']['HealthCheckResponseModel'];
type DashboardMediaSource = components['schemas']['MediaSourceResponseModel'];
type DashboardPlayouts = components['schemas']['PagedPlayoutsResponseModel'];
export type DashboardVersion = components['schemas']['CombinedVersion'];
export interface DashboardData {
channels: DashboardChannel[];
collections: DashboardCollection[];
schedules: DashboardSchedule[];
sessions: DashboardSession[];
version: DashboardVersion;
channelStates: DashboardChannelState[];
mediaSources: DashboardMediaSource[];
playouts: DashboardPlayouts;
}
type DashboardQueryState =
export type DashboardQueryState =
| { data: DashboardData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export type DashboardHealthQueryState =
| { checks: DashboardHealthCheck[]; error: null; refresh: () => void; status: 'success' }
| { checks: null; error: string; refresh: () => void; status: 'error' }
| { checks: null; error: null; refresh: () => void; status: 'loading' };
export type DashboardVersionQueryState =
| { error: null; status: 'success'; version: DashboardVersion }
| { error: string; status: 'error'; version: null }
| { error: null; status: 'loading'; version: null };
type DashboardHealthState =
| { checks: DashboardHealthCheck[]; error: null; status: 'success' }
| { checks: null; error: string; status: 'error' }
| { checks: null; error: null; status: 'loading' };
export async function getDashboardData(): Promise<DashboardData> {
const [channels, collections, schedules, sessions, version] = await Promise.all([
const [channels, channelStates, mediaSources, playouts] = await Promise.all([
request<DashboardChannel[]>('/api/channels'),
request<DashboardCollection[]>('/api/collections'),
request<DashboardSchedule[]>('/api/schedules'),
request<DashboardSession[]>('/api/sessions'),
request<DashboardVersion>('/api/version')
request<DashboardChannelState[]>('/api/channels/state'),
request<DashboardMediaSource[]>('/api/media-sources'),
request<DashboardPlayouts>('/api/playouts')
]);
return { channels, collections, schedules, sessions, version };
return { channels, channelStates, mediaSources, playouts };
}
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
return request<DashboardHealthCheck[]>('/api/health');
}
export function getDashboardVersion(): Promise<DashboardVersion> {
return request<DashboardVersion>('/api/version');
}
export function useDashboardQuery(): DashboardQueryState {
@@ -63,6 +85,87 @@ export function useDashboardQuery(): DashboardQueryState {
return state;
}
export function useDashboardHealthQuery(): DashboardHealthQueryState {
const [state, setState] = useState<DashboardHealthState>({
checks: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadHealth = useCallback(() => {
getDashboardHealth()
.then((checks) => {
if (activeRef.current) {
setState({ checks, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ checks: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const refresh = useCallback(() => {
setState({ checks: null, error: null, status: 'loading' });
loadHealth();
}, [loadHealth]);
useEffect(() => {
loadHealth();
}, [loadHealth]);
if (state.status === 'success') {
return { checks: state.checks, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { checks: null, error: state.error, refresh, status: 'error' };
}
return { checks: null, error: null, refresh, status: 'loading' };
}
export function useDashboardVersionQuery(): DashboardVersionQueryState {
const [state, setState] = useState<DashboardVersionQueryState>({
error: null,
status: 'loading',
version: null
});
useEffect(() => {
let active = true;
getDashboardVersion()
.then((version) => {
if (active) {
setState({ error: null, status: 'success', version });
}
})
.catch((error: unknown) => {
if (active) {
setState({ error: messageFromError(error), status: 'error', version: null });
}
});
return () => {
active = false;
};
}, []);
return state;
}
function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+159
View File
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getChannelStates, messageFromError, type ChannelState } from './channels';
import { request } from './client';
import type { components } from './generated/v1';
export type ChannelGuide = components['schemas']['ChannelGuideResponseModel'];
export type ChannelGuideChannel = components['schemas']['ChannelGuideChannelResponseModel'];
export type ChannelGuideProgramme = components['schemas']['ChannelGuideProgrammeResponseModel'];
export interface GuideScreenData {
channelStates: ChannelState[];
guide: ChannelGuide;
}
export type GuideScreenQueryState =
| {
data: GuideScreenData;
error: null;
refresh: () => void;
setWindowStart: (start: Date) => void;
status: 'success';
windowStart: Date;
windowEnd: Date;
}
| { data: null; error: string; refresh: () => void; status: 'error'; windowStart: Date; windowEnd: Date }
| { data: null; error: null; refresh: () => void; status: 'loading'; windowStart: Date; windowEnd: Date };
type GuideScreenState =
| { data: GuideScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export const GUIDE_PAST_MS = 60 * 60 * 1000;
export const GUIDE_WINDOW_MS = 13 * 60 * 60 * 1000;
export function defaultGuideWindowStart(now = new Date()): Date {
return new Date(now.getTime() - GUIDE_PAST_MS);
}
export function getGuide(start: Date, end: Date): Promise<ChannelGuide> {
const query = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString()
});
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
}
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
const guide = await getGuide(start, end);
const channelStates = await getChannelStates().catch(() => []);
return { channelStates, guide };
}
export function useGuideScreenQuery(channelStatePollMs = 30000): GuideScreenQueryState {
const [windowStart, setWindowStartState] = useState(() => defaultGuideWindowStart());
const windowStartRef = useRef(windowStart);
const [state, setState] = useState<GuideScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const requestIdRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const windowEnd = new Date(windowStart.getTime() + GUIDE_WINDOW_MS);
const load = useCallback((start = windowStartRef.current) => {
const end = new Date(start.getTime() + GUIDE_WINDOW_MS);
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
getGuideScreenData(start, end)
.then((data) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
})
.catch(() => {
// Keep the guide visible when the optional live-state refresh fails.
});
}, []);
useEffect(() => {
load(windowStart);
}, [load, windowStart]);
useEffect(() => {
const intervalId = window.setInterval(loadChannelStates, Math.max(channelStatePollMs, 30000));
return () => {
window.clearInterval(intervalId);
};
}, [channelStatePollMs, loadChannelStates]);
const setWindowStart = useCallback((start: Date) => {
windowStartRef.current = start;
setState({ data: null, error: null, status: 'loading' });
setWindowStartState(start);
}, []);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load(windowStartRef.current);
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, setWindowStart, status: 'success', windowEnd, windowStart };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error', windowEnd, windowStart };
}
return { data: null, error: null, refresh, status: 'loading', windowEnd, windowStart };
}
+4
View File
@@ -2,4 +2,8 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
export * from './guide';
export * from './libraries';
export * from './playouts';
export * from './schedules';
export * from './useChannelsQuery';
+337
View File
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type MediaSource = components['schemas']['MediaSourceResponseModel'];
export type MediaSourceLibrary = components['schemas']['MediaSourceLibraryResponseModel'];
export type LibraryScanStatus = components['schemas']['LibraryScanStatusResponseModel'];
export interface LibrariesScreenData {
scanStatuses: LibraryScanStatus[];
sources: MediaSource[];
}
export type LibrariesScreenQueryState =
| {
data: LibrariesScreenData;
error: string | null;
refresh: () => void;
scanLibrary: (libraryId: number) => Promise<void>;
scanningLibraryIds: Set<number>;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type LibrariesScreenState =
| { data: LibrariesScreenData; error: string | null; pendingLibraryIds: Set<number>; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// A trigger'd scan disappears from the button-disabled set once either: it has been
// observed at least once in scan-status (promoted to "active"), or this many poll
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
const PENDING_GRACE_TICKS = 3;
export function getMediaSources(): Promise<MediaSource[]> {
return request<MediaSource[]>('/api/media-sources');
}
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
// Wire contract: `percent` is a 0-1 FRACTION despite the field name - the backend
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
// Normalize to a 0-100 percentage once, here at the API boundary, so every
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
);
}
export function scanLibrary(libraryId: number): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
}
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
// callers must still write pendingIdsRef.current with the returned set themselves, and
// must call this exactly once per tick before that write to keep it a single, ref-free
// computation that's safe to run under StrictMode double-invocation.
function pruneGraceExpiredPending(
pendingIds: Set<number>,
graceTicks: Map<number, number>,
isSeenActive: (libraryId: number) => boolean
): Set<number> {
const nextPending = new Set<number>();
pendingIds.forEach((libraryId) => {
if (isSeenActive(libraryId)) {
// Seen active at least once - normal active/inactive pruning takes over.
graceTicks.delete(libraryId);
return;
}
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
if (ticksRemaining <= 0) {
// Grace window expired without ever appearing in scan-status - give up on it.
graceTicks.delete(libraryId);
return;
}
graceTicks.set(libraryId, ticksRemaining);
nextPending.add(libraryId);
});
return nextPending;
}
export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQueryState {
const [state, setState] = useState<LibrariesScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const hadScanInProgressRef = useRef(false);
// Mirrors of the corresponding state, kept in sync synchronously so triggerScan can
// guard against double-submits without waiting for a render.
const pendingIdsRef = useRef<Set<number>>(new Set());
const activeIdsRef = useRef<Set<number>>(new Set());
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSources = useCallback(() => {
getMediaSources()
.then((sources) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses: current.data.scanStatuses, sources },
error: null,
pendingLibraryIds: current.pendingLibraryIds,
status: 'success'
};
});
})
.catch(() => {
// A completion refresh should not hide the screen if the source reload fails.
});
}, []);
const loadScanStatuses = useCallback(() => {
return getLibraryScanStatus()
.then((scanStatuses) => {
if (!activeRef.current) {
return;
}
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
activeIdsRef.current = activeScanIds;
const hadScanInProgress = hadScanInProgressRef.current;
hadScanInProgressRef.current = scanStatuses.length > 0;
// Compute the pruned/promoted pending set - and write the grace-tick and pending
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
// invokes them (which would double-decrement grace ticks) and concurrent rendering
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
activeScanIds.has(libraryId)
);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses, sources: current.data.sources },
error: null,
pendingLibraryIds: nextPending,
status: 'success'
};
});
if (hadScanInProgress && scanStatuses.length === 0) {
loadSources();
}
})
.catch(() => {
if (!activeRef.current) {
return;
}
// A status-fetch failure never promotes a pending id to active, so it never
// survives via "seen active" - it just burns down the same grace budget as a
// successful poll that never saw it. This keeps a persistently-erroring endpoint
// from leaving the scan button disabled and the poll interval running forever.
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
// failure must not kill an in-progress scan's UI.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: current.error,
pendingLibraryIds: nextPending,
status: 'success'
};
});
});
}, [loadSources]);
const load = useCallback(() => {
Promise.all([getMediaSources(), getLibraryScanStatus()])
.then(([sources, scanStatuses]) => {
if (!activeRef.current) {
return;
}
hadScanInProgressRef.current = scanStatuses.length > 0;
pendingIdsRef.current = new Set();
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
pendingGraceTicksRef.current = new Map();
setState({
data: { scanStatuses, sources },
error: null,
pendingLibraryIds: new Set(),
status: 'success'
});
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromLibrariesError(error, 'Unable to load libraries'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const hasActiveScans =
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
useEffect(() => {
if (!hasActiveScans) {
return undefined;
}
const intervalId = window.setInterval(loadScanStatuses, Math.max(pollMs, 10000));
return () => {
window.clearInterval(intervalId);
};
}, [hasActiveScans, loadScanStatuses, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const triggerScan = useCallback((libraryId: number): Promise<void> => {
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
// Already pending or active - ignore the duplicate submission.
return Promise.resolve();
}
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: null,
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
status: 'success'
};
});
return scanLibrary(libraryId)
.then(() => loadScanStatuses())
.catch((error: unknown) => {
const pendingIds = new Set(pendingIdsRef.current);
pendingIds.delete(libraryId);
pendingIdsRef.current = pendingIds;
pendingGraceTicksRef.current.delete(libraryId);
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
const pendingLibraryIds = new Set(current.pendingLibraryIds);
pendingLibraryIds.delete(libraryId);
return {
data: current.data,
error: messageFromLibrariesError(error, 'Unable to scan library'),
pendingLibraryIds,
status: 'success'
};
});
});
}, [loadScanStatuses]);
if (state.status === 'success') {
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
const scanningLibraryIds = new Set([...state.pendingLibraryIds, ...activeLibraryIds]);
return {
data: state.data,
error: state.error,
refresh,
scanLibrary: triggerScan,
scanningLibraryIds,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+321
View File
@@ -0,0 +1,321 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
export interface PlayoutsScreenData {
channelStates: PlayoutChannelState[];
items: PlayoutItem[];
playout: PlayoutDetail | null;
playouts: PlayoutSummary[];
selectedPlayoutId: number | null;
totalCount: number;
warningsCount: number;
}
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
export type PlayoutsScreenQueryState =
| {
data: PlayoutsScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActivePlayout: (playoutId: number) => void;
setShowFiller: (showFiller: boolean) => void;
showFiller: boolean;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type PlayoutsScreenState =
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getPlayouts(): Promise<PlayoutsPage> {
return request<PlayoutsPage>('/api/playouts');
}
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
}
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
}
export function getPlayoutWarningsCount(): Promise<number> {
return request<number>('/api/playouts/warnings/count');
}
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
return request<PlayoutChannelState[]>('/api/channels/state');
}
export function resetAllPlayouts(): Promise<void> {
return request<void>('/api/playouts/reset-all', { method: 'POST' });
}
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
const [state, setState] = useState<PlayoutsScreenState>({
data: null,
error: null,
status: 'loading'
});
const [showFiller, setShowFillerState] = useState(false);
const activeRef = useRef(true);
const selectedPlayoutIdRef = useRef<number | null>(null);
const showFillerRef = useRef(false);
// Snapshot of the last-known base fields (everything but the selected playout's
// detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading
// this lets setActivePlayout/setShowFiller kick off their fetch without doing the
// fetch from inside a setState updater (impure; StrictMode double-invokes updaters).
const baseRef = useRef<PlayoutsScreenBase>({
channelStates: [],
playouts: [],
selectedPlayoutId: null,
totalCount: 0,
warningsCount: 0
});
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => {
baseRef.current = base;
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)])
.then(([playout, itemsPage]) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
// Merge onto whatever channel state the background poll may have landed
// while this request was in flight, rather than clobbering it with the
// snapshot captured when the request started.
const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates;
return {
data: {
...base,
channelStates,
items: itemsPage.page ?? [],
playout,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, []);
const load = useCallback(() => {
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
return;
}
const playouts = playoutsPage.page ?? [];
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
? selectedPlayoutIdRef.current
: playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base: PlayoutsScreenBase = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
baseRef.current = base;
if (selectedPlayoutId == null) {
setState({
data: {
...base,
items: [],
playout: null
},
error: null,
itemsLoading: false,
status: 'success'
});
return;
}
loadSelectedPlayout(selectedPlayoutId, base);
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, [loadSelectedPlayout]);
const loadChannelStates = useCallback(() => {
getPlayoutChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
baseRef.current = { ...baseRef.current, channelStates };
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
itemsLoading: current.itemsLoading,
status: 'success'
};
});
})
.catch(() => {
// Keep the monitor visible on background polling failures.
});
}, []);
useEffect(() => {
load();
const intervalId = window.setInterval(loadChannelStates, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [load, loadChannelStates, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActivePlayout = useCallback((playoutId: number) => {
selectedPlayoutIdRef.current = playoutId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: [],
playout: null,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: true,
status: 'success'
};
});
const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId };
loadSelectedPlayout(playoutId, base);
}, [loadSelectedPlayout]);
const setShowFiller = useCallback((next: boolean) => {
showFillerRef.current = next;
setShowFillerState(next);
const playoutId = selectedPlayoutIdRef.current;
if (playoutId == null) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return { ...current, itemsLoading: true };
});
getPlayoutItems(playoutId, next)
.then((itemsPage) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items: itemsPage.page ?? [] },
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActivePlayout,
setShowFiller,
showFiller,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+304
View File
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
// once the schema is fixed to describe the concrete subtypes.
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
count?: null | string;
discardToFillAttempts?: null | number;
multipleCount?: null | string;
multipleMode?: components['schemas']['MultipleMode'];
playoutDuration?: null | string;
tailMode?: components['schemas']['TailMode'];
};
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
export type Watermark = components['schemas']['WatermarkResponseModel'];
export interface SchedulePickerData {
collections: MediaCollection[];
fillerPresets: FillerPreset[];
smartCollections: SmartCollection[];
watermarks: Watermark[];
}
export interface ScheduleScreenData {
activeSchedule: ProgramSchedule | null;
items: ProgramScheduleItem[];
pickers: SchedulePickerData;
schedules: ProgramSchedule[];
totalDurationEstimate: string | null;
}
export type ScheduleScreenQueryState =
| {
data: ScheduleScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActiveSchedule: (scheduleId: number) => void;
setItems: (items: ProgramScheduleItem[]) => void;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ScheduleScreenState =
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getSchedules(): Promise<ProgramSchedule[]> {
return request<ProgramSchedule[]>('/api/schedules');
}
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
}
export function getCollections(): Promise<MediaCollection[]> {
return request<MediaCollection[]>('/api/collections');
}
export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
}
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets');
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks');
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'POST'
});
}
export function replaceScheduleItems(
scheduleId: number,
body: ReplaceScheduleItemsRequest
): Promise<ProgramScheduleItem[]> {
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'PUT'
});
}
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
method: 'DELETE'
});
}
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
const schedules = await getSchedules();
const activeSchedule = schedules[0] ?? null;
if (!activeSchedule) {
return {
activeSchedule: null,
items: [],
pickers: emptyPickerData(),
schedules,
totalDurationEstimate: null
};
}
const [itemsEnvelope, pickers] = await Promise.all([
getScheduleItems(activeSchedule.id),
getSchedulePickerData()
]);
return {
activeSchedule,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
pickers,
schedules,
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
};
}
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
const [state, setState] = useState<ScheduleScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const activeScheduleIdRef = useRef<number | null>(null);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback(() => {
getScheduleScreenData()
.then((data) => {
if (activeRef.current) {
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
setState({ data, error: null, itemsLoading: false, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActiveSchedule = useCallback((scheduleId: number) => {
activeScheduleIdRef.current = scheduleId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
?? current.data.activeSchedule;
return {
data: { ...current.data, activeSchedule: nextActiveSchedule },
error: null,
itemsLoading: true,
status: 'success'
};
});
getScheduleItems(scheduleId)
.then((itemsEnvelope) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState({
data: null,
error: messageFromScheduleError(error, 'Unable to load schedule items'),
status: 'error'
});
});
}, []);
const setItems = useCallback((items: ProgramScheduleItem[]) => {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items },
error: null,
itemsLoading: false,
status: 'success'
};
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActiveSchedule,
setItems,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
async function getSchedulePickerData(): Promise<SchedulePickerData> {
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
getCollections(),
getSmartCollections(),
getFillerPresets(),
getWatermarks()
]);
return {
collections,
fillerPresets: sortByName(fillerPresets),
smartCollections,
watermarks: sortByName(watermarks)
};
}
function emptyPickerData(): SchedulePickerData {
return {
collections: [],
fillerPresets: [],
smartCollections: [],
watermarks: []
};
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+1513 -65
View File
File diff suppressed because it is too large Load Diff