Files
ersatztv/docs/blazor-route-parity.md
T
timothyandClaude Opus 4.8 ed6c43065f
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m48s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m49s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m35s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13m23s
feat(164): guided remediation for health checks (server-declared {Kind, Target})
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>
2026-07-18 00:51:40 +02:00

33 KiB
Raw Blame History

Blazor → SPA route parity tracker

STATUS: #91 phase (b) is COMPLETE (this removal PR). The legacy Blazor Server UI has been removedErsatzTV/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, 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.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.

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: 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 blazorlegacy): 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 <NoWarn> 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.