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
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:
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user