Merge remote-tracking branch 'origin/main' into feat/202-media-sources
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

# Conflicts:
#	docs/blazor-route-parity.md
#	docs/decisions.md
This commit is contained in:
2026-07-11 17:31:04 +02:00
6 changed files with 571 additions and 68 deletions
+126 -7
View File
@@ -32,15 +32,74 @@ public class LegacyUiRedirectsTests
[TestCase("/settings/scanner", "/app/settings/scanner")] [TestCase("/settings/scanner", "/app/settings/scanner")]
[TestCase("/settings/ui", "/app/settings/general")] [TestCase("/settings/ui", "/app/settings/general")]
[TestCase("/settings/xmltv", "/app/settings/xmltv")] [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) public void Known_Route_Should_Redirect(string path, string expected)
{ {
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue(); LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected); 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("/channels/", "/app/channels")]
[TestCase("/schedules/", "/app/schedules")] [TestCase("/schedules/", "/app/schedules")]
[TestCase("/settings/ffmpeg/", "/app/settings/streaming")] [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) public void Trailing_Slash_Should_Match(string path, string expected)
{ {
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue(); LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
@@ -54,12 +113,21 @@ public class LegacyUiRedirectsTests
target.ShouldBe("/app/channels"); target.ShouldBe("/app/channels");
} }
[TestCase("/channels/5")] // channel edit (Blazor-only) [TestCase("/Channels/5", "/app/edit-channel/5")]
[TestCase("/channels/numbers")] // Blazor-only [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("/system/health")] // Blazor home escape hatch
[TestCase("/media/collections")] // Blazor-only media page [TestCase("/media/sources/plex")] // media sources have no SPA UI (#202)
[TestCase("/ffmpeg")] // Blazor-only [TestCase("/media/sources/jellyfin")]
[TestCase("/watermarks")] // Blazor-only [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")] // already the SPA
[TestCase("/app/channels")] // already the SPA [TestCase("/app/channels")] // already the SPA
[TestCase("/iptv/channels.m3u")] // IPTV surface [TestCase("/iptv/channels.m3u")] // IPTV surface
@@ -67,12 +135,63 @@ public class LegacyUiRedirectsTests
[TestCase("")] // empty [TestCase("")] // empty
[TestCase("//")] // all-slash path must not collapse to root "/" [TestCase("//")] // all-slash path must not collapse to root "/"
[TestCase("/channels//")] // double trailing slash is not normalized to a match [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) public void Non_Migrated_Route_Should_Not_Redirect(string path)
{ {
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse(); LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse();
target.ShouldBe(string.Empty); 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}'");
}
}
// The guard invariant is load-bearing for the un-prefix-guarded /api|/artwork|
// /docs|/openapi surface, so it must cover the Tier-2 pattern templates too —
// not only the Tier-1 Map keys — or a future prefix-violating rule would slip in.
foreach (string template in LegacyUiRedirects.PatternTemplates)
{
foreach (string prefix in forbidden)
{
template.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
.ShouldBeFalse($"Pattern template '{template}' must not begin with forbidden prefix '{prefix}'");
}
}
}
[Test] [Test]
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing() public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
{ {
@@ -87,8 +206,8 @@ public class LegacyUiRedirectsTests
redirectIndex.ShouldBeLessThan(routingIndex); redirectIndex.ShouldBeLessThan(routingIndex);
// 302 (temporary), not a permanent redirect. // 302 (temporary), not a permanent redirect; incoming query merged via AppendQueryString.
StartupSource.ShouldContain("context.Request.PathBase + target"); StartupSource.ShouldContain("context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target");
StartupSource.ShouldNotContain("RedirectPermanent(target"); StartupSource.ShouldNotContain("RedirectPermanent(target");
} }
+236 -4
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
namespace ErsatzTV; namespace ErsatzTV;
@@ -10,13 +11,25 @@ namespace ErsatzTV;
// deliberately left reachable (no redirect) so their functionality stays // deliberately left reachable (no redirect) so their functionality stays
// available while the SPA catches up: // available while the SPA catches up:
// /system/health (Blazor home escape hatch; Index.razor also lives here), // /system/health (Blazor home escape hatch; Index.razor also lives here),
// /channels/{id} edit, /channels/numbers, /media/* (collections etc.), // /media/sources/* (source add/edit has no SPA UI yet — ersatztv#202).
// /ffmpeg, /watermarks, /blocks, /decos, /templates, /deco-templates,
// schedule/playout detail editors, /system/logs, /system/troubleshooting.
// //
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on // 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 // 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. // 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 public static class LegacyUiRedirects
{ {
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single // 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/playout"] = "/app/settings/playout",
["/settings/scanner"] = "/app/settings/scanner", ["/settings/scanner"] = "/app/settings/scanner",
["/settings/ui"] = "/app/settings/general", ["/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) public static bool TryGetRedirect(PathString path, out string target)
{ {
target = string.Empty; target = string.Empty;
@@ -63,12 +184,123 @@ public static class LegacyUiRedirects
value = trimmed; value = trimmed;
} }
// Tier 1 — exact map (O(1); covers real traffic).
if (Map.TryGetValue(value, out string mapped)) if (Map.TryGetValue(value, out string mapped))
{ {
target = mapped; target = mapped;
return true; 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; 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;
// Exposed for the guard-invariant meta-test (InternalsVisibleTo ErsatzTV.Tests):
// the source-side template of every Tier-2 rule, so the test can assert no
// template — not just no Tier-1 Map key — begins with a forbidden prefix.
internal static IEnumerable<string> PatternTemplates => Patterns.Select(rule => rule.Template);
private sealed class PatternRule
{
public PatternRule(string template, string target)
{
Template = template;
Segments = template.Split('/', StringSplitOptions.RemoveEmptyEntries);
Target = target;
}
// The raw legacy route template, e.g. "/channels/{id}" (source side).
public string Template { get; }
// 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) // browser caching would make rollback painful. UsePathBase (ETV_BASE_URL)
// only rewrites the request side (Request.Path/PathBase); it never touches // only rewrites the request side (Request.Path/PathBase); it never touches
// redirect Location headers, so the PathBase prefix must be re-applied here. // 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) => blazor.Use(async (context, next) =>
{ {
if (HttpMethods.IsGet(context.Request.Method) || if (HttpMethods.IsGet(context.Request.Method) ||
@@ -751,7 +754,7 @@ public class Startup
if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target)) if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target))
{ {
context.Response.Redirect( context.Response.Redirect(
context.Request.PathBase + target + context.Request.QueryString); context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target, context.Request.QueryString));
return; return;
} }
} }
+94 -56
View File
@@ -19,8 +19,12 @@ Sources of truth checked when compiling this table: `ErsatzTV/LegacyUiRedirects.
## Section 1 — REDIRECTED ## Section 1 — REDIRECTED
(`ErsatzTV/LegacyUiRedirects.cs`'s `Map`, verified current as of this doc — 13 entries, unchanged `ErsatzTV/LegacyUiRedirects.cs` now matches in two tiers (ersatztv#204): the exact-path `Map`
since ersatztv#91 phase (a)/PR #148): (**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 | | 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 all (there are none currently — every Blazor `Settings/*.razor` page has both an SPA screen and a
redirect). 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 ## Section 2 — SPA-READY, not yet redirected
> **Mutation-depth verification (2026-07-09, #203 sweep)**: "SPA-READY" previously meant only > **Mutation-depth verification (2026-07-09, #203 sweep)**: "SPA-READY" previously meant only
@@ -72,59 +126,18 @@ redirect).
> >
> 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. > 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.
Confirmed as of this doc: the SPA screen exists (verified against `web/src/App.tsx`'s route table As of ersatztv#204, every route the mutation-depth sweep marked PARITY-OK received a redirect entry
and `web/src/screens/`) but `LegacyUiRedirects.cs` has **no entry** for the Blazor route yet. (an exact `Map` entry or a Tier-2 `PatternRule`) and moved to **Section 1** — EXCEPT the 14
Scheduling-parity work (#144/#162, DONE 2026-07-07, PRs #170#175/#179) built the SPA screens for `/media/sources/*` routes. Those became **SPA-READY as of #202 (DONE 2026-07-11)**: new screens under
blocks/templates/decos/deco-templates/playout editors, #145 (PR #182, merged to main) built the `/app/libraries/*` (`LibrariesScreen` hub + `LocalLibraryEditScreen`, `PlexSourceScreen`,
troubleshooting/YAML-validator screens, and #141 (PR #183, merged to main) built the media detail `RemoteSourceScreen`, `RemoteConnectionEditScreen`, `RemoteLibrariesEditScreen`,
pages + image folder browser (`MediaDetailScreen.tsx`'s `MovieDetailScreen`/`ShowDetailScreen`/ `PathReplacementsEditScreen`) over a new REST write API. They are listed below; their redirect entries
`SeasonDetailScreen`/`ArtistDetailScreen`, `ImageBrowserScreen.tsx`, both dispatched via `App.tsx`'s are **deferred to the #91b removal PR** (matching the pre-removal spot-check convention). Once those
`MediaRouteScreen` sub-route wrapper, same pattern as `PlayoutsRouteScreen`) — none of these have redirects land, this section is empty.
been added to the redirect map yet.
| Blazor route | Blazor file | SPA route | Notes | | 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 | | `/media/sources/local` | `LocalLibraries.razor` | `/app/libraries` | local-library rows on the hub (list, scan, gear → editor); **#202** |
| `/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 |
| `/media/sources/local` | `LocalLibraries.razor` | `/app/libraries` | local-library rows on the hub (list, scan, gear → editor); **#202**, moved here from Section 3 2026-07-11 |
| `/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/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/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` | `PlexMediaSources.razor` | `/app/libraries/plex` | `PlexSourceScreen`: OAuth pin-flow sign-in/fix-credentials/sign-out, server table, per-server Refresh; **#202** |
@@ -139,10 +152,6 @@ been added to the redirect map yet.
| `/media/sources/emby/{Id:int}/libraries` | `EmbyLibrariesEditor.razor` | `/app/libraries/emby/{id}/sync` | `RemoteLibrariesEditScreen` (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** | | `/media/sources/emby/{Id:int}/paths` | `EmbyPathReplacementsEditor.razor` | `/app/libraries/emby/{id}/path-replacements` | `PathReplacementsEditScreen` (family=emby); **#202** |
Redirect entries for all 14 `/media/sources/*` routes above are **deferred to the #91b removal
PR**, matching every other row in this section (per this table's own convention: SPA-READY rows
get a redirect only once spot-checked, right before Blazor removal).
## Section 3 — BLAZOR-ONLY (blocking issues) ## Section 3 — BLAZOR-ONLY (blocking issues)
### Playlist variant management — API gap #153 RESOLVED ### Playlist variant management — API gap #153 RESOLVED
@@ -258,6 +267,35 @@ intentional exit ramp until phase (b) removes Blazor entirely.
|---|---|---| |---|---|---|
| `/system/health` | `Index.razor` | Blazor's own home page; reachable via Settings → System "Classic UI" link. | | `/system/health` | `Index.razor` | Blazor's own home page; reachable via Settings → System "Classic UI" link. |
## Section 5 — Removal execution runbook (#91 phase b, Step 2/3)
The removal PR is **gated** — it starts only after these clear: ~~#202 (media-source write API + SPA)~~
**DONE 2026-07-11**, #235 F9 (deep-scan + external-collections-scan API — no API today, so `Libraries.razor`
can't be deleted yet), and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#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.) When those close, the removal-PR routine
executes, in order:
1. **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. **Delete Blazor** per #91's Step 2 recon (comment 2026-07-07): `ErsatzTV/Pages/`, `Shared/`, `App.razor`,
Blazor-only `ViewModels/`, `_Host.cshtml`; strip `AddServerSideBlazor`/`MapBlazorHub`/circuits + MudBlazor +
jQuery/jQueryUI/Sortable and prune their `Directory.Packages.props` entries. **Surgical, not wholesale**, on
the Blazor `MapWhen` branch (`Startup.cs:736-795`): it co-hosts `MapControllers()` + `/docs` (Scalar) + the
legacy-redirect middleware, all of which must survive. Replace `MapFallbackToPage("/_Host")` with the #204
design's **catch-all 302→`/app`** (any non-API/artwork/docs path that reaches the end of the former Blazor
branch) so no legacy path hard-404s once `_Host` is gone.
3. **Auth posture is already signed off** — no separate auth step beyond deleting the Blazor-attached OIDC
pieces (`AuthorizeFolder("/")` via `AddRazorPages`, `blazor.UseAuthentication/UseAuthorization`). **Keep** the
OIDC/JWT/API-key service wiring (independent gates: `ConditionalIptvAuthorizeFilter` on `/iptv/*`,
`ApiKeyAuthorizationFilter` on mutating `/api/*`). Full analysis + must-not-break list:
`docs/decisions.md` 2026-07-11 "Blazor removal auth posture" (#206). Real SPA/API auth = **#197**.
4. **Verify + docs** per #91's Step 3: full build + all test projects (incl. Architecture) + web
test/lint/build; a **live-E2E redirect sweep** (every legacy route 302s; `/app/*`, `/iptv/*`, `/artwork/*`
unaffected); Docker image build. Flip every row in this doc to REDIRECTED; update CLAUDE.md architecture +
`docs/contributing.md` Blazor/MudBlazor sections + the Obsidian ErsatzTV doc.
## Cross-reference: `docs/handoffs/chicorytv-issue-queue.md` ## 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 Keep this table and that handoff doc in sync at a high level — this table is the detailed route
+104
View File
@@ -545,3 +545,107 @@ App re-pushes the pre-pop URL and the wrapper never sees the rejected path. This
`libraries` route only (gated on `activeRoute === 'libraries'`) so unguarded sub-path routes `libraries` route only (gated on `activeRoute === 'libraries'`) so unguarded sub-path routes
(Playouts, Media) stay byte-identical. See `spa-conventions.md` §2/§8 for the updated exemplar (Playouts, Media) stay byte-identical. See `spa-conventions.md` §2/§8 for the updated exemplar
list and the resolved caveat text. list and the resolved caveat text.
## 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.
## 2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)
Sign-off for the #91 phase (b) removal-gate item #206 ("deleting the last challenged Blazor page leaves
only the open SPA"). The actual authorization wiring in `ErsatzTV/Startup.cs` + `ErsatzTV/Pages` was
enumerated in code (not assumed) before clearing the gate.
**What is gated today**
- **OIDC** (`OidcHelper.IsEnabled` — active only when `Authority`/`ClientId`/`ClientSecret` are configured):
`AddAuthentication` (cookie default, `oidc` challenge) + `AddAuthorization` `DefaultPolicy =
RequireAuthenticatedUser` + **`AddRazorPages(… AuthorizeFolder("/"))`** (Startup.cs:379-385) +
`blazor.UseAuthentication()/UseAuthorization()` inside the Blazor `MapWhen` branch (Startup.cs:764-770).
`AuthorizeFolder("/")` gates **Razor Pages only**, and the sole user-facing Razor Page is
`Pages/_Host.cshtml` — the Blazor Server host (the other `.cshtml`, `Shared/_Favicons.cshtml`, is a
cosmetic partial). **So the OIDC challenge protects exactly the Blazor UI and nothing else.**
- **`/app` (SPA)** is served by its own `MapWhen(path=/app)` static-file branch (Startup.cs:701-714) with
**no authentication/authorization middleware** — open since phase (a) (`/``/app`, PR #148).
- **`/api/*` controllers** carry no `[Authorize]` (verified: zero attributes in `Controllers/`); the
Razor-Pages `AuthorizeFolder`/`DefaultPolicy` never reach them. Their only optional gate is the
per-endpoint `ApiKeyAuthorizationFilter` (API-key on mutating JSON endpoints), independent of OIDC/Blazor.
- **`/iptv/*`** is gated by `ConditionalIptvAuthorizeFilter` (JWT `JwtOnlyScheme`, active only when
`JwtHelper.IsEnabled`) in its own `MapWhen` branch (Startup.cs:797-803) — independent of Blazor.
**Posture after Blazor removal.** Removing `Pages/_Host.cshtml`, `AddRazorPages`/`AuthorizeFolder("/")`,
`blazor.UseAuthentication/UseAuthorization`, `MapBlazorHub`, and `MapFallbackToPage("/_Host")` deletes the
OIDC challenge's **only attachment point** — no user-facing surface remains challenged. **No capability is
lost:** every Blazor-served capability already has an open SPA equivalent (the #91 parity effort), and the
SPA was already the unauthenticated path since phase (a), so removal exposes nothing a user could not already
reach via `/app`.
**The one honest caveat (not a regression introduced by removal):** an OIDC-configured operator's *Blazor*
admin UI sits behind a login today; after removal there is no login-gated admin UI at all (the SPA admin UI
is open). That exposure delta already happened at **phase (a)** (the open SPA became the default admin
surface); removal only deletes the now-redundant challenged duplicate. Designing real SPA/API authentication
is deliberately deferred to **#197** (cold API security review — a HARD GATE before any remote exposure).
**Removal-PR must-not-break (independent gates that survive):** `ConditionalIptvAuthorizeFilter` (`/iptv/*`
JWT), `ApiKeyAuthorizationFilter` (mutating `/api/*`), and `JwtHelper` access_token query support. **Leave
the OIDC service registrations in place** (conditional on config, inert once no Razor Page consumes them) —
ripping OIDC out is a #197 decision, not a removal-PR one. The removal PR removes only the Blazor-attached
pieces above; `MapControllers()` + `/docs` (Scalar), currently co-hosted in the Blazor `MapWhen` branch, must
survive the surgical reduction.
## 2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205)
Removal-gate item #205: the removal PR deletes both the Blazor reference implementation and the
`/system/health` escape hatch, so a post-deletion parity gap would otherwise be an archaeology exercise
(guessing which release tag still matches `main` minus Blazor). Decision + procedure, to run as the **first
action of the Step 2 deletion PR merge** (not before — `main` moves until then):
1. On the `main` commit **immediately preceding** the removal merge (the last commit that still contains
`ErsatzTV/Pages/**`), cut an annotated tag and push it:
`git tag -a blazor-final -m "Last commit with the legacy Blazor Server UI (pre-#91-phase-b removal)"`
then `git push origin blazor-final`. The tag name is **`blazor-final`** (not `v*`) so it does **not**
trigger the `v*` prod-release build in `.gitea/workflows/docker-build.yml`.
2. **Restore path** (if a gap surfaces post-removal): `git checkout blazor-final` → `docker build -f
docker/Dockerfile -t ersatztv:blazor-final .` → pin the **test** container to that image while the gap is
fixed forward on `main`. Alternatively `git revert` the single deletion merge commit (keep the deletion as
one squash/merge commit specifically to make this a one-liner).
3. Document the tag + restore path in the removal PR body; update this entry with the tag's commit sha when
cut.
Not cut this session — `main` still carries Blazor and will advance before the removal PR.
+7
View File
@@ -206,3 +206,10 @@ HARD CONSTRAINTS:
guard; no discriminator). This is the SCOPED placeholder; true content-aware stable child identity is #253 guard; no discriminator). This is the SCOPED placeholder; true content-aware stable child identity is #253
(a moved fill-group item still inherits the state of whatever held its new slot). Anyone doing #253 builds on (a moved fill-group item still inherits the state of whatever held its new slot). Anyone doing #253 builds on
/ replaces this positional reconcile in `ReplaceProgramScheduleItemsHandler`. / replaces this positional reconcile in `ReplaceProgramScheduleItemsHandler`.
- **A docs PR that lists gates/cross-refs can go STALE mid-flight** (2026-07-11, #205/#206 PR #261): while the
PR sat in review a *listed* gate (#204) merged. The PR FF-rejected → rebased; the `decisions.md` append
conflict was trivial, but the real trap was the **prose** — the new removal runbook still named #204 an open
removal gate. After rebasing over a parallel merge, re-read your prose cross-references (gate lists, "blocked
on #N", status tables), not just resolve the conflict markers — git fast-forwards stale *facts* silently. The
hand-written-docs sibling of the "regenerate generated artifacts after merging main" lesson. Also: docs-only
PRs still trip main's `Build & test` branch protection, so a rebase = a fresh full-CI cycle before merge.