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.
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.
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)
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.
scripts/select-queue.sh ranks Renovate's bot-authored Dependency Dashboard (#22) as a workable pickup; it should be excluded.
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.
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)
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.
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.
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.
- 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.
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
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.mdPlan:
docs/superpowers/plans/2026-07-21-in-browser-channel-preview.mdShape
Server declares capability; the SPA derives nothing.
ChannelResponseModelgains an additivePreviewfield ({Availability, ManifestUrl, UnavailableReason}), computed in one place by the pureMapper.GetPreview. This followsapi.healthcheck-remediation-dto— the alternative was keying SPA behavior offMapper.GetStreamingMode's human-readable display label, where a copy tweak would silently break playback.Unavailable— IPTV JWT authentication is enabledUnavailable— Channel is disabledUnavailable— Channel has no playoutHttpLiveStreamingSegmenter/DirectAvailable/iptv/channel/{n}.m3u8TransportStream/HybridForcedHlsOnly/iptv/channel/{n}.m3u8?mode=segmenterGuard 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/mp2tthe 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 reusableHlsPlayer. The panel shows the resolved mode, the exact manifest URL (so a failure is reproducible withcurl), live state, and what the guide says is on now (reused fromChannelStateResponseModel.NowPlaying, which the screen already holds — no new endpoint, no new fetch, no timer).Diagnostic honesty is the throughline.
HlsPlayergainedonErrorandonPlaying; fatal errors are reported, never auto-recovered, and a fatal error cannot be erased by a laterplayingevent. Retry is user-initiated only, incrementing a realplayTokenbecause the manifest GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op.Verification
tsc --noEmitclean, 916/916, build succeeds.ForcedHlsOnly+?mode=segmenter; switching to segmenter returnedAvailable+ 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.dotnet format whitespaceclean;decisions_validate.pyexit 0.Available.Follow-ups (filed separately, not dropped)
/iptv/*does not accept thectv-sessioncookie and nothing mints a JWT today.scripts/select-queue.shranks Renovate's bot-authored Dependency Dashboard (#22) as a workable pickup; it should be excluded.play()may be rejected and the panel sits atstartingwith no "press play" hint.🤖 Generated with Claude Code
- 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.Review-verdict: MERGEABLE @
6bf8e191Review 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:
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.onPlayingfired onMANIFEST_PARSED, before any media decoded, so the panel could report playing over a black video. Now driven by the<video>element's realplayingevent on both paths.Available, so Play was live and failed with a rawnetworkError: manifestLoadError.startingindefinitely (the manifest GET blocks).availabilitywas a barestring, so'ForcedHLSOnly'would have compiled clean and silently killed a branch.Re-review of the fix pass caught a regression the fix itself introduced:
onPlayingclearederror, and sinceplayingnow 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 guardedfailedRef; Retry still clears deliberately.Final commit review: MERGEABLE, 0 Critical/Important. Its one Minor is what
6bf8e191addresses — the test guarding thatfailedRefreset would have stayed green if the reset were deleted. Now the switched-to channel must also reachplaying, 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
tsc --noEmitclean, 916/916 (917 with the new guard), build succeeds.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.dotnet format whitespaceclean — a real BOM was caught and stripped fromGetAllChannelsForApi.csbefore the first push.decisions_validate.pyexit 0.Rebased twice onto a moving
main(#546, then #550); bothdecisions.mdcollisions resolved append-only with the catalog regenerated, never hand-merged.Caveats for the reviewer
ChannelDetailResponseModeldeliberately did not gainPreview— nothing consumes it there, and/api/v1stays additive so it can be added later at no cost.startingwith no "press play" hint — known, tracked in #554.loadSource/attachMediaduring 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 iskey={channel.id}on the panel rather than more ref bookkeeping.timothy referenced this pull request2026-09-05 01:38:10 +02:00