ChicoryTV SPA: Blazor → SPA cutover (retire old UI) #91
Notifications
Due Date
No due date set.
Depends on
#83 ChicoryTV SPA: Dashboard screen
timothy/ersatztv
#84 ChicoryTV SPA: Channels screen
timothy/ersatztv
#85 ChicoryTV SPA: Guide / EPG screen
timothy/ersatztv
#86 ChicoryTV SPA: Schedule editor screen
timothy/ersatztv
#87 ChicoryTV SPA: Playouts screen
timothy/ersatztv
#88 ChicoryTV SPA: Libraries screen
timothy/ersatztv
#89 ChicoryTV SPA: Channel Builder (primary deliverable)
timothy/ersatztv
Reference: timothy/ersatztv#91
Reference in New Issue
Block a user
Complete the transition from the Blazor/MudBlazor UI to the SPA — the "fully wired replacement" milestone.
Do
CLAUDE.md,docs/) and Docker/host config as needed (infra changes → server-management).Acceptance: old Blazor admin UI fully replaced by the ChicoryTV SPA; no user-facing MudBlazor screens remain.
Blocked by all screens (Dashboard, Channels, EPG, Schedule, Playouts, Libraries, Channel Builder).
Starting work (branch
feat/91-cutoveroff mainb564545f, post-v26.4.0). Plan: recon first, then staged into two PRs — (a) root-route flip to the SPA + 301 redirects for legacy Blazor routes, (b) full Blazor/MudBlazor removal. Recon will decide whether any Blazor-only functionality needs a legacy escape hatch or a blocking sub-issue.Recon complete — staging decision.
The SPA covers 8 screen areas, but the Blazor UI still has ~55 routes with no SPA equivalent: collections editing (
/app/collectionsis a placeholder), media browsing/detail/search/trash, Trakt lists, filler presets, watermarks, the full FFmpeg profile editor, blocks/decos/templates/deco-templates, playout detail editors, logs, and troubleshooting. Removing Blazor today would delete those features.Plan:
/302→/app; legacy routes with SPA equivalents (channels/schedules/playouts/libraries/settings/*) 302 to their SPA counterparts; Blazor-only routes stay served by Blazor (/system/healthremains the Blazor home escape hatch). 302 not 301 — the map evolves while parity lands, and permanent-redirect caching would make rollback painful. The SPA keeps its/appmount (Vitebase: '/app/'hardcodes asset URLs — serving it at root would 404 all assets).Docker smoke test hits
/and tolerates a redirect; PR (a) also adds an explicit/appsmoke assertion.Phase (a) merged — PR #148, main
d04769cc. The ChicoryTV SPA is now the default UI:/and 12 legacy routes with SPA equivalents 302 to/app/...(query strings + ETV_BASE_URL preserved; 302 deliberately, not 301). Blazor keeps serving the un-migrated screens; Blazor home =/system/health.What was done:
ErsatzTV/LegacyUiRedirects.cs(exact-match redirect map — the single source of truth for what has migrated) + middleware in Startup's Blazor branch before UseRouting; 32 new tests (ErsatzTV.Tests now 527); docker smoke now asserts/app/serves the SPA. Verified live against a fresh server (redirects, Blazor-only routes, /iptv, /api, /docs, SPA client-route fallback), reviewed by 3 lenses + a verification fork (found+fixed: PathBase must be re-applied to redirect targets since UsePathBase never rewrites Location headers).Remaining for phase (b) (Blazor/MudBlazor deletion, closes this issue): blocked on SPA parity #140–#146 (+#147 escape-hatch link). Known gaps flagged during review: OIDC installs now land on the open SPA instead of a challenged Blazor page (no new exposure — GET APIs and /app were already unauthenticated — but SPA auth is a phase-(b) design item); the SPA Channels edit pencil is a dead end (noted on #146); PlayoutsScreen can't reach playout editors (noted on #144).
Docs updated inline: CLAUDE.md architecture/conventions; homelab-docs Docker/ErsatzTV.md architecture section.
Phase (b) readiness check (2026-07-07, post-#180/#182/#183)
Full route reconciliation now lives in
docs/blazor-route-parity.md(landing on main with this session's docs commit; keep it updated per PR). Summary of the 124 Blazor@pageroutes:LegacyUiRedirects.cshas no entry yet./system/troubleshooting/playback→ #145 (last item there; HLS player + session logs; L, own session)./media/playlists/{id}) + collection items listing → #153/#155 (S3's PlaylistController is GET-only list/groups; SPA collections screen must reach BlazorPlaylistEditor/CollectionItemsdepth before these routes can die — verify SPA editing depth as part of #153/#155, don't assume)./system/health(Blazor home) needs no successor: the SPA dashboard's health panel covers it, and the Settings→System "Classic UI" link gets removed with Blazor.Removal plan (execute when the 4 gates close)
Step 1 — redirects sweep [S]: add every SPA-READY route to
LegacyUiRedirects.cs(including id-carrying detail/editor routes → their/app/...equivalents). Ship separately from deletion; lets stragglers surface as harmless 302s while Blazor still exists as fallback.Step 2 — Blazor deletion [M-L, one session]: remove
ErsatzTV/Pages/,ErsatzTV/Shared/, Blazor-onlyErsatzTV/ViewModels/(e.g.ImageTreeItemViewModel),_Host.cshtml/App.razor/MainLayout.razor; strip Blazor Server DI + endpoints from startup (AddServerSideBlazor,MapBlazorHub, circuits); drop MudBlazor + Blazor-only static assets (jQuery/jQueryUI/Sortable) and prune theirDirectory.Packages.propsentries. No SignalR hubs outside Blazor (verified). KEEP: all MVC/API controllers (ArtworkControlleretc. serve the SPA), all Application VMs (handlers own them; pruning Blazor-only VMs is a later cleanup).Step 3 — verification + docs [S]: full gates (build, all test projects incl. Architecture, web test/lint/build) + a live-E2E redirect sweep (every legacy route 302s correctly, /app/* unaffected, /iptv/* + /artwork/* untouched) + Docker image build check. Update CLAUDE.md architecture section,
docs/blazor-route-parity.md(all rows → redirected),docs/contributing.mdBlazor/MudBlazor sections, Obsidian ErsatzTV doc.Suggested order: #185 docs part 2 + quick wins → #155 (smallest, unblocks collections depth) → #151/#152 (mirror each other; one session) → #153 → #145 playback (own session) → Step 1 → Step 2+3. Step 1 (redirect sweep for already-covered routes) could also ship earlier at any point — it's independent of the gates.
Gate added for phase (b) — cold adversarial review required BEFORE the Blazor-removal PR.
Every parity claim to date (docs/blazor-route-parity.md, the per-screen E2E passes) was produced inside the same session workflow that built the SPA — shared-context reviews can inherit shared blind spots. Since deleting Blazor removes both the reference implementation and the escape hatch, phase (b) must start with a zero-shared-context review:
~/adversarial-reviewer, run/reviewin pre-implementation plan review mode against the phase (b) removal plan.web/. Verify independently that nothing of value dies: walkErsatzTV/Pages/**/*.razorand confirm every user-facing capability has a working SPA equivalent (docs/blazor-route-parity.md is the claim — verify it, don't trust it), check for Blazor-only behaviors not visible as routes (JS interop, SignalR usage, background triggers fired from pages), and review the removal plan for blast radius/rollback (can we restore Blazor from a tag if a gap surfaces post-deletion?)."/dispatch(review label); phase (b) implementation starts only after MUST-FIX findings are resolved or explicitly waived.Do not close this issue without this review having happened.
Cold adversarial review gate: RUN — verdict GATE: BLOCKED (3 must-fix).
The review ran 2026-07-09 from the external
~/adversarial-reviewerproject as a fresh zero-shared-context session (headless, given only the brief from the gate comment above + factual pointers). Findings filed via/dispatchasreview-labeled issues:MediaSourcesControlleris GET-only; SPA "Add Source" is a hardcoded disabled stub). Deleting Blazor would make a fresh install unable to connect a library. Verified in code (App.tsx:2557, single[HttpGet]in MediaSourcesController).docs/blazor-route-parity.md's "SPA-READY" conflated screen-exists with functional parity; every Section-2 row needs mutation-depth verification. A systematic per-cluster verification sweep is running now; results + any promoted sub-issues will be posted on #203.LegacyUiRedirectsis exact-match only; the Step-1 promise to redirect id-carrying routes is infeasible as written. Verified (dictionaryTryGetValue). Resolution = pattern/prefix redirect support in the removal PR; strategy to be posted on #204.blazor-finalrollback tag immediately before the removal merge + document restore.What the cold review confirmed safe: no SignalR outside
MapBlazorHub, JS interop is cosmetic-only, SmartCollection editing has real parity.Phase (b) implementation stays blocked until #202/#203/#204 are resolved. #202 is the new feature gate (peer of #151/#152/#153) and gets its own session.
Phase (b) gate — consolidated status after resolving #203 (mutation-depth sweep complete).
The definitive blocker list for Blazor removal is now:
MUST-FIX feature gates (build before removal):
SHOULD-FIX before/with removal: #212 (channel editor), #213 (nits batch + decision-log entries).
Resolved from the cold review: #203 closed (sweep done,
docs/blazor-route-parity.mdcorrected — verdict table embedded, schedules + media-sources rows moved to Section 3); #204 has an agreed pattern-redirect + catch-all strategy (lands in the removal PR); #205/#206 planned into the removal PR routine.Removal itself (already reconned: Courier is Blazor-only-consumed; 6 packages removable; the Startup
MapWhenbranch co-hosts the REST API and needs surgical—not wholesale—removal) starts only after 1–5 close.claiming — session started 2026-07-11 (evening/late).
Working the last SPA pre-work gate for the #91b removal PR (per
blazor-route-parity.md §5): wiring the already-shippedscanLibrary(id, deep)+scanCollections(family, id, deep)clients intoLibrariesScreenas Deep Scan Library and External Collections (quick + deep) affordances, matchingLibraries.razor's four scan actions. This is a pure-SPA change — the F9 API + clients already landed (#235), and the External Collections rows derive faithfully from the existinggetMediaSources()(the API handler already filters each source'slibrariesto sync-enabled, so a remote source appears ifflibraries.length > 0— exactlyGetExternalCollections'sLibraries.Any(ShouldSyncItems)filter). No backend/OpenAPI change.The removal PR proper (rollback tag → surgical Blazor delete + catch-all fallback → auth cleanup → verify, with the mandatory cold adversarial pass + Fable go/no-go) remains a separate focused follow-up; this session only lands the capability so
Libraries.razorcan be deleted at parity.Progress (SPA pre-work landed — #91 stays open for the removal PR proper).
PR #272 merged to main (
bb7b1899, fast-forward):LibrariesScreennow reaches parity withLibraries.razor's four scan actions — Deep Scan Library on remote library rows + an External Collections section (quick + deep per remote source). This closes the last SPA pre-work gate for the Blazor removal; perblazor-route-parity.md §5the removal PR's only remaining pre-work is now the mandatory cold adversarial pass (the #204 catch-all fallback lands in the removal PR itself). No API gate remains (F9 closed via #235).Verification: CI green; adversarial review fork = MERGEABLE (no Blocker/High/Medium); live headless E2E = PASS against a real DB/handler, including a negative control (a
ShouldSyncItems=0library correctly excluded from both the library rows and the External Collections derivation).Approach note: External Collections rows derive client-side from
getMediaSources()(no new endpoint) — the media-sources API handler already filters each source'slibrariesto sync-enabled entries, exactly matchingGetExternalCollections'sLibraries.Any(ShouldSyncItems). Collections scans have no status/progress REST surface, so their button state is optimistic + timeout-bounded — follow-up #271 filed for a proper collections status endpoint.Docs updated in-PR:
blazor-route-parity.md §5,decisions.md.claiming — session started 2026-07-11 (late evening). Picking up arc item 4: the #91 phase (b) Blazor-removal PR. All gates cleared per the tracker (#237): #202 media-source write API+SPA DONE, #235 F9 deep-scan/external-collections API DONE, SPA deep-scan/collections buttons DONE (PR #272). Remaining pre-work per
blazor-route-parity.md §5: mandatory cold adversarial pass + Fable go/no-go, then the 4-step removal runbook (rollback tagblazor-final→ surgical Blazor delete + #204 catch-all fallback → auth-attached-OIDC cleanup → verify+docs).Confirmed no active session is mid-flight on a Blazor page: the live parallel worktrees (#253 PR2 #268, PR3) touch versioned aggregate handlers + SPA editors, not
ErsatzTV/Pages/**or theStartup.csBlazorMapWhenbranch. Will keep the deletion diff surgical and preserve the co-hostedMapControllers()//docs/redirect-middleware.Done — legacy Blazor Server UI removed (PR #274, merged FF to main
78262ea4)The ChicoryTV React SPA (
web/, served at/app) is now the only UI. This closes #91 phase (b) — the milestone-capping removal.What was done (runbook:
docs/blazor-route-parity.md §5, executed in order):blazor-finalcut on the pre-deletionmaincommitbb310f6b(annotated; not av*tag → no prod release build). Restore =git checkout blazor-final(test image) orgit revertthe merge.ErsatzTV/Pages/**,Shared/**,ViewModels/**(39),Validators/**(10),App.razor,_Imports.razor,Locals/{Shared,Pages}/**(Blazor loc resx;Locals/Resources.*kept),wwwroot/css+wwwroot/lib,libman.json, an orphanedNavigationManagerExtensions.cs,MultiSelectBaseTests.cs.Startup.csreduced: droppedAddRazorPages/AddServerSideBlazor/AddMudServices/AddSortable/AddCourier/IHtmlSanitizer, the Blazor-attached OIDC middleware,MapBlazorHub,MapFallbackToPage("/_Host"); renamed the branchblazor→legacy; added the #204 catch-all (MapFallback→302/app, excluding/api|/artwork|/docs|/openapi→404). Pruned 9 now-dead packages + the dead #25 razor-Sonar NoWarn. Added the 14/media/sources/*→/app/libraries/*redirects + lifted the #204 prefix ban.ConditionalIptvAuthorizeFilter(/iptv),ApiKeyAuthorizationFilter(mutating/api) all kept. Real SPA/API auth remains #197.dotnet test ErsatzTV.slngreen on the merged tree (ErsatzTV.Tests 1404, Core 493, Scanner 1467, Infra 53, Arch 5, FFmpeg 20; 0 failed); PR CI green (Build & test, EF migration integrity, Docs reminder, image build). Docs updated in-PR:blazor-route-parity.md(phase b COMPLETE),decisions.md,CLAUDE.md,contributing.md,README.md.Verification gates cleared: 2 independent adversarial reviews (cold fork + Codex, both MERGEABLE-WITH-NITS, no disagreement — all nits fixed), a live-E2E redirect sweep (20/20: legacy exact/pattern/media-sources routes 302 correctly, catch-all→/app,
/api404 //artwork400 never redirected, no 500s), and a Fable GO go/no-go.Deferred / residual (accepted, non-blocking): non-GET requests to retired routes 302→
/apprather than 404/405 (same class as the old_Hostfallback; mutating/apistill 404s); bookmarked/system/healthlands on/app(SPA sidebar health dot replaces it); real SPA/API auth stays open until #197. ~30 historical "matches Blazor's behavior" rationale comments left as accurate documentation (only references to deleted code were cleaned).Milestone "Blazor removal (#91 phase b)" is now complete. Root
/and every legacy route reach the SPA (redirect map + catch-all).