Make the ~14 health checks actionable: each check that has a fix now declares
where to go, and the SPA acts on it.
Backend:
- Widen domain HealthCheckLink (string Link) -> (string Target, HealthCheckLinkKind
Kind) with ExternalDoc|AppRoute + factories; only the 4 link-building checks and
the API mapper touched .Link.
- Evolve HealthCheckResponseModel additively (/api/v1 frozen-additive): keep
deprecated string? Link (still populated), add Brief (the BriefMessage the mapper
was silently dropping) and nested Remediation {Kind, Target}. Kind is a mapped
string, not a wire enum.
- Make Mapper.GetStatus total: NotApplicable no longer throws (defensive; handler
still filters it). InternalsVisibleTo(ErsatzTV.Tests) added to unit-test totality.
- Fix 2 stale Blazor route links (media/trash -> /app/trash, search?query ->
/app/search); add AppRoute remediation to actionable checks that had none
(libraries / schedules / ffmpeg-profiles / settings).
SPA:
- DashboardScreen health panel renders remediation: AppRoute -> client-side nav
button, ExternalDoc -> new-tab anchor; detail text truncates with title-hover.
- Remove the dead "Open Classic UI" -> /system/health row from SettingsScreen
(a #91b leftover that just 302'd to /app); update its regression test.
Docs: decisions.md (#164), api-conventions.md (deprecate-in-place DTO evolution),
blazor-route-parity.md (Section 4 correction); v1.json/v1.d.ts/endpoint-index
regenerated.
fixes #164
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
33 KiB
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 (seedocs/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 viaErsatzTV/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 isblazor-final(seedocs/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'sMap— 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, not Blazor-only as an earlier draft
of this table implied — 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.mdexcluded). 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}/reshufflereseeds + rebuilds viaPlayoutBuildMode.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. |
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. Found by the #91 cold review (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 <a href="/system/health">Open Classic UI</a> 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: SPA affordance DONE 2026-07-11 (landed ahead of the removal PR
so the deletion diff stays pure): LibrariesScreen exposes only quick-scan; add the deep-scan and external-collections-scan
buttons before deleting Libraries.razor.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:
- ✅ DONE — Cut the rollback tag
blazor-finalon the pre-deletionmaincommit — the first action, before deleting anything. Exact command + restore path:docs/decisions.md2026-07-11 "Pre-removal Blazor rollback tagblazor-final" (#205). Not av*tag → does not trigger a prod release build. - ✅ DONE — Deleted Blazor per #91's Step 2 recon (comment 2026-07-07):
ErsatzTV/Pages/**,Shared/**,App.razor,_Imports.razor, the BlazorViewModels/**+Validators/**,_Host.cshtml, the BlazorLocals/{Pages,Shared}/**resx (Locals/Resources.*KEPT),wwwroot/css/**+wwwroot/lib/**,libman.json, andErsatzTV.Tests/Pages/MultiSelectBaseTests.cs; strippedAddServerSideBlazor/MapBlazorHub+AddMudServices/AddSortable/AddCourierand pruned the 9 packages (MudBlazor, Heron.MudCalendar, Blazored.FluentValidation, BlazorSortable, MediatR.Courier.DependencyInjection, Markdig, HtmlSanitizer, Chronic.Core, NaturalSort.Extension) fromDirectory.Packages.props+ErsatzTV.csproj. Surgical, not wholesale, on the former BlazorMapWhenbranch (lambda param renamedblazor→legacy): it still co-hostsMapControllers()+/docs(Scalar) + devMapOpenApi()+ the legacy-redirect middleware, all of which survive.MapFallbackToPage("/_Host")was REPLACED by the #204 design's catch-allMapFallback→ 302/app(any path not under/api//artwork//docs//openapi; those four 404), so no legacy path hard-404s now that_Hostis gone. Also removed the dead #25 razor-Sonar<NoWarn>line fromErsatzTV.csproj. - ✅ DONE — Auth posture (signed off #206): deleted only the Blazor-attached OIDC pieces
(
AuthorizeFolder("/")viaAddRazorPages, thelegacybranch'sUseAuthentication/UseAuthorization). KEPT the OIDC/JWT/API-key service wiring (inert unless configured; real auth = #197) and the independent gatesConditionalIptvAuthorizeFilter(/iptv/*) +ApiKeyAuthorizationFilter(mutating/api/*). Full analysis + must-not-break list:docs/decisions.md2026-07-11 "Blazor removal auth posture" (#206). - ✅ DONE — Verify + docs per #91's Step 3: full
dotnet test ErsatzTV.slngreen (all projects, incl. Architecture; +14 newLegacyUiRedirectsmedia-sources cases + updated Startup source-text tests asserting Blazor wiring is gone and the catch-all present). Also in this PR:LegacyUiRedirects.csgained the 14/media/sources/*redirects and dropped the/media/sourcesforbidden-prefix guard; this doc, CLAUDE.md architecture,docs/contributing.md(§4 now "Web UI"),docs/README.md, anddocs/decisions.mdupdated.
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.