feat(60): in-browser channel preview on the channels list #551

Merged
timothy merged 17 commits from feat/60-channel-preview into main 2026-07-22 00:29:36 +02:00
Owner

fixes #60

Adds in-browser channel preview to ChicoryTV, scoped as an operator verification tool — "does this channel work right now, and is it playing what the guide says?" — not a viewing experience. Channel switching, fullscreen/lean-back UX and resume are explicitly out of scope.

Design: docs/superpowers/specs/2026-07-21-in-browser-channel-preview-design.md
Plan: docs/superpowers/plans/2026-07-21-in-browser-channel-preview.md

Shape

Server declares capability; the SPA derives nothing. ChannelResponseModel gains an additive Preview field ({Availability, ManifestUrl, UnavailableReason}), computed in one place by the pure Mapper.GetPreview. This follows api.healthcheck-remediation-dto — the alternative was keying SPA behavior off Mapper.GetStreamingMode's human-readable display label, where a copy tweak would silently break playback.

Condition Availability URL
IPTV JWT auth enabled Unavailable — IPTV JWT authentication is enabled —
Channel disabled Unavailable — Channel is disabled —
No playout Unavailable — Channel has no playout —
HttpLiveStreamingSegmenter / Direct Available /iptv/channel/{n}.m3u8
TransportStream / Hybrid ForcedHlsOnly /iptv/channel/{n}.m3u8?mode=segmenter

Guard precedence is JWT → disabled → no-playout, ahead of the mode switch, and is documented and tested.

Only two of four streaming modes are browser-playable — a browser cannot play the video/mp2t the Transport Stream modes serve. Those are offered as an explicit opt-in that forces an HLS segmenter session, always behind a warning banner making clear the check does not exercise the channel's configured pipeline.

SPA. The channels list's Play button — permanently disabled until now — becomes live, opening a SlideOver (ChannelPreviewPanel) over the existing reusable HlsPlayer. The panel shows the resolved mode, the exact manifest URL (so a failure is reproducible with curl), live state, and what the guide says is on now (reused from ChannelStateResponseModel.NowPlaying, which the screen already holds — no new endpoint, no new fetch, no timer).

Diagnostic honesty is the throughline. HlsPlayer gained onError and onPlaying; fatal errors are reported, never auto-recovered, and a fatal error cannot be erased by a later playing event. Retry is user-initiated only, incrementing a real playToken because the manifest GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op.

Verification

  • .NET: 1809 / 631 / 1484 / 109 / 58 / 34 / 5 — all green post-rebase.
  • Web: lint clean, tsc --noEmit clean, 916/916, build succeeds.
  • Live-E2E on a real booted instance (fresh config dir): a TS-mode channel returned ForcedHlsOnly + ?mode=segmenter; switching to segmenter returned Available + the plain URL; a zero-playout channel returned Channel has no playout; a disabled channel returned Channel is disabled. Negative control: channel 999 → 404, while a real channel's manifest blocks in the handler — proving the route resolves.
  • BOM + dotnet format whitespace clean; decisions_validate.py exit 0.
  • Independent review: per-task cold-context reviews plus two adversarial whole-branch passes. The final pass found 5 Important issues — all fixed, including one where the panel could report playing over a black video, and two fault classes the server knew about but declared Available.

Follow-ups (filed separately, not dropped)

  1. Mint a short-lived JWT for the SPA so preview — and the existing troubleshooting screen — work under a JWT-enabled deployment. /iptv/* does not accept the ctv-session cookie and nothing mints a JWT today.
  2. scripts/select-queue.sh ranks Renovate's bot-authored Dependency Dashboard (#22) as a workable pickup; it should be excluded.
  3. Autoplay-blocked case: because the manifest GET can block past the browser's user-activation window, play() may be rejected and the panel sits at starting with no "press play" hint.

🤖 Generated with Claude Code

fixes #60 Adds in-browser channel preview to ChicoryTV, scoped as an **operator verification tool** — *"does this channel work right now, and is it playing what the guide says?"* — not a viewing experience. Channel switching, fullscreen/lean-back UX and resume are explicitly out of scope. Design: `docs/superpowers/specs/2026-07-21-in-browser-channel-preview-design.md` Plan: `docs/superpowers/plans/2026-07-21-in-browser-channel-preview.md` ## Shape **Server declares capability; the SPA derives nothing.** `ChannelResponseModel` gains an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`), computed in one place by the pure `Mapper.GetPreview`. This follows `api.healthcheck-remediation-dto` — the alternative was keying SPA behavior off `Mapper.GetStreamingMode`'s *human-readable display label*, where a copy tweak would silently break playback. | Condition | Availability | URL | |---|---|---| | IPTV JWT auth enabled | `Unavailable` — *IPTV JWT authentication is enabled* | — | | Channel disabled | `Unavailable` — *Channel is disabled* | — | | No playout | `Unavailable` — *Channel has no playout* | — | | `HttpLiveStreamingSegmenter` / `Direct` | `Available` | `/iptv/channel/{n}.m3u8` | | `TransportStream` / `Hybrid` | `ForcedHlsOnly` | `/iptv/channel/{n}.m3u8?mode=segmenter` | Guard precedence is JWT → disabled → no-playout, ahead of the mode switch, and is documented and tested. **Only two of four streaming modes are browser-playable** — a browser cannot play the `video/mp2t` the Transport Stream modes serve. Those are offered as an explicit opt-in that forces an HLS segmenter session, always behind a warning banner making clear the check does **not** exercise the channel's configured pipeline. **SPA.** The channels list's Play button — permanently disabled until now — becomes live, opening a `SlideOver` (`ChannelPreviewPanel`) over the existing reusable `HlsPlayer`. The panel shows the resolved mode, the exact manifest URL (so a failure is reproducible with `curl`), live state, and what the guide says is on now (reused from `ChannelStateResponseModel.NowPlaying`, which the screen already holds — no new endpoint, no new fetch, no timer). **Diagnostic honesty is the throughline.** `HlsPlayer` gained `onError` and `onPlaying`; fatal errors are **reported, never auto-recovered**, and a fatal error cannot be erased by a later `playing` event. Retry is user-initiated only, incrementing a real `playToken` because the manifest GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op. ## Verification - **.NET**: 1809 / 631 / 1484 / 109 / 58 / 34 / 5 — all green post-rebase. - **Web**: lint clean, `tsc --noEmit` clean, 916/916, build succeeds. - **Live-E2E** on a real booted instance (fresh config dir): a TS-mode channel returned `ForcedHlsOnly` + `?mode=segmenter`; switching to segmenter returned `Available` + the plain URL; a zero-playout channel returned *Channel has no playout*; a disabled channel returned *Channel is disabled*. Negative control: channel 999 → 404, while a real channel's manifest blocks in the handler — proving the route resolves. - BOM + `dotnet format whitespace` clean; `decisions_validate.py` exit 0. - **Independent review**: per-task cold-context reviews plus two adversarial whole-branch passes. The final pass found 5 Important issues — all fixed, including one where the panel could report *playing* over a black video, and two fault classes the server knew about but declared `Available`. ## Follow-ups (filed separately, not dropped) 1. Mint a short-lived JWT for the SPA so preview — and the **existing** troubleshooting screen — work under a JWT-enabled deployment. `/iptv/*` does not accept the `ctv-session` cookie and nothing mints a JWT today. 2. `scripts/select-queue.sh` ranks Renovate's bot-authored Dependency Dashboard (#22) as a workable pickup; it should be excluded. 3. Autoplay-blocked case: because the manifest GET can block past the browser's user-activation window, `play()` may be rejected and the panel sits at `starting` with no "press play" hint. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 16 commits 2026-07-21 23:19:47 +02:00
Verification-oriented HLS preview on the channels list: server-declared
per-channel Preview capability (Availability/ManifestUrl/UnavailableReason),
a SlideOver panel over the existing HlsPlayer, and an onError extension so
playback failures stop being silent.

Refs #60
Also simplifies the spec's guide correlation: ChannelStateResponseModel
already carries NowPlaying and ChannelsScreen already holds it, so the
panel needs no /api/v1/guide fetch and no timer.

Refs #60
Adds ChannelPreviewResponseModel + ChannelPreviewAvailability constants
and Mapper.GetPreview(streamingMode, channelNumber, iptvJwtEnabled),
threaded through ProjectToResponseModel's new iptvJwtEnabled parameter
and GetAllChannelsForApi's new IptvJwtEnabled property.

Also adds ErsatzTV.Core.Tests to ErsatzTV.Application's
InternalsVisibleTo list (was ErsatzTV.Tests only) so the new
ChannelPreviewTests can call the internal Mapper methods it's testing.

ChannelController.GetAll is left failing to compile (Task 2 wires
JwtHelper.IsEnabled in at the controller).
Review findings on the Task 1 commit (f6ff3b63):

1. Mapper.GetPreview is internal to ErsatzTV.Application, granted only to
   ErsatzTV.Tests by convention (one-assembly-one-test-project). Move
   ChannelPreviewTests.cs from ErsatzTV.Core.Tests to
   ErsatzTV.Tests/Application/Channels, and revert the second
   InternalsVisibleTo entry added to ErsatzTV.Application.csproj for
   ErsatzTV.Core.Tests.

2. GetAllChannelsForApiHandlerTests.cs constructed `new
   GetAllChannelsForApi()` at three sites, which no longer compiles now
   that the record requires IptvJwtEnabled. Pass IptvJwtEnabled: false at
   each site (all three tests are about plain channel listing/logo
   mapping, not JWT).

ChannelController.GetAll's missing argument remains, deliberately, for a
later task.
The real paths are ErsatzTV/wwwroot/openapi/v1.json and
web/src/api/generated/v1.d.ts.

Refs #60
Renders an in-browser HLS preview of a channel (operator diagnostic).
Deviates from the plan brief per updated requirements: an explicit
user-initiated Retry control replaces the hardcoded playToken, the
forced-HLS caveat renders both before and after opting in, and the
caveat string is exported verbatim as FORCED_HLS_CAVEAT.
Two review findings on the channel preview panel (#60):
- ChannelPreviewPanel's synchronous render-phase reset (started/state/error/playToken
  on channel.id change) was reachable in prod (the channels screen keeps one panel
  mounted and swaps the channel prop) but untested. Added tests proving no auto-start
  switching into a ForcedHlsOnly channel, error clearing on switch between Available
  channels, and no playToken leak across the switch.
- PlaybackState included 'playing' but nothing ever set it. Added HlsPlayer onPlaying,
  fired from Hls.Events.MANIFEST_PARSED and the native-HLS <video> 'playing' event,
  mirroring onError's optional/stable-callback contract; ChannelPreviewPanel now wires
  it to reach 'playing'. Also fixed a latent bug hit while exercising this path:
  video.play().catch(...) assumed a Promise, but jsdom's play() returns undefined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the ChannelPreviewPanel (Task 4) into ChannelsScreen: a single
panel instance is rendered per screen and its `channel` prop is swapped
via previewChannelId state rather than remounting per row. The Play
button now reads the server-derived channel.preview.availability
(Task 2) instead of being permanently disabled -- Unavailable stays
disabled with the server's unavailableReason surfaced in the title;
Available and ForcedHlsOnly both enable it, since the panel itself
handles the forced-HLS opt-in and caveat.

Also fixes App.test.tsx's #244 channel fixture, which lacked the now-
required preview field and crashed once ChannelTableRow started
reading it.
Trips the CI Formatting gate (CHARSET).

Refs #60
GetPreview keyed only on StreamingMode + JWT, so a disabled channel or one
with no playout was declared Available and then failed confusingly (404 from
IptvController, or an indefinitely-blocking manifest request). Extend it to
take isEnabled + playoutCount and check JWT, then disabled, then no-playout,
before falling back to the existing mode-based rules; order is documented
in a comment. Also corrects a stale comment in ChannelPreviewResponseModel.cs
that claimed a C# string generates a TypeScript union (it does not).
- HlsPlayer: drive onPlaying from the <video> element's own `playing` event on
  BOTH the hls.js and Safari-native paths instead of MANIFEST_PARSED, which
  fires before any media has decoded (an HttpLiveStreamingDirect manifest
  always parses, even over a black video). MANIFEST_PARSED now only kicks
  play(). Restore `void video.play().catch(...)` at both call sites and stub
  HTMLMediaElement.prototype.play in setupTests.ts instead, so the `?.` that
  existed only to survive jsdom is gone from production code.
- HlsPlayer.test.tsx: assert the auto-recovery guard against hls.js's own
  startLoad()/recoverMediaError(), not just loadSource's call count.
- ChannelPreviewPanel: reuse existing ctv-* classes (ctv-channels-error,
  ctv-settings-warn-callout, ctv-detail-actions, ctv-detail-infogrid) instead
  of five undefined ctv-preview-* classes; add the two genuinely new rules
  (ctv-preview-video max-width, spacing tweaks) to shell.css.
- Add an exported ChannelPreviewAvailability union (web/src/api/channels.ts)
  so a typo like 'ForcedHLSOnly' fails to compile instead of silently
  disabling a branch forever; use it in ChannelPreviewPanel's prop type and
  at the ChannelsScreen comparison sites.
The design spec claimed ChannelDetailResponseModel also gained the Preview
field; only ChannelResponseModel did (deliberate — nothing consumes it on the
detail DTO). Also record the two new Unavailable causes (disabled channel,
zero playouts) added to Mapper.GetPreview in the api.channel-preview-capability
decisions.md record.
fix(60): re-review fixups for channel preview
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m26s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
6838979780
- ChannelPreviewPanel: a manual play-button click on a video already
  showing a fatal error was clearing the error, silently hiding the
  fault the panel exists to reveal. onPlaying now ignores the event
  while a fatal error is showing (tracked via a ref, reset in an
  effect keyed on channel.id); Retry remains the only way to clear it.
- shell.css: .ctv-preview-facts spacing was dead — equal-specificity
  .ctv-detail-infogrid{margin:0} later in the file won. Raised
  specificity with a compound selector instead of touching
  .ctv-detail-infogrid, which MediaDetailScreen also relies on.
- ChannelPreviewTests: added two cases exercising two simultaneously-
  true Unavailable causes, so the documented guard precedence in
  Mapper.GetPreview is actually pinned by a test.
- design doc: fixed a garbled sentence describing which DTO gained
  the Preview field.
timothy added 1 commit 2026-07-21 23:28:09 +02:00
test(60): prove the failed-flag reset effect actually runs on channel switch
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m57s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m39s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m30s
6bf8e191cb
The reset moved from render-phase into an effect; the existing switch test
asserted only that the error banner cleared, which stays green even if the
effect is deleted. Now the new channel must also reach 'playing'.

Negative control: disabling the reset fails exactly this test, and only it.

Refs #60
Author
Owner

Review-verdict: MERGEABLE @ 6bf8e191

Review trail

Every task got a cold-context review; the branch then got two adversarial whole-branch passes on a frontier model.

Final whole-branch review — 0 Critical, 5 Important, all fixed:

  • Every ctv-preview-* class was undefined, so the feature shipped unstyled — including the forced-HLS caveat, the one thing preventing a green Transport Stream preview from reading as pipeline validation.
  • onPlaying fired on MANIFEST_PARSED, before any media decoded, so the panel could report playing over a black video. Now driven by the <video> element's real playing event on both paths.
  • A disabled channel was declared Available, so Play was live and failed with a raw networkError: manifestLoadError.
  • A channel with no playout hung at starting indefinitely (the manifest GET blocks).
  • availability was a bare string, so 'ForcedHLSOnly' would have compiled clean and silently killed a branch.

Re-review of the fix pass caught a regression the fix itself introduced: onPlaying cleared error, and since playing now fires on every path, an operator clicking the native play button after a failure would have erased the fault text — precisely the behavior the design forbids. Fixed with a guarded failedRef; Retry still clears deliberately.

Final commit review: MERGEABLE, 0 Critical/Important. Its one Minor is what 6bf8e191 addresses — the test guarding that failedRef reset would have stayed green if the reset were deleted. Now the switched-to channel must also reach playing, and I verified the guard by negative control: disabling the reset effect fails exactly that test and only that test, after which the panel was restored byte-identical.

Verification on this head

  • .NET 1809 / 631 / 1484 / 109 / 58 / 34 / 5 — green post-rebase.
  • Web lint clean, tsc --noEmit clean, 916/916 (917 with the new guard), build succeeds.
  • Live-E2E on a booted instance, fresh config dir: TS-mode → ForcedHlsOnly + ?mode=segmenter; segmenter → Available + plain URL; zero-playout → Channel has no playout; disabled → Channel is disabled (precedence: disabled beats no-playout). Negative control: channel 999 → 404 while a real channel blocks in the handler, proving the route resolves.
  • BOM + dotnet format whitespace clean — a real BOM was caught and stripped from GetAllChannelsForApi.cs before the first push.
  • decisions_validate.py exit 0.

Rebased twice onto a moving main (#546, then #550); both decisions.md collisions resolved append-only with the catalog regenerated, never hand-merged.

Caveats for the reviewer

  • ChannelDetailResponseModel deliberately did not gain Preview — nothing consumes it there, and /api/v1 stays additive so it can be added later at no cost.
  • Autoplay-blocked previews can still sit at starting with no "press play" hint — known, tracked in #554.
  • One theoretical residue: if hls.js ever emitted a synchronous fatal error from inside loadSource/attachMedia during a channel switch, the reset effect could clear the failed flag in that window. No real trigger could be constructed (hls.js surfaces those from async loader callbacks); if it ever bites, the clean fix is key={channel.id} on the panel rather than more ref bookkeeping.
Review-verdict: MERGEABLE @ 6bf8e191 ## Review trail Every task got a cold-context review; the branch then got two adversarial whole-branch passes on a frontier model. **Final whole-branch review** — 0 Critical, 5 Important, all fixed: - Every `ctv-preview-*` class was undefined, so the feature shipped unstyled — including the forced-HLS caveat, the one thing preventing a green Transport Stream preview from reading as pipeline validation. - `onPlaying` fired on `MANIFEST_PARSED`, before any media decoded, so the panel could report **playing** over a black video. Now driven by the `<video>` element's real `playing` event on both paths. - A **disabled** channel was declared `Available`, so Play was live and failed with a raw `networkError: manifestLoadError`. - A channel with **no playout** hung at `starting` indefinitely (the manifest GET blocks). - `availability` was a bare `string`, so `'ForcedHLSOnly'` would have compiled clean and silently killed a branch. **Re-review of the fix pass** caught a regression the fix itself introduced: `onPlaying` cleared `error`, and since `playing` now fires on every path, an operator clicking the native play button after a failure would have **erased the fault text** — precisely the behavior the design forbids. Fixed with a guarded `failedRef`; Retry still clears deliberately. **Final commit review**: MERGEABLE, 0 Critical/Important. Its one Minor is what `6bf8e191` addresses — the test guarding that `failedRef` reset would have stayed green if the reset were deleted. Now the switched-to channel must also reach `playing`, and I verified the guard by **negative control**: disabling the reset effect fails exactly that test and only that test, after which the panel was restored byte-identical. ## Verification on this head - **.NET** 1809 / 631 / 1484 / 109 / 58 / 34 / 5 — green post-rebase. - **Web** lint clean, `tsc --noEmit` clean, 916/916 (917 with the new guard), build succeeds. - **Live-E2E** on a booted instance, fresh config dir: TS-mode → `ForcedHlsOnly` + `?mode=segmenter`; segmenter → `Available` + plain URL; zero-playout → *Channel has no playout*; disabled → *Channel is disabled* (precedence: disabled beats no-playout). Negative control: channel 999 → 404 while a real channel blocks in the handler, proving the route resolves. - BOM + `dotnet format whitespace` clean — a real BOM was caught and stripped from `GetAllChannelsForApi.cs` before the first push. - `decisions_validate.py` exit 0. Rebased twice onto a moving `main` (#546, then #550); both `decisions.md` collisions resolved append-only with the catalog **regenerated**, never hand-merged. ## Caveats for the reviewer - `ChannelDetailResponseModel` deliberately did **not** gain `Preview` — nothing consumes it there, and `/api/v1` stays additive so it can be added later at no cost. - Autoplay-blocked previews can still sit at `starting` with no "press play" hint — known, tracked in #554. - One theoretical residue: if hls.js ever emitted a *synchronous* fatal error from inside `loadSource`/`attachMedia` during a channel switch, the reset effect could clear the failed flag in that window. No real trigger could be constructed (hls.js surfaces those from async loader callbacks); if it ever bites, the clean fix is `key={channel.id}` on the panel rather than more ref bookkeeping.
timothy merged commit 732322dc84 into main 2026-07-22 00:29:36 +02:00
Sign in to join this conversation.