# Blazor → SPA route parity tracker
> **STATUS: #91 phase (b) is COMPLETE (this removal PR).** The legacy Blazor Server UI has been
> **removed** — `ErsatzTV/Pages/**`, `Shared/**`, `App.razor`, the Blazor ViewModels/Validators, and
> the MudBlazor/Blazor packages are all gone (see `docs/decisions.md`, 2026-07-11 "Blazor Server UI
> removed"). Every legacy route now resolves one of two ways: an explicit 302 to its SPA equivalent via
> `ErsatzTV/LegacyUiRedirects.cs`, or the Startup **catch-all fallback** (any unmatched path that isn't
> under `/api`/`/artwork`/`/docs`/`/openapi` → 302 `/app`; those four prefixes 404). The rollback tag is
> `blazor-final` (see `docs/decisions.md`). This doc is now the **historical record** of the cutover and
> the full legacy→SPA route inventory, not a live "what's left" tracker.
Purpose (historical): the route-by-route tracker for ersatztv#91 phase (b) — retiring the legacy Blazor
Server UI once every route it served had a ChicoryTV SPA equivalent and a redirect.
Sources of truth checked when compiling this table: `ErsatzTV/LegacyUiRedirects.cs` (redirect map) and
`web/src/App.tsx` (SPA route table). (`ErsatzTV/Pages/**/*.razor` no longer exists — deleted in this PR.)
## How to read this
- **REDIRECTED**: in `LegacyUiRedirects.cs`'s `Map` — the Blazor route 302s to the SPA route.
- **SPA-READY (not yet redirected)**: the SPA screen exists and covers the functionality, but the
Blazor route is still reachable directly (no redirect entry yet) — adding the redirect is a small,
low-risk PR once the SPA screen has been spot-checked against the Blazor page it replaces.
- **BLAZOR-ONLY**: no SPA equivalent yet; removing/redirecting this route is blocked on the listed
issue(s).
## Section 1 — REDIRECTED
`ErsatzTV/LegacyUiRedirects.cs` now matches in two tiers (ersatztv#204): the exact-path `Map`
(**52 entries** — the 13 foundational routes below plus the #204 (A)/(B)/(E-base) additions) and an
ordered list of **36 `PatternRule` segment-template rules** ((C)/(C2)/(D)/(E-page)) for the
parameterized routes. Together they cover every SPA-ready route (all rows formerly in Section 2).
**Foundational 13** (unchanged since ersatztv#91 phase (a)/PR #148):
| Blazor route | SPA route |
|---|---|
| `/` | `/app` |
| `/channels` | `/app/channels` |
| `/channels/add` | `/app/new-channel` |
| `/schedules` | `/app/schedules` |
| `/playouts` | `/app/playouts` |
| `/media/libraries` | `/app/libraries` |
| `/settings/ffmpeg` | `/app/settings/streaming` |
| `/settings/hdhr` | `/app/settings/system` |
| `/settings/logging` | `/app/settings/logging` |
| `/settings/playout` | `/app/settings/playout` |
| `/settings/scanner` | `/app/settings/scanner` |
| `/settings/ui` | `/app/settings/general` |
| `/settings/xmltv` | `/app/settings/xmltv` |
Note: **the settings sub-routes above are already redirected** — `LegacyUiRedirects.cs` covers all
seven `/settings/*` pages that have SPA equivalents. There is no Blazor-only settings sub-route left except the ones with no SPA screen at
all (there are none currently — every Blazor `Settings/*.razor` page has both an SPA screen and a
redirect).
**#204 route migrations** (moved here from Section 2 when their redirects landed). Parameterized
routes: `{id}` = strict positive integer (non-int/`0`/negative/overflow falls through to Blazor);
`/media/*/page/{n}` paging collapses to the SPA default page (the `page` param is merged into the
target query and harmlessly ignored). The incoming query string is merged into `?kind=` targets via
`AppendQueryString` (`&`-join). See `docs/decisions.md` (2026-07-11, #204) and §5 of the design.
| Blazor route | Blazor file | SPA route | Notes |
|---|---|---|---|
| `/channels/{Id:int?}` | `ChannelEditor.razor` | `/app/edit-channel/{id}` | allowSubPaths; external logo URL field (mutual exclusion with upload), enumerated language/credits-template/stream-selector pickers, and bare-channel create (`ChannelsScreen`'s "New blank channel") landed #212 |
| `/channels/numbers` | `ChannelNumbers.razor` | `/app/channels` | merged into channels table |
| `/search` | `Search.razor` | `/app/search` | |
| `/system/logs` | `Logs.razor` | `/app/logs` | |
| `/system/troubleshooting` | `Troubleshooting/Troubleshooting.razor` | `/app/troubleshooting` | |
| `/system/troubleshooting/block-playout` | `Troubleshooting/BlockPlayoutTroubleshooting.razor` (+`BlockPlayoutHistory.razor`) | `/app/troubleshooting/blocks` | **covered by PR #182 / #145** |
| `/system/troubleshooting/sequential-schedule` | `Troubleshooting/YamlValidator.razor` | `/app/troubleshooting/yaml` | **covered by PR #182 / #145** |
| `/system/troubleshooting/playback` | `Troubleshooting/PlaybackTroubleshooting.razor` | `/app/troubleshooting/playback` | **covered by #145** — no nav entry; entry points are the Channels table Troubleshoot action (`?channel={id}`) the movie detail page, and per-episode Troubleshoot actions on season detail pages (`?mediaItem={id}`, #209) — remaining kinds (music videos, songs, …) still need a hand-built `?mediaItem={id}` URL |
| `/blocks`, `/blocks/{Id:int}` | `Blocks.razor`, `BlockEditor.razor` | `/app/blocks`(`/{id}`) | allowSubPaths; #144 S1; list search/filter (#213) added 2026-07-11 |
| `/templates`, `/templates/{Id:int}` | `Templates.razor`, `TemplateEditor.razor` | `/app/templates`(`/{id}`) | allowSubPaths; #144 S2; list search/filter (#213) added 2026-07-11 |
| `/decos`, `/decos/{Id:int}` | `Decos.razor`, `DecoEditor.razor` | `/app/decos`(`/{id}`) | allowSubPaths; #144 S3 |
| `/deco-templates`, `/deco-templates/{Id:int}` | `DecoTemplates.razor`, `DecoTemplateEditor.razor` | `/app/deco-templates`(`/{id}`) | allowSubPaths; #144 S4 |
| `/playouts/add`(`/{kind}`) | `PlayoutEditor.razor` variants | `/app/playouts` | merged into playouts screen creation flow; #144 S5 |
| `/playouts/classic/{Id}`, `/playouts/block/{Id}`, `/playouts/scripted/{Id}`, `/playouts/sequential/{Id}` | `ClassicPlayoutEditor.razor`, `BlockPlayoutEditor.razor`, `ScriptedPlayoutEditor.razor`, `SequentialPlayoutEditor.razor` | `/app/playouts` | merged; #144 S5/S6 |
| `/playouts/{Id:int}/alternate-schedules` | `PlayoutAlternateSchedulesEditor.razor` | `/app/playouts/{id}/alternate-schedules` | allowSubPaths (`PlayoutsRouteScreen`); #144 S6/#162 |
| `/playouts/{Id:int}/templates` | `PlayoutTemplatesEditor.razor` | `/app/playouts/{id}/templates` | allowSubPaths (`PlayoutsRouteScreen`); #144 S6/#162 |
| `/schedules/add`, `/schedules/{Id:int}`, `/schedules/{Id:int}/items` | `ScheduleEditor.razor`, `ScheduleItemsEditor.razor` | `/app/schedules` | #207; id-dropping (single SPA screen) |
| `/media/multi-collections`(`/add`, `/{Id}/edit`) | `MultiCollections.razor`, `MultiCollectionEditor.razor` | `/app/multi-collections` | **SPA DONE** (`MultiCollectionsScreen`; in-screen list↔editor via local state, no sub-paths); #151 |
| `/media/rerun-collections`(`/add`, `/{Id}/edit`) | `RerunCollections.razor`, `RerunCollectionEditor.razor` | `/app/rerun-collections` | **SPA DONE** (`RerunCollectionsScreen`; in-screen list↔editor via local state, no sub-paths); #152 |
| `/media/filler/presets`(`/add`, `/{Id}/edit`) | `FillerPresets.razor`, `FillerPresetEditor.razor` | `/app/filler-presets` | allowSubPaths |
| `/media/collections`(`/add`, `/{Id}/edit`, `/{Id}`) | `ManualCollections.razor`, `CollectionEditor.razor`, `CollectionItems.razor` + `SmartCollections.razor`/`SmartCollectionEditor.razor` | `/app/collections` | allowSubPaths |
| `/media/trash` | `Trash.razor` | `/app/trash` | |
| `/media/playlists`(`/{Id}`) | `Playlists.razor`, `PlaylistEditor.razor` | `/app/playlists` | SPA DONE (#153): group tree + playlist item editor + playout preview (`PlaylistsScreen`). Note: the `/{Id}` playlist-editor sub-path is NOT redirected (SPA is in-screen, no sub-path); only `/media/playlists` redirects |
| `/media/trakt/lists`(`/{Id}`) | `TraktLists.razor`, `TraktListEditor.razor` | `/app/trakt-lists`(`/{id}`) | allowSubPaths |
| `/ffmpeg`(`/add`, `/{Id}`) | `FFmpeg.razor`, `FFmpegEditor.razor` | `/app/ffmpeg-profiles` | allowSubPaths |
| `/watermarks`(`/add`, `/{Id}`) | `Watermarks.razor`, `WatermarkEditor.razor` | `/app/watermarks` | allowSubPaths |
| `/media/movies`(`/page/{n}`) | `MovieList.razor` | `/app/media?kind=movies` | generic browse (`MediaBrowseScreen`); PR #183 / #141 |
| `/media/movies/{MovieId:int}` | `Movie.razor` | `/app/media/movies/{id}` | detail page (`MovieDetailScreen`); PR #183 / #141 |
| `/media/tv/shows`(`/page/{n}`) | `TelevisionShowList.razor` | `/app/media?kind=shows` | generic browse; PR #183 / #141 |
| `/media/tv/shows/{ShowId:int}` | `TelevisionSeasonList.razor` | `/app/media/shows/{id}` | show + season list (`ShowDetailScreen`); PR #183 / #141 |
| `/media/tv/seasons`(`/page/{n}`) | `TelevisionSeasonSearchResults.razor` | `/app/media?kind=seasons` | seasons browsable as a top-level kind (`MediaBrowseScreen`; also reachable via show drill-in); #209 review fix |
| `/media/tv/seasons/{SeasonId:int}` | `TelevisionEpisodeList.razor` | `/app/media/seasons/{id}` | season + episode list (`SeasonDetailScreen`); PR #183 / #141 |
| `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media?kind=episodes` | standalone SPA episode browse EXISTS (`MediaBrowseScreen`, generic grid, top-level `episodes` kind); episode cards there and on the Search screen now navigate to the season detail page and anchor/highlight the episode (`/app/media/seasons/{seasonId}#episode-{id}`), matching `Search.razor:241`'s `media/tv/seasons/{SeasonId}#episode-{EpisodeId}` link (`LibraryBrowseItemResponseModel.SeasonId`, `mediaDetailPath`); #220. Note: only the `page` browse redirects; individual episode detail has no dedicated SPA route |
| `/media/music/artists`(`/page/{n}`) | `ArtistList.razor` | `/app/media?kind=artists` | generic browse; PR #183 / #141 |
| `/media/music/artists/{ArtistId:int}` | `Artist.razor` | `/app/media/artists/{id}` | detail page (`ArtistDetailScreen`); PR #183 / #141 |
| `/media/music/videos`(`/page/{n}`) | `MusicVideoList.razor` | `/app/media?kind=music-videos` | generic browse; PR #183 / #141 |
| `/media/music/songs`(`/page/{n}`) | `SongList.razor` | `/app/media?kind=songs` | no dedicated SPA song browse beyond generic grid; PR #183 / #141 |
| `/media/other/videos`(`/page/{n}`) | `OtherVideoList.razor` | `/app/media?kind=other-videos` | generic browse; PR #183 / #141 |
| `/media/remote/streams`(`/page/{n}`) | `RemoteStreamList.razor` | `/app/media?kind=remote-streams` | generic browse; PR #183 / #141 |
| `/media/images`(`/page/{n}`) | `ImageList.razor` | `/app/media?kind=images` | generic browse; PR #183 / #141 |
| `/media/browser/images` | `ImageBrowser.razor` | `/app/media/images/browser` | interactive image grid picker used by channel editors etc. (`ImageBrowserScreen`); PR #183 / #141 |
## Section 2 — (was: SPA-READY, not yet redirected) — now EMPTY, all redirected
**This section is now empty.** The 14 `/media/sources/*` routes that were the last SPA-ready-but-not-yet-
redirected entries received their redirect entries in **this #91b removal PR** (7 Tier-1 exact `Map`
entries + 7 Tier-2 `{id}` `PatternRule` rules → their `/app/libraries/*` SPA screens), and the #204-era
`/media/sources` forbidden-prefix guard was lifted. Their Blazor → SPA mappings are retained below for the
inventory record; all 14 now 302-redirect (like every row in Section 1):
| Blazor route | SPA route |
|---|---|
| `/media/sources/local` | `/app/libraries` |
| `/media/sources/local/add` | `/app/libraries/local/new` |
| `/media/sources/local/{id}/edit` | `/app/libraries/local/{id}` |
| `/media/sources/plex` | `/app/libraries/plex` |
| `/media/sources/plex/{id}/libraries` | `/app/libraries/plex/{id}/sync` |
| `/media/sources/plex/{id}/paths` | `/app/libraries/plex/{id}/path-replacements` |
| `/media/sources/jellyfin` | `/app/libraries/jellyfin` |
| `/media/sources/jellyfin/edit` | `/app/libraries/jellyfin/connection` |
| `/media/sources/jellyfin/{id}/libraries` | `/app/libraries/jellyfin/{id}/sync` |
| `/media/sources/jellyfin/{id}/paths` | `/app/libraries/jellyfin/{id}/path-replacements` |
| `/media/sources/emby` | `/app/libraries/emby` |
| `/media/sources/emby/edit` | `/app/libraries/emby/connection` |
| `/media/sources/emby/{id}/libraries` | `/app/libraries/emby/{id}/sync` |
| `/media/sources/emby/{id}/paths` | `/app/libraries/emby/{id}/path-replacements` |
The mutation-depth verification and per-route capability notes that justified marking these routes
SPA-ready are preserved below for the record.
> **Mutation-depth verification (2026-07-09, #203 sweep)**: "SPA-READY" previously meant only
> "screen exists". A cold adversarial review (#91 gate, findings #202–#206) proved that
> insufficient, so every row below was re-verified capability-by-capability (Blazor page
> mutations vs SPA screen + REST endpoint; accepted deviations in `docs/decisions.md` excluded).
> Verdicts:
>
> | Cluster | Verdict | Gap issue |
> |---|---|---|
> | Troubleshooting (4 routes) | PARITY-OK (block-history page-size persistence + Id>=0 History gating added 2026-07-11) | — |
> | Blocks/Templates/Decos/Deco-templates | PARITY-OK (block-copy UI added 2026-07-09; blocks/templates list search/filter added 2026-07-11) | — |
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
> | Playout creation + alternate-schedules | PARITY-OK | — |
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215; per-playout **Reshuffle** action added alongside Reset/Erase/Delete — `POST /api/v1/playouts/{id}/reshuffle` reseeds + rebuilds via `PlayoutBuildMode.Reset`, 2026-07-16 #71) | — |
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — |
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
> | Channel editor | PARITY-OK (external logo URL mutual-exclusion, bare-create defaults, enumerated pickers — 2026-07-11) | #212 |
> | Channels-numbers / Logs | PARITY-OK (logs sort + page-size persistence added, 2026-07-11) | — |
> | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — |
> | Media browse/detail (read paths + image browser) | PARITY-OK | — |
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
> | Schedules editors | PARITY-OK (full item + schedule CRUD rebuild, draft/explicit-Save, all Blazor fields/gates/resets; 2026-07-11) | **#207 DONE** |
> | Media sources | **PARITY-OK** (#202 DONE 2026-07-11 — moved back from Section 3; new SPA screens under `/app/libraries/*` over a new REST write API) | — |
>
> Bold issues are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11; #202 DONE 2026-07-11.
As of ersatztv#204, every route the mutation-depth sweep marked PARITY-OK received a redirect entry
(an exact `Map` entry or a Tier-2 `PatternRule`) and moved to **Section 1** — EXCEPT the 14
`/media/sources/*` routes. Those became **SPA-READY as of #202 (DONE 2026-07-11)**: new screens under
`/app/libraries/*` (`LibrariesScreen` hub + `LocalLibraryEditScreen`, `PlexSourceScreen`,
`RemoteSourceScreen`, `RemoteConnectionEditScreen`, `RemoteLibrariesEditScreen`,
`PathReplacementsEditScreen`) over a new REST write API. Their redirect entries were **deferred to the
#91b removal PR** (matching the pre-removal spot-check convention) and **landed there** — so this
section is now empty (mappings retained at the top of this section for the record).
| Blazor route | Blazor file | SPA route | Notes |
|---|---|---|---|
| `/media/sources/local` | `LocalLibraries.razor` | `/app/libraries` | local-library rows on the hub (list, scan, gear → editor); **#202** |
| `/media/sources/local/add` | `LocalLibraryEditor.razor` (add mode) | `/app/libraries/local/new` | `LocalLibraryEditScreen` create mode: name/media-kind/paths (`path-exists` pre-check); **#202** |
| `/media/sources/local/{Id:int}/edit` | `LocalLibraryEditor.razor` (edit mode) | `/app/libraries/local/{id}` | `LocalLibraryEditScreen` edit mode: name + path add/delete (count-confirm)/move (incl. "(New Library)"), media-kind immutable; **#202** |
| `/media/sources/plex` | `PlexMediaSources.razor` | `/app/libraries/plex` | `PlexSourceScreen`: OAuth pin-flow sign-in/fix-credentials/sign-out, server table, per-server Refresh; **#202** |
| `/media/sources/plex/{Id:int}/libraries` | `PlexLibrariesEditor.razor` | `/app/libraries/plex/{id}/sync` | `RemoteLibrariesEditScreen` (family=plex): sortable Name/MediaKind, per-library sync toggle + Save; **#202** |
| `/media/sources/plex/{Id:int}/paths` | `PlexPathReplacementsEditor.razor` | `/app/libraries/plex/{id}/path-replacements` | `PathReplacementsEditScreen` (family=plex); **#202** |
| `/media/sources/jellyfin` | `JellyfinMediaSources.razor` | `/app/libraries/jellyfin` | `RemoteSourceScreen` (family=jellyfin): connect/edit-connection/disconnect + server table; **#202** |
| `/media/sources/jellyfin/edit` | `JellyfinMediaSourceEditor.razor` | `/app/libraries/jellyfin/connection` | `RemoteConnectionEditScreen` (family=jellyfin): address prefilled, api-key set-affordance (never displayed once set); **#202** |
| `/media/sources/jellyfin/{Id:int}/libraries` | `JellyfinLibrariesEditor.razor` | `/app/libraries/jellyfin/{id}/sync` | `RemoteLibrariesEditScreen` (family=jellyfin); **#202** |
| `/media/sources/jellyfin/{Id:int}/paths` | `JellyfinPathReplacementsEditor.razor` | `/app/libraries/jellyfin/{id}/path-replacements` | `PathReplacementsEditScreen` (family=jellyfin); **#202** |
| `/media/sources/emby` | `EmbyMediaSources.razor` | `/app/libraries/emby` | `RemoteSourceScreen` (family=emby); **#202** |
| `/media/sources/emby/edit` | `EmbyMediaSourceEditor.razor` | `/app/libraries/emby/connection` | `RemoteConnectionEditScreen` (family=emby); **#202** |
| `/media/sources/emby/{Id:int}/libraries` | `EmbyLibrariesEditor.razor` | `/app/libraries/emby/{id}/sync` | `RemoteLibrariesEditScreen` (family=emby); **#202** |
| `/media/sources/emby/{Id:int}/paths` | `EmbyPathReplacementsEditor.razor` | `/app/libraries/emby/{id}/path-replacements` | `PathReplacementsEditScreen` (family=emby); **#202** |
## Section 2b — SPA-NATIVE (no Blazor ancestor)
Screens the SPA introduces that never existed in the Blazor UI, so they have no parity row and no
redirect (nothing to redirect *from*). Recorded here so the parity tracker is complete.
| SPA route | Screen | Notes |
|---|---|---|
| `/app/auto-tune` | `AutoTuneScreen` | **Net-new (#69).** Auto-Tune wizard: bulk-generate channels from library metadata (axes: TV Shows / TV Genres / Movie Genres) → preview & select → bulk-create, over the PR1 `POST /api/v1/channels/auto-tune/preview` + `POST /api/v1/channels/auto-tune` endpoints. Additive/non-destructive; no Blazor equivalent. Per-channel DetailPanel deferred to #383. |
| `/app/channels` (Play button) | `ChannelPreviewPanel` | **Net-new (#60).** In-browser channel preview: the channels-list Play button opens a `SlideOver` HLS player driven by the server-declared `Preview` field on `ChannelResponseModel` (`Available` / `ForcedHlsOnly` / `Unavailable`) — not derived client-side. HLS-mode channels preview directly; Transport Stream channels require an explicit opt-in and always show a caveat that the check does not exercise the channel's configured pipeline; JWT-enabled deployments show `Unavailable` (`/iptv/*` doesn't accept the SPA's session cookie). No Blazor predecessor — this is an addition, not a parity item. See `docs/decisions.md` → `api.channel-preview-capability`. |
## Section 3 — BLAZOR-ONLY (blocking issues)
### Playlist variant management — API gap #153 RESOLVED
**#151 (multi-collections), #152 (rerun collections), and #153 (playlists) are DONE** — all three now
have SPA editor screens (`/app/multi-collections`, `/app/rerun-collections`, `/app/playlists`; see
Section 2). The rerun editor offers the REST-supported selection types only (Collection,
MultiCollection, SmartCollection, and the media-item types
TelevisionShow/TelevisionSeason/Artist/Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream);
Playlist is intentionally excluded, matching `RerunCollectionRequestMapping.IsSupportedSelectionType`.
**#153**: the playlist CRUD REST API (`/api/v1/playlists/*` — groups, playlists, item-list replace, and
draft playout preview) plus the `/app/playlists` SPA screen (`PlaylistsScreen`) now mirror
`Playlists.razor` (group tree, add/rename/delete groups, add/delete playlists) and
`PlaylistEditor.razor` (per-item Collection Type over the 12 playlist item types — Collection,
TelevisionShow, TelevisionSeason, Artist, MultiCollection, SmartCollection, Movie, Episode, MusicVideo,
OtherVideo, Song, Image; type-conditional playback order; count; Play All; Show In EPG; reorder/copy/
remove; playout preview). `IsSystem` groups and playlists are read-only in the SPA, matching Blazor.
**#155 RESOLVED** (collection-items enumeration): `GET /api/v1/collections/{id}/items` (paged) now returns a
manual collection's full contents across all media kinds (reusing `LibraryBrowseItemResponseModel`), so the
SPA `/app/collections` items view lists real members instead of the old lossy Lucene `collection:"name"`
search preview. The `POST /api/v1/collections/{id}/items` bogus-id case already returns 422 (guarded by
`AddItemsToCollectionHandler.ValidateMediaItems`), not 500.
### Playback troubleshooting — #145 DONE
Nothing remains here. The playback troubleshooting screen (`/app/troubleshooting/playback`,
`PlaybackTroubleshootingScreen.tsx`) is built and now lives in Section 2.
### Media sources — #202 DONE (2026-07-11)
Nothing remains here. As of the #91 sweep (2026-07-09): the SPA `/app/libraries` screen
listed sources/libraries and could trigger scans, but source add/edit, connection parameters,
path replacements, and library enable/disable had **no SPA UI and no write REST API**
(`MediaSourcesController` was GET-only; "Add Source" was a hardcoded disabled stub) — a fresh
install could not connect any library without Blazor.
**Resolved by #202**: new write controllers (`LocalLibrariesController` under
`/api/v1/libraries/local/*`; `PlexMediaSourcesController`/`JellyfinMediaSourcesController`/
`EmbyMediaSourcesController` under `/api/v1/media-sources/{plex|jellyfin|emby}/*`) wrap the existing
MediatR commands (no new commands, no DB migration), plus new SPA screens
(`LocalLibraryEditScreen`, `PlexSourceScreen`, `RemoteSourceScreen`, `RemoteConnectionEditScreen`,
`RemoteLibrariesEditScreen`, `PathReplacementsEditScreen`) under the now-`allowSubPaths`
`/app/libraries/*` route. All 14 Blazor routes now have SPA equivalents — see the per-route table
in Section 2 (moved there from this section). Fixed along the way (pre-existing bugs #202 chose to
own, not just parity-copy): the Plex pin-flow lock leak on an abandoned OAuth flow, cross-source
overwrite in the path-replacement repo SQL, non-`finally` lock release in sign-out/disconnect
handlers, and the secure `apiKey` contract (never served over GET). Full design rationale in
`docs/decisions.md` (2026-07-11 entries) and the capability matrix posted on issue #202.
### Schedule editors — #207 DONE (2026-07-11)
| Blazor route | Blazor file | SPA route | Status |
|---|---|---|---|
| `/schedules/{Id:int}`, `/schedules/add`, `/schedules/{Id:int}/items` | `ScheduleEditor.razor`, `ScheduleItemsEditor.razor` | `/app/schedules` (`SchedulesScreen`) | **PARITY-OK** |
Was (correctly, until #207) a near read-only viewer — no schedule create/edit/delete, every per-item
inspector control hard-coded disabled (~35 Blazor fields unchangeable), add-item defaults-only.
**Rebuilt in #207** (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`): full schedule
CRUD (create via TopBar/in-screen, edit properties, delete) + full item mutation — every Blazor field,
option list, enable-gate and forced-reset, encoded in the pure `itemRules.ts` (exhaustively unit-tested)
and applied in the inspector. Uses a **draft / explicit-Save** model over the single destructive
`PUT .../items` replace (not the old instant-persist), with Discard + a dirty navigation guard — see
`decisions.md` 2026-07-11 (a)/(b)/(c) and `spa-conventions.md` §8. The capability matrix (issue #207)
maps each Blazor field → SPA control → request key → test.
### Remaining mutation-depth gaps inside Section 2 rows
Tracked as #91 phase (b) gates without moving whole rows — #212 DONE 2026-07-11 (channel editor),
#213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as
`ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on
the Blocks and Templates lists (`fix/213-spa-nits`); logs column sorting + page-size persistence
and trash per-kind "see all" paging (`fix/213-logs-trash`).
CLOSED 2026-07-09: **#210** (playout delete/reset/erase/scheduling-context + preview calendar)
and **#211** (collection custom order + all-kind add picker); the block/watermark copy, trash
select-all, and Trakt-note items of #213 landed in the same PR.
CLOSED 2026-07-10: **#208** (search mutations: card drill-in, per-card + multi-select add-to,
query-wide Add All via `GET /api/v1/search/all-items`, Save As Smart Collection) and **#209**
(media browse/detail: shared Add-to layer `web/src/media/addTo/` on tiles + all four detail
pages + child grids, per-show Quick/Deep scan gated to Plex/Jellyfin/Emby, per-episode Media
Info + Troubleshoot entries; `POST /api/v1/playlists/{id}/items` added). Known accepted deviations
(select-mode toggle, per-card target superset) recorded in `docs/decisions.md`.
CLOSED 2026-07-10: **#221** (adversarial-reviewer#18 follow-up to #208/#209) — those PRs added
mutation actions to two screens whose fetch model keeps the previous result set rendered during a
refetch. On Search and Media browse the per-card Add-to menu, Select/select-mode, selection action
bar, Add-all, and Save-as-smart-collection are now **gated while a refetch is in flight** (query on
Search; kind/query/page on Media browse), with a visible "Refreshing…" cue and dimmed grid; card
navigation stays live. `SearchScreen.addAll` also binds its completion to the requesting query so a
late `GET /api/v1/search/all-items` can no longer open a bulk-add dialog scoped to the previous query.
See `docs/spa-conventions.md` §3a for the pattern.
CLOSED 2026-07-10: **#215** (adversarial-reviewer#18 removal gate) — Blazor's `EntityLocker`
build-lock gating of per-playout Reset/Erase/Delete/Edit is now enforced server-side: every
id-keyed `PlayoutController` mutation + `ChannelController.ResetPlayout` returns **409** while
`IsPlayoutLocked(id)`, and the SPA mirrors the lock via an `IsLocked` flag on the playout list
DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on
Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`.
2026-07-11 (#213, remaining scope): logs sort (`GET /api/v1/logs` `sortField`/`sortDirection`,
clickable column headers) and page-size persistence (client-local `localStorage`, not a server
`ConfigElement`) landed; trash "see all" now pages past the 100/kind cap via
`GET /api/v1/library/browse` (no new API surface — see `docs/decisions.md`). The sibling branch landed the rest
(block-history page-size/gating, blocks/templates list filters) — #213 fully closed.
## Section 4 — Blazor home / escape hatch — REMOVED
The Blazor home page (`/system/health`, formerly `Index.razor`) was the intentional exit ramp until
phase (b). It is now **deleted with the rest of Blazor** in this removal PR. `/system/health` has no
SPA equivalent and no explicit redirect entry, so it falls through to the Startup **catch-all fallback**
→ 302 `/app`. The "Classic UI" link that pointed to it is gone (its host chrome was part of the deleted
Blazor `Shared/**`). **Correction (#164, 2026-07-17):** one leftover `Open
Classic UI` row survived in the SPA `SettingsScreen` (System pane) — a dead link that just
bounced to `/app`. It was removed here (its regression test now asserts absence); blocks/decos/
templates/playout editors all live in the SPA now, so there is nothing left to escape to.
| Blazor route | Former file | Now |
|---|---|---|
| `/system/health` | `Index.razor` (deleted) | catch-all fallback → 302 `/app` |
## Section 5 — Removal execution runbook (#91 phase b) — EXECUTED in this PR
**All four steps below were EXECUTED in this #91b removal PR** (2026-07-11); the historical gating detail
is kept for the record, but the framing is now "done", not "will do". Verification: full
`dotnet test ErsatzTV.sln` green across every project (ErsatzTV.Tests +14 new redirect cases, Core,
Scanner, Infrastructure, Architecture, FFmpeg; 0 failed); new/updated Startup source-text tests assert the
Blazor wiring is gone and the catch-all is present; new `LegacyUiRedirects` tests cover the 14
`/media/sources` redirects.
The removal PR was **gated** — it started only after these cleared: ~~#202 (media-source write API + SPA)~~
**DONE 2026-07-11**, ~~#235 F9 (deep-scan + external-collections-scan API)~~ **API DONE (#235 slice B)**:
`POST /api/v1/libraries/{id}/scan?deep=` now threads deep-scan, and `POST /api/v1/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=`
covers external-collections scan (the two `Libraries.razor` parity gaps) — the SPA `Libraries.razor` port can now
proceed, and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). ~~**Remaining SPA affordance for
the removal PR**: `LibrariesScreen` exposes only quick-scan; add the **deep-scan** and **external-collections-scan**
buttons before deleting `Libraries.razor`.~~ **SPA affordance DONE 2026-07-11** (landed ahead of the removal PR
so the deletion diff stays pure): `LibrariesScreen` now wires the shipped `scanLibrary(id, deep)` +
`scanCollections(family, id, deep)` clients — a **Deep Scan Library** button on each remote library row and an
**External Collections** section (quick + deep per remote source), matching all four `Libraries.razor` scan actions.
The External Collections rows derive client-side from `getMediaSources()` (no new endpoint): the media-sources API
handler already filters each source's `libraries` to sync-enabled entries, so a remote source with a non-empty
`libraries` list is exactly `GetExternalCollections`'s `Libraries.Any(ShouldSyncItems)` filter. Collections scans
now reconcile against `GET /api/v1/media-sources/collections-scan-status` (family-global lock state), like library
scans do — #271 replaced the original optimistic timeout with authoritative polling. (#204's id-carrying
pattern redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
into Step 2 below, since it can only ship when `_Host` is deleted.) With the SPA parity done and the
**mandatory cold adversarial pass** complete, the runbook was executed, in order:
1. ✅ **DONE — Cut the rollback tag `blazor-final`** on the pre-deletion `main` commit — the *first* action, before
deleting anything. Exact command + restore path: `docs/decisions.md` 2026-07-11 "Pre-removal Blazor
rollback tag `blazor-final`" (#205). Not a `v*` tag → does not trigger a prod release build.
2. ✅ **DONE — Deleted Blazor** per #91's Step 2 recon (comment 2026-07-07): `ErsatzTV/Pages/**`, `Shared/**`,
`App.razor`, `_Imports.razor`, the Blazor `ViewModels/**` + `Validators/**`, `_Host.cshtml`, the Blazor
`Locals/{Pages,Shared}/**` resx (`Locals/Resources.*` KEPT), `wwwroot/css/**` + `wwwroot/lib/**`,
`libman.json`, and `ErsatzTV.Tests/Pages/MultiSelectBaseTests.cs`; stripped
`AddServerSideBlazor`/`MapBlazorHub` + `AddMudServices`/`AddSortable`/`AddCourier` and pruned the 9 packages
(MudBlazor, Heron.MudCalendar, Blazored.FluentValidation, BlazorSortable, MediatR.Courier.DependencyInjection,
Markdig, HtmlSanitizer, Chronic.Core, NaturalSort.Extension) from `Directory.Packages.props` +
`ErsatzTV.csproj`. **Surgical, not wholesale**, on the former Blazor `MapWhen` branch (lambda param renamed
`blazor`→`legacy`): it still co-hosts `MapControllers()` + `/docs` (Scalar) + dev `MapOpenApi()` + the
legacy-redirect middleware, all of which survive. `MapFallbackToPage("/_Host")` was REPLACED by the #204
design's **catch-all `MapFallback` → 302 `/app`** (any path not under `/api`/`/artwork`/`/docs`/`/openapi`;
those four 404), so no legacy path hard-404s now that `_Host` is gone. Also removed the dead #25 razor-Sonar
`` line from `ErsatzTV.csproj`.
3. ✅ **DONE — Auth posture** (signed off #206): deleted only the Blazor-attached OIDC pieces
(`AuthorizeFolder("/")` via `AddRazorPages`, the `legacy` branch's `UseAuthentication`/`UseAuthorization`).
**KEPT** the OIDC/JWT/API-key service wiring (inert unless configured; real auth = **#197**) and the
independent gates `ConditionalIptvAuthorizeFilter` (`/iptv/*`) + `ApiKeyAuthorizationFilter` (mutating
`/api/*`). Full analysis + must-not-break list: `docs/decisions.md` 2026-07-11 "Blazor removal auth posture" (#206).
4. ✅ **DONE — Verify + docs** per #91's Step 3: full `dotnet test ErsatzTV.sln` green (all projects, incl.
Architecture; +14 new `LegacyUiRedirects` media-sources cases + updated Startup source-text tests asserting
Blazor wiring is gone and the catch-all present). Also in this PR: `LegacyUiRedirects.cs` gained the 14
`/media/sources/*` redirects and dropped the `/media/sources` forbidden-prefix guard; this doc, CLAUDE.md
architecture, `docs/contributing.md` (§4 now "Web UI"), `docs/README.md`, and `docs/decisions.md` updated.
## Cross-reference: `docs/handoffs/chicorytv-issue-queue.md`
Keep this table and that handoff doc in sync at a high level — this table is the detailed route
inventory; the handoff doc tracks issue sequencing/session planning. If they disagree on an issue's
status, the more recently updated one wins; fix the stale one in the same PR you notice it.