fix(ui): pattern-based legacy→SPA redirect matcher for parameterized routes (#204)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Extend LegacyUiRedirects from an exact-match dictionary to a two-tier matcher:
Tier 1 keeps the exact Map (now 52 entries incl. the ?kind= browse roots),
Tier 2 adds 36 ordered segment-template PatternRules for id-carrying routes.
{id} is a strict positive integer (non-int/0/neg/overflow falls through), which
also makes the rule set collision-free by construction. New AppendQueryString
helper merges the incoming query into ?kind= targets with '&' (kills the
double-'?' bug); one-line Startup change keeps the redirect GET/HEAD-only 302
before UseRouting.

Completes phase-(a) Step 1 for every PARITY-OK route (#91 phase b); the
catch-all fallback replacing MapFallbackToPage stays with the removal PR.
/media/sources/* (#202) and /system/health remain deliberately un-redirected.

fixes #204

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:39:56 +02:00
co-authored by Claude Opus 4.8
parent 06c877b5fc
commit 8a2238b62e
5 changed files with 449 additions and 66 deletions
+114 -7
View File
@@ -32,15 +32,74 @@ public class LegacyUiRedirectsTests
[TestCase("/settings/scanner", "/app/settings/scanner")]
[TestCase("/settings/ui", "/app/settings/general")]
[TestCase("/settings/xmltv", "/app/settings/xmltv")]
// (A) parameterless routes migrated in #204 (one spot-check per new group)
[TestCase("/channels/numbers", "/app/channels")]
[TestCase("/system/troubleshooting/block-playout", "/app/troubleshooting/blocks")]
[TestCase("/media/browser/images", "/app/media/images/browser")]
[TestCase("/schedules/add", "/app/schedules")]
// (B) ".../add" sub-actions
[TestCase("/playouts/add", "/app/playouts")]
[TestCase("/ffmpeg/add", "/app/ffmpeg-profiles")]
// (E-base) browse roots
[TestCase("/media/movies", "/app/media?kind=movies")]
[TestCase("/media/remote/streams", "/app/media?kind=remote-streams")]
public void Known_Route_Should_Redirect(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
// (C) id-substituting
[TestCase("/channels/5", "/app/edit-channel/5")]
[TestCase("/blocks/12", "/app/blocks/12")]
[TestCase("/deco-templates/3", "/app/deco-templates/3")]
[TestCase("/media/trakt/lists/11", "/app/trakt-lists/11")]
[TestCase("/media/movies/42", "/app/media/movies/42")]
[TestCase("/media/tv/shows/7", "/app/media/shows/7")]
[TestCase("/media/tv/seasons/8", "/app/media/seasons/8")]
[TestCase("/media/music/artists/9", "/app/media/artists/9")]
// (C2) mid-path
[TestCase("/playouts/9/templates", "/app/playouts/9/templates")]
[TestCase("/playouts/9/alternate-schedules", "/app/playouts/9/alternate-schedules")]
// (D) id-dropping
[TestCase("/schedules/4", "/app/schedules")]
[TestCase("/schedules/4/items", "/app/schedules")]
[TestCase("/playouts/classic/2", "/app/playouts")]
[TestCase("/playouts/block/2", "/app/playouts")]
[TestCase("/playouts/scripted/2", "/app/playouts")]
[TestCase("/playouts/sequential/2", "/app/playouts")]
[TestCase("/playouts/add/classic", "/app/playouts")]
[TestCase("/media/collections/8", "/app/collections")]
[TestCase("/media/collections/8/edit", "/app/collections")]
[TestCase("/media/multi-collections/8/edit", "/app/multi-collections")]
[TestCase("/media/rerun-collections/8/edit", "/app/rerun-collections")]
[TestCase("/media/filler/presets/8/edit", "/app/filler-presets")]
[TestCase("/ffmpeg/6", "/app/ffmpeg-profiles")]
[TestCase("/watermarks/6", "/app/watermarks")]
// (E-page) paging
[TestCase("/media/movies/page/3", "/app/media?kind=movies")]
[TestCase("/media/tv/shows/page/2", "/app/media?kind=shows")]
[TestCase("/media/tv/seasons/page/2", "/app/media?kind=seasons")]
[TestCase("/media/tv/episodes/page/2", "/app/media?kind=episodes")]
[TestCase("/media/music/artists/page/2", "/app/media?kind=artists")]
[TestCase("/media/music/videos/page/2", "/app/media?kind=music-videos")]
[TestCase("/media/music/songs/page/2", "/app/media?kind=songs")]
[TestCase("/media/other/videos/page/2", "/app/media?kind=other-videos")]
[TestCase("/media/remote/streams/page/2", "/app/media?kind=remote-streams")]
[TestCase("/media/images/page/2", "/app/media?kind=images")]
public void Pattern_Route_Should_Redirect(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/channels/", "/app/channels")]
[TestCase("/schedules/", "/app/schedules")]
[TestCase("/settings/ffmpeg/", "/app/settings/streaming")]
// trailing slash on parameterized/pattern routes
[TestCase("/channels/5/", "/app/edit-channel/5")]
[TestCase("/playouts/9/templates/", "/app/playouts/9/templates")]
[TestCase("/media/movies/", "/app/media?kind=movies")]
public void Trailing_Slash_Should_Match(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
@@ -54,12 +113,21 @@ public class LegacyUiRedirectsTests
target.ShouldBe("/app/channels");
}
[TestCase("/channels/5")] // channel edit (Blazor-only)
[TestCase("/channels/numbers")] // Blazor-only
[TestCase("/Channels/5", "/app/edit-channel/5")]
[TestCase("/MEDIA/TV/SHOWS/7", "/app/media/shows/7")]
public void Pattern_Lookup_Should_Be_Case_Insensitive(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/system/health")] // Blazor home escape hatch
[TestCase("/media/collections")] // Blazor-only media page
[TestCase("/ffmpeg")] // Blazor-only
[TestCase("/watermarks")] // Blazor-only
[TestCase("/media/sources/plex")] // media sources have no SPA UI (#202)
[TestCase("/media/sources/jellyfin")]
[TestCase("/api/foo")] // API surface
[TestCase("/artwork/1")] // artwork surface
[TestCase("/docs")] // docs surface
[TestCase("/openapi")] // openapi surface
[TestCase("/app")] // already the SPA
[TestCase("/app/channels")] // already the SPA
[TestCase("/iptv/channels.m3u")] // IPTV surface
@@ -67,12 +135,51 @@ public class LegacyUiRedirectsTests
[TestCase("")] // empty
[TestCase("//")] // all-slash path must not collapse to root "/"
[TestCase("/channels//")] // double trailing slash is not normalized to a match
// strict id validation — these must all fall through
[TestCase("/channels/abc")]
[TestCase("/channels/0")]
[TestCase("/channels/-1")]
[TestCase("/channels/1.5")]
[TestCase("/channels/999999999999")]
[TestCase("/channels//5")] // empty segment: must not match /channels/{id}
[TestCase("/media/movies/page/abc")]
[TestCase("/media/movies/page/0")]
[TestCase("/playouts/templates")] // no id segment
[TestCase("/media/movies/page")] // 3-seg, non-numeric tail
public void Non_Migrated_Route_Should_Not_Redirect(string path)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse();
target.ShouldBe(string.Empty);
}
[TestCase("/app/media?kind=movies", "?page=2", "/app/media?kind=movies&page=2")]
[TestCase("/app/media?kind=movies", "", "/app/media?kind=movies")]
[TestCase("/app/channels", "?x=1", "/app/channels?x=1")]
[TestCase("/app/channels", "", "/app/channels")]
[TestCase("/app/media?kind=movies", "?kind=shows", "/app/media?kind=movies&kind=shows")]
public void AppendQueryString_Should_Merge(string target, string query, string expected)
{
LegacyUiRedirects.AppendQueryString(target, new QueryString(query)).ShouldBe(expected);
}
[Test]
public void Map_Keys_Should_Not_Begin_With_Forbidden_Prefix()
{
string[] forbidden =
{
"/api", "/artwork", "/docs", "/openapi", "/iptv", "/app", "/media/sources"
};
foreach (string key in LegacyUiRedirects.Map.Keys)
{
foreach (string prefix in forbidden)
{
key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
.ShouldBeFalse($"Map key '{key}' must not begin with forbidden prefix '{prefix}'");
}
}
}
[Test]
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
{
@@ -87,8 +194,8 @@ public class LegacyUiRedirectsTests
redirectIndex.ShouldBeLessThan(routingIndex);
// 302 (temporary), not a permanent redirect.
StartupSource.ShouldContain("context.Request.PathBase + target");
// 302 (temporary), not a permanent redirect; incoming query merged via AppendQueryString.
StartupSource.ShouldContain("context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target");
StartupSource.ShouldNotContain("RedirectPermanent(target");
}
+227 -4
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.AspNetCore.Http;
namespace ErsatzTV;
@@ -10,13 +11,25 @@ namespace ErsatzTV;
// deliberately left reachable (no redirect) so their functionality stays
// available while the SPA catches up:
// /system/health (Blazor home escape hatch; Index.razor also lives here),
// /channels/{id} edit, /channels/numbers, /media/* (collections etc.),
// /ffmpeg, /watermarks, /blocks, /decos, /templates, /deco-templates,
// schedule/playout detail editors, /system/logs, /system/troubleshooting.
// /media/sources/* (source add/edit has no SPA UI yet — ersatztv#202).
//
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on
// full SPA parity for every route in this map. Until then this map is the
// single source of truth for "what has migrated" and is expected to grow.
//
// Matching is two-tier (see TryGetRedirect):
// Tier 1 = the exact-path dictionary Map below (parameterless routes, plus
// the (E-base) browse roots whose *targets* carry "?kind=...").
// Tier 2 = the ordered Patterns list of segment-template rules, consulted
// only when Tier 1 misses (parameterized routes with one variable
// segment: an {id} or an {any} kind).
//
// GUARD INVARIANT (see also the collision-freedom note above Patterns): no
// Tier-1 key and no Tier-2 template may begin with /api, /artwork, /docs,
// /openapi, /iptv, /app, or /media/sources. Rules are always full, specific
// templates — never prefix wildcards (a bare "/media/{any}" style rule is
// forbidden). The blazor branch does not prefix-guard /api|/artwork|/docs|
// /openapi, so the matcher's specificity is part of their protection.
public static class LegacyUiRedirects
{
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
@@ -36,9 +49,117 @@ public static class LegacyUiRedirects
["/settings/playout"] = "/app/settings/playout",
["/settings/scanner"] = "/app/settings/scanner",
["/settings/ui"] = "/app/settings/general",
["/settings/xmltv"] = "/app/settings/xmltv"
["/settings/xmltv"] = "/app/settings/xmltv",
// (A) parameterless routes migrated in ersatztv#204
["/channels/numbers"] = "/app/channels",
["/search"] = "/app/search",
["/system/logs"] = "/app/logs",
["/system/troubleshooting"] = "/app/troubleshooting",
["/system/troubleshooting/block-playout"] = "/app/troubleshooting/blocks",
["/system/troubleshooting/sequential-schedule"] = "/app/troubleshooting/yaml",
["/system/troubleshooting/playback"] = "/app/troubleshooting/playback",
["/blocks"] = "/app/blocks",
["/templates"] = "/app/templates",
["/decos"] = "/app/decos",
["/deco-templates"] = "/app/deco-templates",
["/media/multi-collections"] = "/app/multi-collections",
["/media/rerun-collections"] = "/app/rerun-collections",
["/media/filler/presets"] = "/app/filler-presets",
["/media/collections"] = "/app/collections",
["/media/trash"] = "/app/trash",
["/media/playlists"] = "/app/playlists",
["/media/trakt/lists"] = "/app/trakt-lists",
["/ffmpeg"] = "/app/ffmpeg-profiles",
["/watermarks"] = "/app/watermarks",
["/media/browser/images"] = "/app/media/images/browser",
["/schedules/add"] = "/app/schedules",
// (B) ".../add" sub-actions (ersatztv#204)
["/playouts/add"] = "/app/playouts",
["/media/multi-collections/add"] = "/app/multi-collections",
["/media/rerun-collections/add"] = "/app/rerun-collections",
["/media/filler/presets/add"] = "/app/filler-presets",
["/media/collections/add"] = "/app/collections",
["/ffmpeg/add"] = "/app/ffmpeg-profiles",
["/watermarks/add"] = "/app/watermarks",
// (E-base) browse roots — targets carry "?kind=" (ersatztv#204)
["/media/movies"] = "/app/media?kind=movies",
["/media/tv/shows"] = "/app/media?kind=shows",
["/media/tv/seasons"] = "/app/media?kind=seasons",
["/media/tv/episodes"] = "/app/media?kind=episodes",
["/media/music/artists"] = "/app/media?kind=artists",
["/media/music/videos"] = "/app/media?kind=music-videos",
["/media/music/songs"] = "/app/media?kind=songs",
["/media/other/videos"] = "/app/media?kind=other-videos",
["/media/remote/streams"] = "/app/media?kind=remote-streams",
["/media/images"] = "/app/media?kind=images"
};
// Tier-2 segment-template rules, consulted only after the exact Map misses.
// Declaration order IS match order (first match wins) — keep it exactly as
// grouped below: (C) -> (C2) -> (D) -> (E-page).
//
// The rule set is COLLISION-FREE BY CONSTRUCTION — exact-before-pattern plus
// strict positive-integer {id} validation means no two tiers/rules can match
// the same path:
// - /channels/add, /channels/numbers, /schedules/add, /playouts/add,
// /media/collections/add, /media/browser/images are Tier-1 exact hits
// (checked first) and their trailing segments are non-numeric anyway, so
// they could never match an {id} rule.
// - /playouts/classic/{id} vs /playouts/{id}/templates: both 3 segments,
// but "classic" fails {id} and "templates" != the {id}-position literal.
// - /playouts/add/{any} vs /playouts/{id}/...: "add" fails {id} — disjoint.
// - /media/movies/{id} (3 seg) vs /media/movies/page/{id} (4 seg): disjoint
// by count; /media/movies/page (3 seg, "page" non-numeric) falls through.
private static readonly IReadOnlyList<PatternRule> Patterns = new List<PatternRule>
{
// (C) id-substituting
new("/channels/{id}", "/app/edit-channel/{id}"),
new("/blocks/{id}", "/app/blocks/{id}"),
new("/templates/{id}", "/app/templates/{id}"),
new("/decos/{id}", "/app/decos/{id}"),
new("/deco-templates/{id}", "/app/deco-templates/{id}"),
new("/media/trakt/lists/{id}", "/app/trakt-lists/{id}"),
new("/media/movies/{id}", "/app/media/movies/{id}"),
new("/media/tv/shows/{id}", "/app/media/shows/{id}"),
new("/media/tv/seasons/{id}", "/app/media/seasons/{id}"),
new("/media/music/artists/{id}", "/app/media/artists/{id}"),
// (C2) id-substituting mid-path
new("/playouts/{id}/alternate-schedules", "/app/playouts/{id}/alternate-schedules"),
new("/playouts/{id}/templates", "/app/playouts/{id}/templates"),
// (D) id-dropping
new("/schedules/{id}", "/app/schedules"),
new("/schedules/{id}/items", "/app/schedules"),
new("/playouts/classic/{id}", "/app/playouts"),
new("/playouts/block/{id}", "/app/playouts"),
new("/playouts/scripted/{id}", "/app/playouts"),
new("/playouts/sequential/{id}", "/app/playouts"),
new("/playouts/add/{any}", "/app/playouts"),
new("/media/multi-collections/{id}/edit", "/app/multi-collections"),
new("/media/rerun-collections/{id}/edit", "/app/rerun-collections"),
new("/media/filler/presets/{id}/edit", "/app/filler-presets"),
new("/media/collections/{id}/edit", "/app/collections"),
new("/media/collections/{id}", "/app/collections"),
new("/ffmpeg/{id}", "/app/ffmpeg-profiles"),
new("/watermarks/{id}", "/app/watermarks"),
// (E-page) paging -> "?kind=" ({id} token reused for the page number, value dropped)
new("/media/movies/page/{id}", "/app/media?kind=movies"),
new("/media/tv/shows/page/{id}", "/app/media?kind=shows"),
new("/media/tv/seasons/page/{id}", "/app/media?kind=seasons"),
new("/media/tv/episodes/page/{id}", "/app/media?kind=episodes"),
new("/media/music/artists/page/{id}", "/app/media?kind=artists"),
new("/media/music/videos/page/{id}", "/app/media?kind=music-videos"),
new("/media/music/songs/page/{id}", "/app/media?kind=songs"),
new("/media/other/videos/page/{id}", "/app/media?kind=other-videos"),
new("/media/remote/streams/page/{id}", "/app/media?kind=remote-streams"),
new("/media/images/page/{id}", "/app/media?kind=images")
};
public static bool TryGetRedirect(PathString path, out string target)
{
target = string.Empty;
@@ -63,12 +184,114 @@ public static class LegacyUiRedirects
value = trimmed;
}
// Tier 1 — exact map (O(1); covers real traffic).
if (Map.TryGetValue(value, out string mapped))
{
target = mapped;
return true;
}
// Tier 2 — ordered segment-template rules. Split with
// StringSplitOptions.None (NOT RemoveEmptyEntries) and reject empty
// segments so "/channels//5" cannot match "/channels/{id}".
if (value.Length == 0 || value[0] != '/')
{
return false;
}
string[] segments = value[1..].Split('/', StringSplitOptions.None);
foreach (string segment in segments)
{
if (segment.Length == 0)
{
return false;
}
}
foreach (PatternRule rule in Patterns)
{
if (rule.Segments.Length != segments.Length)
{
continue;
}
var matched = true;
string capturedId = string.Empty;
for (var i = 0; i < rule.Segments.Length; i++)
{
string template = rule.Segments[i];
string segment = segments[i];
if (template == "{id}")
{
if (!IsPositiveInteger(segment))
{
matched = false;
break;
}
capturedId = segment;
}
else if (template == "{any}")
{
// any non-empty segment (already guaranteed non-empty above)
}
else if (!string.Equals(template, segment, StringComparison.OrdinalIgnoreCase))
{
matched = false;
break;
}
}
if (matched)
{
target = capturedId.Length > 0
? rule.Target.Replace("{id}", capturedId)
: rule.Target;
return true;
}
}
return false;
}
// Appends the incoming request query string to a redirect target that may
// already carry its own query (e.g. "/app/media?kind=movies"). Merges with
// '&' instead of producing a malformed double '?'. Targets WITHOUT a '?'
// behave byte-for-byte as before (verbatim append). A duplicated key after a
// merge (e.g. incoming "?kind=shows" onto "?kind=movies") is first-wins in
// the SPA (URLSearchParams.get returns the first value) — acceptable.
public static string AppendQueryString(string target, QueryString query)
{
if (!query.HasValue || query.Value is null || query.Value.Length <= 1)
{
return target;
}
return target.Contains('?')
? target + "&" + query.Value[1..]
: target + query.Value;
}
// A {id} segment must be a positive integer. NumberStyles.None rejects signs,
// whitespace and separators; TryParse failure also rejects overflow garbage
// like "999999999999". The raw matched text (e.g. "007") is substituted — the
// SPA's parseInt handles leading zeros, so do not re-format.
private static bool IsPositiveInteger(string segment) =>
int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int id) && id > 0;
private sealed class PatternRule
{
public PatternRule(string template, string target)
{
Segments = template.Split('/', StringSplitOptions.RemoveEmptyEntries);
Target = target;
}
// e.g. ["channels", "{id}"]
public string[] Segments { get; }
// e.g. "/app/edit-channel/{id}"
public string Target { get; }
}
}
+4 -1
View File
@@ -743,6 +743,9 @@ public class Startup
// browser caching would make rollback painful. UsePathBase (ETV_BASE_URL)
// only rewrites the request side (Request.Path/PathBase); it never touches
// redirect Location headers, so the PathBase prefix must be re-applied here.
// AppendQueryString merges the incoming query into the target: a target that
// already carries its own query (e.g. "/app/media?kind=movies") gets '&'-joined
// instead of a malformed double '?'.
blazor.Use(async (context, next) =>
{
if (HttpMethods.IsGet(context.Request.Method) ||
@@ -751,7 +754,7 @@ public class Startup
if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target))
{
context.Response.Redirect(
context.Request.PathBase + target + context.Request.QueryString);
context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target, context.Request.QueryString));
return;
}
}
+63 -54
View File
@@ -19,8 +19,12 @@ Sources of truth checked when compiling this table: `ErsatzTV/LegacyUiRedirects.
## Section 1 — REDIRECTED
(`ErsatzTV/LegacyUiRedirects.cs`'s `Map`, verified current as of this doc — 13 entries, unchanged
since ersatztv#91 phase (a)/PR #148):
`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 |
|---|---|
@@ -44,6 +48,56 @@ equivalents. There is no Blazor-only settings sub-route left except the ones wit
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 — SPA-READY, not yet redirected
> **Mutation-depth verification (2026-07-09, #203 sweep)**: "SPA-READY" previously meant only
@@ -72,58 +126,13 @@ redirect).
>
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11.
Confirmed as of this doc: the SPA screen exists (verified against `web/src/App.tsx`'s route table
and `web/src/screens/`) but `LegacyUiRedirects.cs` has **no entry** for the Blazor route yet.
Scheduling-parity work (#144/#162, DONE 2026-07-07, PRs #170–#175/#179) built the SPA screens for
blocks/templates/decos/deco-templates/playout editors, #145 (PR #182, merged to main) built the
troubleshooting/YAML-validator screens, and #141 (PR #183, merged to main) built the media detail
pages + image folder browser (`MediaDetailScreen.tsx`'s `MovieDetailScreen`/`ShowDetailScreen`/
`SeasonDetailScreen`/`ArtistDetailScreen`, `ImageBrowserScreen.tsx`, both dispatched via `App.tsx`'s
`MediaRouteScreen` sub-route wrapper, same pattern as `PlayoutsRouteScreen`) — none of these have
been added to the redirect map yet.
| 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 |
| `/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`) |
| `/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 |
| `/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 |
**As of ersatztv#204, this section is empty** — every route the mutation-depth sweep marked
PARITY-OK now has a redirect entry (exact `Map` or a `PatternRule`) and has moved to **Section 1**.
The SPA screens were verified against `web/src/App.tsx`'s route table and `web/src/screens/` when
each was built (scheduling parity #144/#162; troubleshooting/YAML #145; media detail + image browser
#141/#183); #204 only added the redirects. The one route still deliberately un-redirected is
`/media/sources/*` — disproven and tracked in **Section 3** (#202) — plus the `/system/health`
escape hatch in **Section 4**.
## Section 3 — BLAZOR-ONLY (blocking issues)
+41
View File
@@ -459,3 +459,44 @@ into the moved `progressFromChannelState` (behavior-identical — `ChannelState.
**#238 (TopBar `primaryAction` dead button) left untouched** — the `channels` route's inert `ctv:primary-action`
dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign
is deferred to #247 (epic phase 4).
## 2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204)
`LegacyUiRedirects.TryGetRedirect` grew from a single exact-path dictionary to a **two-tier matcher**
behind the unchanged `(PathString, out string)` signature. **Tier 1** is the existing
`OrdinalIgnoreCase` `Map` (now 52 entries — the parameterless (A)/(B) routes plus the (E-base) browse
roots whose *targets* carry `?kind=…`). **Tier 2** is an ordered `IReadOnlyList<PatternRule>` of 36
segment-template rules ((C)/(C2)/(D)/(E-page)), consulted only on a Tier-1 miss; declaration order is
match order (first-match-wins).
Template tokens are minimal: `{id}` matches a **strict positive integer**
(`int.TryParse(seg, NumberStyles.None, InvariantCulture, out id) && id > 0` — rejects signs,
whitespace, separators, `0`, negatives, and overflow like `999999999999`; the raw segment text,
e.g. `007`, is substituted, not re-formatted); `{any}` matches any non-empty segment and is dropped
(only `/playouts/add/{any}`); everything else is a literal compared `OrdinalIgnoreCase`. The request
path is split with `StringSplitOptions.None` and **empty segments are rejected** (load-bearing: so
`/channels//5` cannot match `/channels/{id}`); templates themselves use `RemoveEmptyEntries`. The
existing single-trailing-slash normalization runs before both tiers, so `/channels/5/` matches.
The set is **collision-free by construction** — exact-before-pattern plus strict numeric `{id}` means
no two tiers/rules can match the same path. **Guard invariant** (comment + `Map`-keys meta-test): no
Tier-1 key or Tier-2 template may begin with `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`,
or `/media/sources`; rules are always full, specific templates — **never prefix wildcards** (a bare
`/media/{any}` rule is forbidden). The blazor branch does not prefix-guard `/api|/artwork|/docs|
/openapi`, so the matcher's specificity is part of their protection.
**Query-string merge**: the incoming request query is now merged into the target via a new public
`AppendQueryString(target, QueryString)` helper (one-line Startup change:
`context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target, context.Request.QueryString)`).
A target that already carries `?` (the `?kind=…` browse roots) is `&`-joined instead of producing a
malformed double `?`; plain targets keep verbatim-append behavior byte-for-byte. A duplicated key
after a merge (`?kind=movies` + incoming `?kind=shows`) is first-wins in the SPA
(`URLSearchParams.get` returns the first value) — acceptable. Extracting the merge into
`LegacyUiRedirects` keeps it unit-testable without a TestServer while preserving the PathBase
re-application invariant the Startup source-text test protects.
**Rejected**: regex pairs (harder to audit for the `/api`/`/artwork` greediness invariant, noisier
tests, no benefit — every parameterized route here is "fixed segments + one variable segment");
ASP.NET `TemplateMatcher`/`RouteMatcher` (pulls routing machinery into a static helper for 36 rules);
a single unified rule list (loses the O(1) dictionary hit for the ~52 exact routes that dominate real
traffic). No `/api` change, no OpenAPI regen, no SPA change.