feat: SPA playback troubleshooting screen + status/subtitles/stream-selectors endpoints (#145) #199

Merged
timothy merged 12 commits from feat/145-troubleshooting-spa into main 2026-07-09 08:21:04 +02:00
Owner

Ports the last remaining Blazor-only route — /system/troubleshooting/playback — to the ChicoryTV SPA, completing #145 (the final functional gate for #91 phase b).

Backend

  • GET /api/troubleshoot/playback/stream-selectors, GET /api/troubleshoot/playback/subtitles/{mediaItemId} (404 pre-check), GET /api/troubleshoot/playback/status.
  • New ITroubleshootingPlaybackStatusStore (Core singleton, TroubleshootingNotifier precedent) fed by a MediatR INotificationHandler<PlaybackTroubleshootingCompletedNotification>; reset at session lock in PrepareTroubleshootingPlaybackHandler — replaces Blazor''s in-process Courier subscription with a pollable status (state/exitCode/speed/logs tail).
  • GET /api/graphics-elements?refresh=true option (Blazor refresh-before-list parity).

SPA

  • /app/troubleshooting/playback (?channel= / ?mediaItem= modes) mirroring PlaybackTroubleshooting.razor field-for-field: profile/stream-selector/subtitle/watermark/graphics pickers with Blazor gating rules, seek defaults, RemoteStream handling; hls.js HlsPlayer (new reusable component, playToken re-attach); 2s status polling with starting→running→settled phase machine (30s timeout); logs pane + speed badge; archive/sample downloads; entry points on the channels table + movie detail page.
  • Deviations (documented in docs/decisions.md): datetime-local instead of Chronic NL parsing; polling instead of push; media-sample download gated while running.

Review + E2E hardening (fork adversarial review + live Playwright pass)

  • MUST-FIX: repeat Play with identical settings never re-issued the session request (state no-op) → playToken teardown/re-attach + regression test.
  • SHOULD-FIX: first-poll race vs late server lock → starting phase ignores stale settled results until server-observed running; graphics list staleness → refresh param.
  • Live-E2E-found: channel mode sent ffmpegProfile=0 — runtime JSON is Newtonsoft-cased (ffmpegProfileId) while generated types say fFmpegProfileId; worked around here, root cause filed as #198 (also affects ChannelEditScreen on main).

Verification

ErsatzTV.Tests 927, Core.Tests 493(+1 skip), Architecture 5, web 385 — all green locally; live E2E on a scratch instance verified empty state, both modes, request shape (ffmpegProfile=2, ISO start), 30s timeout path, repeat-Play re-request (first request ERR_ABORTED by hls destroy), bogus-id 404 handling, downloads, and /app/troubleshooting regression.

Docs: blazor-route-parity (playback row → Section 2; Section 3 now empty), decisions.md, spa-conventions (HlsPlayer), endpoint-index + v1.json regenerated.

Closes #145.

Ports the last remaining Blazor-only route — `/system/troubleshooting/playback` — to the ChicoryTV SPA, completing #145 (the final functional gate for #91 phase b). ## Backend - `GET /api/troubleshoot/playback/stream-selectors`, `GET /api/troubleshoot/playback/subtitles/{mediaItemId}` (404 pre-check), `GET /api/troubleshoot/playback/status`. - New `ITroubleshootingPlaybackStatusStore` (Core singleton, `TroubleshootingNotifier` precedent) fed by a MediatR `INotificationHandler<PlaybackTroubleshootingCompletedNotification>`; reset at session lock in `PrepareTroubleshootingPlaybackHandler` — replaces Blazor''s in-process Courier subscription with a pollable status (state/exitCode/speed/logs tail). - `GET /api/graphics-elements?refresh=true` option (Blazor refresh-before-list parity). ## SPA - `/app/troubleshooting/playback` (`?channel=` / `?mediaItem=` modes) mirroring `PlaybackTroubleshooting.razor` field-for-field: profile/stream-selector/subtitle/watermark/graphics pickers with Blazor gating rules, seek defaults, RemoteStream handling; hls.js `HlsPlayer` (new reusable component, `playToken` re-attach); 2s status polling with `starting→running→settled` phase machine (30s timeout); logs pane + speed badge; archive/sample downloads; entry points on the channels table + movie detail page. - Deviations (documented in docs/decisions.md): datetime-local instead of Chronic NL parsing; polling instead of push; media-sample download gated while running. ## Review + E2E hardening (fork adversarial review + live Playwright pass) - MUST-FIX: repeat Play with identical settings never re-issued the session request (state no-op) → `playToken` teardown/re-attach + regression test. - SHOULD-FIX: first-poll race vs late server lock → `starting` phase ignores stale settled results until server-observed `running`; graphics list staleness → `refresh` param. - Live-E2E-found: channel mode sent `ffmpegProfile=0` — runtime JSON is Newtonsoft-cased (`ffmpegProfileId`) while generated types say `fFmpegProfileId`; worked around here, root cause filed as **#198** (also affects `ChannelEditScreen` on main). ## Verification ErsatzTV.Tests **927**, Core.Tests 493(+1 skip), Architecture 5, web **385** — all green locally; live E2E on a scratch instance verified empty state, both modes, request shape (`ffmpegProfile=2`, ISO `start`), 30s timeout path, repeat-Play re-request (first request `ERR_ABORTED` by hls destroy), bogus-id 404 handling, downloads, and `/app/troubleshooting` regression. Docs: blazor-route-parity (playback row → Section 2; Section 3 now empty), decisions.md, spa-conventions (HlsPlayer), endpoint-index + v1.json regenerated. Closes #145.
timothy added 12 commits 2026-07-09 08:09:55 +02:00
Add a singleton ITroubleshootingPlaybackStatusStore (Core, alongside
TroubleshootingNotifier) that records the exit code + speed of the most
recent troubleshooting playback session. A new MediatR notification
handler writes to it on PlaybackTroubleshootingCompletedNotification, and
PrepareTroubleshootingPlaybackHandler resets it when a new session starts
(both the channel and media-item lock paths).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add three GET endpoints to TroubleshootController for the SPA port of the
playback troubleshooting page:
- /api/troubleshoot/playback/stream-selectors (List<string>)
- /api/troubleshoot/playback/subtitles/{mediaItemId} (404 pre-check via
  GetMediaItemInfo; maps SubtitleViewModel -> TroubleshootingSubtitleResponseModel)
- /api/troubleshoot/playback/status (TroubleshootingPlaybackStatusResponseModel:
  idle/running/completed/failed + exitCode/speed + logs.txt tail)

Regenerate v1.json, endpoint-index.md, and the web API types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add controller tests for stream-selectors, subtitles (404 + mapping), and
the status endpoint's idle/running/completed/failed transitions (using the
real status store), plus a unit test for the notification handler. Add the
subtitles 404 case to the OpenAPI ProblemDetails contract test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds hls.js as a web dependency and a small HlsPlayer wrapper that attaches
hls.js when MSE is available (config mirrors the legacy _Host.cshtml
previewChannel: liveDurationInfinity + unbounded manifest time-to-first-byte,
since the troubleshooting playback.m3u8 endpoint blocks until segments exist)
and falls back to native HLS on Safari. Tears down the instance on src change
and unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds thin client wrappers (+ URL-building tests) for GET
/api/troubleshoot/playback/{stream-selectors,subtitles/{id},status}, used by
the playback troubleshooting screen (#145).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports Blazor Pages/Troubleshooting/PlaybackTroubleshooting.razor to the SPA at
/app/troubleshooting/playback. Driven by ?mediaItem={id} (media mode) or
?channel={id} (channel mode); neither shows a friendly empty state (no redirect).

- Channel mode: FFmpeg profile (channel's preselected), stream selector, and a
  datetime-local Date/Time (deviation from Blazor's Chronic natural-language
  parse) sent as the m3u8 'start' ISO param.
- Media mode: subtitle (mutually exclusive with stream selector), watermark +
  graphics-element multi-select (repeated watermark/graphicsElement id params),
  Start From Beginning (disabled for RemoteStream) + Seek Seconds.
- Play builds playback.m3u8, plays via HlsPlayer, and polls
  /api/troubleshoot/playback/status every 2s; on running->completed/failed it
  stops polling, shows the log tail + a colored speed badge, and a completion
  notice. Polls once on mount to reflect a session running elsewhere.
- Entry points: enable the Channels table Troubleshoot action and add a
  Troubleshoot Playback button to the movie detail page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- blazor-route-parity.md: move /system/troubleshooting/playback from Section 3
  (BLAZOR-ONLY) to Section 2 (SPA-ready); Section 3 now has no blocking rows.
- decisions.md: poll-based completion feedback (no SPA push channel) and the
  datetime-local vs Chronic deviation.
- spa-conventions.md: new HlsPlayer reusable-component + test-mocking note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review SHOULD-FIX (#145): the SPA graphics picker could go stale because
Blazor ran RefreshGraphicsElements (disk->DB sync) before listing, while the
API endpoint never refreshed — a newly added .yml would not appear. GET
/api/graphics-elements?refresh=true now sends RefreshGraphicsElements before
the list query; default false leaves existing callers untouched. The playback
troubleshooting screen passes refresh=true. Controller tests cover
refresh-iff-true ordering; regenerated OpenAPI v1.json (endpoint index and
generated TS schemas unchanged - query params are not part of either).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review MUST-FIX + SHOULD-FIX (#145):

- Repeat Play with unchanged settings was fully broken: the manifest GET is
  what starts a session server-side, but an identical URL made setPlayerSrc a
  state no-op, so HlsPlayer never re-attached and no request was issued —
  while the poll saw the previous session's stored result and toasted a
  spurious success over a dead player. HlsPlayer now takes a playToken prop
  (incremented per Play) that forces teardown/re-attach even for identical src.

- First-poll race: PrepareTroubleshootingPlayback runs DB queries BEFORE
  taking the playback lock, so polls right after Play can still see the stale
  settled result. Replaced the manually-seeded 'running' assumption with a
  play-phase machine (idle -> starting -> running -> idle): settled statuses
  are ignored during 'starting' until the server has reported running at
  least once, with a 30s timeout that surfaces an error and stops polling.
  Play + downloads are gated and a Starting/Transcoding indicator shows for
  the whole busy window.

Nits: mediaItem takes precedence over channel when both query params are
present (matching Blazor); on-mount poll comment notes the intentional
post-restart log recovery. Tests: repeat-play re-issues loadSource, stale
settled ignored until running observed, starting timeout, download gating
while busy, both-params precedence (web suite 378 -> 384).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- decisions.md: Download Media Sample is gated while a session is active in
  the SPA (Blazor only gated Download Results) — intentional deviation.
- blazor-route-parity.md: playback row notes entry points are the channels
  table + movie detail page only for now (other media kinds need a hand-built
  ?mediaItem= URL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(web): channel-mode Play sent ffmpegProfile=0 (runtime/spec key drift, #198)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m16s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
63983244ab
Live E2E caught what unit tests could not: the runtime JSON from GET
/api/channels/{id} is Newtonsoft-cased ('ffmpegProfileId'), while the
generated OpenAPI types say 'fFmpegProfileId' (STJ naming) — so the
channel-mode default-profile read was undefined, the form held null, and
buildPlaybackUrl fell back to ffmpegProfile=0. The unit-test channel mock was
typed from the same wrong generated key, so mocks conformed to the spec, not
the server.

- Read the runtime key first via a typed escape hatch, fall back to the spec
  key, then to the first profile — never 0 (see #198 for the root spec-
  generation fix; ChannelEditScreen's same latent bug is #198's scope).
- buildPlaybackUrl now refuses to build a URL with a null profile, and Play
  is additionally gated on ffmpegProfileId != null.
- streamSelectorMode/streamSelector verified against live runtime JSON: no
  leading acronym, keys match the generated types — noted in a comment.
- Channel mock now pins the RUNTIME shape (ffmpegProfileId), and a new
  channel-mode Play test asserts ffmpegProfile=2 + channel=7 + ISO start in
  the URL (web suite 384 -> 385).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timothy merged commit af17dafcab into main 2026-07-09 08:21:04 +02:00
Sign in to join this conversation.