Files
ersatztv/docs/blazor-route-parity.md
T
timothyandtimothy 736649b3b7
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m33s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m52s
fix(812): classify the narrative sites by who-benefits; keep the detector's reach (#882)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 20:28:14 +00:00

378 lines
34 KiB
Markdown
Raw Blame History

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