From 8a2238b62e4500a90fd2adf2c58ec1c06d9133be Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 11 Jul 2026 16:39:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20pattern-based=20legacy=E2=86=92SPA?= =?UTF-8?q?=20redirect=20matcher=20for=20parameterized=20routes=20(#204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ErsatzTV.Tests/LegacyUiRedirectsTests.cs | 121 +++++++++++- ErsatzTV/LegacyUiRedirects.cs | 231 ++++++++++++++++++++++- ErsatzTV/Startup.cs | 5 +- docs/blazor-route-parity.md | 117 ++++++------ docs/decisions.md | 41 ++++ 5 files changed, 449 insertions(+), 66 deletions(-) diff --git a/ErsatzTV.Tests/LegacyUiRedirectsTests.cs b/ErsatzTV.Tests/LegacyUiRedirectsTests.cs index bf8c96ecf..caa64d193 100644 --- a/ErsatzTV.Tests/LegacyUiRedirectsTests.cs +++ b/ErsatzTV.Tests/LegacyUiRedirectsTests.cs @@ -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"); } diff --git a/ErsatzTV/LegacyUiRedirects.cs b/ErsatzTV/LegacyUiRedirects.cs index 5b692673c..cbfc98159 100644 --- a/ErsatzTV/LegacyUiRedirects.cs +++ b/ErsatzTV/LegacyUiRedirects.cs @@ -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 Patterns = new List + { + // (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; } + } } diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index c90399a8d..aa1e983eb 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -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; } } diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index 7b26b012c..d36685835 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -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) diff --git a/docs/decisions.md b/docs/decisions.md index defbf3990..143c07c71 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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` 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.