Files
ersatztv/docs/blazor-route-parity.md
T
timothyandClaude Opus 4.8 6d31758cca
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): #271 collections scan-status REST surface + authoritative SPA reconcile
Add GET /api/media-sources/collections-scan-status (MediaSourcesController →
GetCollectionsScanStatus handler) reporting which media-source families
(plex/jellyfin/emby) currently hold their external-collections scan lock,
reading IEntityLocker.Are{X}CollectionsLocked(). The lock is family-global
(no source id) and boolean (no percent), so the DTO carries just {family} and
returns only active families — the counterpart to GET /api/libraries/scan-status.

SPA: useCollectionsScan now polls this endpoint and reconciles optimistic
pending against the active-family set (seeding on mount so an in-progress scan
disables buttons immediately), using the same grace-tick helper as library
scans (now generic over the pending key type). Drops COLLECTIONS_PENDING_TIMEOUT_MS
— a long deep scan no longer re-enables the button early, and a fast scan no
longer wedges it disabled for the full timeout. A row shows Scanning when its
family is active or it has an in-grace optimistic pending key.

Tests: handler (3), controller route+delegation (2), SPA api fn + hook reconcile
(mount-seed / 202-promote / 409-keeps-disabled / 404-error). OpenAPI + TS types
regenerated. Docs: api-conventions §3b, blazor-route-parity §5, decisions.md.

Unblocks #91b (arc item 4): Libraries.razor's collections-scan affordance now
has full authoritative parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:23:58 +02:00

32 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)
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 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/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/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/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/libraries/local/*; PlexMediaSourcesController/JellyfinMediaSourcesController/ EmbyMediaSourcesController under /api/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/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/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/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/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/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/**).

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/libraries/{id}/scan?deep= now threads deep-scan, and POST /api/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/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.