diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 7d5fac7f3..65cb65f58 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -274,8 +274,8 @@ jobs: done echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1 } - if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "&1 | tail -n 40 || true exit 1 diff --git a/ErsatzTV.Tests/LegacyUiRedirectsTests.cs b/ErsatzTV.Tests/LegacyUiRedirectsTests.cs new file mode 100644 index 000000000..bf8c96ecf --- /dev/null +++ b/ErsatzTV.Tests/LegacyUiRedirectsTests.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests; + +[TestFixture] +public class LegacyUiRedirectsTests +{ + private static readonly string StartupSource = File.ReadAllText(FindStartupPath()); + + [Test] + public void Every_Mapping_Should_Resolve() + { + foreach ((string from, string to) in LegacyUiRedirects.Map) + { + LegacyUiRedirects.TryGetRedirect(new PathString(from), out string target).ShouldBeTrue(); + target.ShouldBe(to); + } + } + + [TestCase("/", "/app")] + [TestCase("/channels", "/app/channels")] + [TestCase("/channels/add", "/app/new-channel")] + [TestCase("/schedules", "/app/schedules")] + [TestCase("/playouts", "/app/playouts")] + [TestCase("/media/libraries", "/app/libraries")] + [TestCase("/settings/ffmpeg", "/app/settings/streaming")] + [TestCase("/settings/hdhr", "/app/settings/system")] + [TestCase("/settings/logging", "/app/settings/logging")] + [TestCase("/settings/playout", "/app/settings/playout")] + [TestCase("/settings/scanner", "/app/settings/scanner")] + [TestCase("/settings/ui", "/app/settings/general")] + [TestCase("/settings/xmltv", "/app/settings/xmltv")] + public void Known_Route_Should_Redirect(string path, string expected) + { + LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue(); + target.ShouldBe(expected); + } + + [TestCase("/channels/", "/app/channels")] + [TestCase("/schedules/", "/app/schedules")] + [TestCase("/settings/ffmpeg/", "/app/settings/streaming")] + public void Trailing_Slash_Should_Match(string path, string expected) + { + LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue(); + target.ShouldBe(expected); + } + + [Test] + public void Lookup_Should_Be_Case_Insensitive() + { + LegacyUiRedirects.TryGetRedirect(new PathString("/Channels"), out string target).ShouldBeTrue(); + target.ShouldBe("/app/channels"); + } + + [TestCase("/channels/5")] // channel edit (Blazor-only) + [TestCase("/channels/numbers")] // Blazor-only + [TestCase("/system/health")] // Blazor home escape hatch + [TestCase("/media/collections")] // Blazor-only media page + [TestCase("/ffmpeg")] // Blazor-only + [TestCase("/watermarks")] // Blazor-only + [TestCase("/app")] // already the SPA + [TestCase("/app/channels")] // already the SPA + [TestCase("/iptv/channels.m3u")] // IPTV surface + [TestCase("/api/health")] // API surface + [TestCase("")] // empty + [TestCase("//")] // all-slash path must not collapse to root "/" + [TestCase("/channels//")] // double trailing slash is not normalized to a match + public void Non_Migrated_Route_Should_Not_Redirect(string path) + { + LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse(); + target.ShouldBe(string.Empty); + } + + [Test] + public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing() + { + // The redirect middleware must be wired inside the blazor branch and run + // before UseRouting so migrated routes never reach the Blazor fallback. + int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal); + redirectIndex.ShouldBeGreaterThan(-1); + + // The Blazor branch's UseRouting call that follows the redirect middleware. + int routingIndex = StartupSource.IndexOf("blazor.UseRouting()", StringComparison.Ordinal); + routingIndex.ShouldBeGreaterThan(-1); + + redirectIndex.ShouldBeLessThan(routingIndex); + + // 302 (temporary), not a permanent redirect. + StartupSource.ShouldContain("context.Request.PathBase + target"); + StartupSource.ShouldNotContain("RedirectPermanent(target"); + } + + private static string FindStartupPath() + { + DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory); + + while (directory is not null) + { + string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs"); + } +} diff --git a/ErsatzTV/LegacyUiRedirects.cs b/ErsatzTV/LegacyUiRedirects.cs new file mode 100644 index 000000000..5b692673c --- /dev/null +++ b/ErsatzTV/LegacyUiRedirects.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Http; + +namespace ErsatzTV; + +// Phase (a) of the Blazor -> ChicoryTV SPA cutover (ersatztv#91). +// +// The React SPA (served under /app) is now the default UI: the legacy Blazor +// routes below 302-redirect to their SPA equivalents. Only routes that already +// have SPA parity are listed here. Blazor pages WITHOUT a SPA equivalent are +// deliberately left reachable (no redirect) so their functionality stays +// available while the SPA catches up: +// /system/health (Blazor home escape hatch; Index.razor also lives here), +// /channels/{id} edit, /channels/numbers, /media/* (collections etc.), +// /ffmpeg, /watermarks, /blocks, /decos, /templates, /deco-templates, +// schedule/playout detail editors, /system/logs, /system/troubleshooting. +// +// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on +// full SPA parity for every route in this map. Until then this map is the +// single source of truth for "what has migrated" and is expected to grow. +public static class LegacyUiRedirects +{ + // Blazor route -> SPA route. EXACT paths only (no prefix matching); a single + // trailing slash on the request is normalized away before lookup. + public static readonly IReadOnlyDictionary Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["/"] = "/app", + ["/channels"] = "/app/channels", + ["/channels/add"] = "/app/new-channel", + ["/schedules"] = "/app/schedules", + ["/playouts"] = "/app/playouts", + ["/media/libraries"] = "/app/libraries", + ["/settings/ffmpeg"] = "/app/settings/streaming", + ["/settings/hdhr"] = "/app/settings/system", + ["/settings/logging"] = "/app/settings/logging", + ["/settings/playout"] = "/app/settings/playout", + ["/settings/scanner"] = "/app/settings/scanner", + ["/settings/ui"] = "/app/settings/general", + ["/settings/xmltv"] = "/app/settings/xmltv" + }; + + public static bool TryGetRedirect(PathString path, out string target) + { + target = string.Empty; + + string value = path.Value; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + // Normalize a single trailing slash so "/channels/" matches "/channels" + // (but keep root "/" intact). Guard against a path of all slashes (e.g. + // "//") collapsing down to "/" and falsely matching the root entry. + if (value.Length > 1 && value.EndsWith('/')) + { + string trimmed = value[..^1]; + if (trimmed == "/") + { + return false; + } + + value = trimmed; + } + + if (Map.TryGetValue(value, out string mapped)) + { + target = mapped; + return true; + } + + return false; + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 95539c626..a938621fb 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -726,6 +726,28 @@ public class Startup ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path), blazor => { + // ersatztv#91 phase (a): make the ChicoryTV SPA the default UI by + // redirecting migrated legacy Blazor routes to their /app equivalents. + // 302 (not 301): this map grows as pages migrate, and permanent-redirect + // browser caching would make rollback painful. UsePathBase (ETV_BASE_URL) + // only rewrites the request side (Request.Path/PathBase); it never touches + // redirect Location headers, so the PathBase prefix must be re-applied here. + blazor.Use(async (context, next) => + { + if (HttpMethods.IsGet(context.Request.Method) || + HttpMethods.IsHead(context.Request.Method)) + { + if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target)) + { + context.Response.Redirect( + context.Request.PathBase + target + context.Request.QueryString); + return; + } + } + + await next(context); + }); + blazor.UseRouting(); if (OidcHelper.IsEnabled)