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:
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ErsatzTV;
|
||||
@@ -10,13 +11,25 @@ namespace ErsatzTV;
|
||||
// deliberately left reachable (no redirect) so their functionality stays
|
||||
// available while the SPA catches up:
|
||||
// /system/health (Blazor home escape hatch; Index.razor also lives here),
|
||||
// /channels/{id} edit, /channels/numbers, /media/* (collections etc.),
|
||||
// /ffmpeg, /watermarks, /blocks, /decos, /templates, /deco-templates,
|
||||
// schedule/playout detail editors, /system/logs, /system/troubleshooting.
|
||||
// /media/sources/* (source add/edit has no SPA UI yet — ersatztv#202).
|
||||
//
|
||||
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on
|
||||
// full SPA parity for every route in this map. Until then this map is the
|
||||
// single source of truth for "what has migrated" and is expected to grow.
|
||||
//
|
||||
// Matching is two-tier (see TryGetRedirect):
|
||||
// Tier 1 = the exact-path dictionary Map below (parameterless routes, plus
|
||||
// the (E-base) browse roots whose *targets* carry "?kind=...").
|
||||
// Tier 2 = the ordered Patterns list of segment-template rules, consulted
|
||||
// only when Tier 1 misses (parameterized routes with one variable
|
||||
// segment: an {id} or an {any} kind).
|
||||
//
|
||||
// GUARD INVARIANT (see also the collision-freedom note above Patterns): no
|
||||
// Tier-1 key and no Tier-2 template may begin with /api, /artwork, /docs,
|
||||
// /openapi, /iptv, /app, or /media/sources. Rules are always full, specific
|
||||
// templates — never prefix wildcards (a bare "/media/{any}" style rule is
|
||||
// forbidden). The blazor branch does not prefix-guard /api|/artwork|/docs|
|
||||
// /openapi, so the matcher's specificity is part of their protection.
|
||||
public static class LegacyUiRedirects
|
||||
{
|
||||
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
|
||||
@@ -36,9 +49,117 @@ public static class LegacyUiRedirects
|
||||
["/settings/playout"] = "/app/settings/playout",
|
||||
["/settings/scanner"] = "/app/settings/scanner",
|
||||
["/settings/ui"] = "/app/settings/general",
|
||||
["/settings/xmltv"] = "/app/settings/xmltv"
|
||||
["/settings/xmltv"] = "/app/settings/xmltv",
|
||||
|
||||
// (A) parameterless routes migrated in ersatztv#204
|
||||
["/channels/numbers"] = "/app/channels",
|
||||
["/search"] = "/app/search",
|
||||
["/system/logs"] = "/app/logs",
|
||||
["/system/troubleshooting"] = "/app/troubleshooting",
|
||||
["/system/troubleshooting/block-playout"] = "/app/troubleshooting/blocks",
|
||||
["/system/troubleshooting/sequential-schedule"] = "/app/troubleshooting/yaml",
|
||||
["/system/troubleshooting/playback"] = "/app/troubleshooting/playback",
|
||||
["/blocks"] = "/app/blocks",
|
||||
["/templates"] = "/app/templates",
|
||||
["/decos"] = "/app/decos",
|
||||
["/deco-templates"] = "/app/deco-templates",
|
||||
["/media/multi-collections"] = "/app/multi-collections",
|
||||
["/media/rerun-collections"] = "/app/rerun-collections",
|
||||
["/media/filler/presets"] = "/app/filler-presets",
|
||||
["/media/collections"] = "/app/collections",
|
||||
["/media/trash"] = "/app/trash",
|
||||
["/media/playlists"] = "/app/playlists",
|
||||
["/media/trakt/lists"] = "/app/trakt-lists",
|
||||
["/ffmpeg"] = "/app/ffmpeg-profiles",
|
||||
["/watermarks"] = "/app/watermarks",
|
||||
["/media/browser/images"] = "/app/media/images/browser",
|
||||
["/schedules/add"] = "/app/schedules",
|
||||
|
||||
// (B) ".../add" sub-actions (ersatztv#204)
|
||||
["/playouts/add"] = "/app/playouts",
|
||||
["/media/multi-collections/add"] = "/app/multi-collections",
|
||||
["/media/rerun-collections/add"] = "/app/rerun-collections",
|
||||
["/media/filler/presets/add"] = "/app/filler-presets",
|
||||
["/media/collections/add"] = "/app/collections",
|
||||
["/ffmpeg/add"] = "/app/ffmpeg-profiles",
|
||||
["/watermarks/add"] = "/app/watermarks",
|
||||
|
||||
// (E-base) browse roots — targets carry "?kind=" (ersatztv#204)
|
||||
["/media/movies"] = "/app/media?kind=movies",
|
||||
["/media/tv/shows"] = "/app/media?kind=shows",
|
||||
["/media/tv/seasons"] = "/app/media?kind=seasons",
|
||||
["/media/tv/episodes"] = "/app/media?kind=episodes",
|
||||
["/media/music/artists"] = "/app/media?kind=artists",
|
||||
["/media/music/videos"] = "/app/media?kind=music-videos",
|
||||
["/media/music/songs"] = "/app/media?kind=songs",
|
||||
["/media/other/videos"] = "/app/media?kind=other-videos",
|
||||
["/media/remote/streams"] = "/app/media?kind=remote-streams",
|
||||
["/media/images"] = "/app/media?kind=images"
|
||||
};
|
||||
|
||||
// Tier-2 segment-template rules, consulted only after the exact Map misses.
|
||||
// Declaration order IS match order (first match wins) — keep it exactly as
|
||||
// grouped below: (C) -> (C2) -> (D) -> (E-page).
|
||||
//
|
||||
// The rule set is COLLISION-FREE BY CONSTRUCTION — exact-before-pattern plus
|
||||
// strict positive-integer {id} validation means no two tiers/rules can match
|
||||
// the same path:
|
||||
// - /channels/add, /channels/numbers, /schedules/add, /playouts/add,
|
||||
// /media/collections/add, /media/browser/images are Tier-1 exact hits
|
||||
// (checked first) and their trailing segments are non-numeric anyway, so
|
||||
// they could never match an {id} rule.
|
||||
// - /playouts/classic/{id} vs /playouts/{id}/templates: both 3 segments,
|
||||
// but "classic" fails {id} and "templates" != the {id}-position literal.
|
||||
// - /playouts/add/{any} vs /playouts/{id}/...: "add" fails {id} — disjoint.
|
||||
// - /media/movies/{id} (3 seg) vs /media/movies/page/{id} (4 seg): disjoint
|
||||
// by count; /media/movies/page (3 seg, "page" non-numeric) falls through.
|
||||
private static readonly IReadOnlyList<PatternRule> Patterns = new List<PatternRule>
|
||||
{
|
||||
// (C) id-substituting
|
||||
new("/channels/{id}", "/app/edit-channel/{id}"),
|
||||
new("/blocks/{id}", "/app/blocks/{id}"),
|
||||
new("/templates/{id}", "/app/templates/{id}"),
|
||||
new("/decos/{id}", "/app/decos/{id}"),
|
||||
new("/deco-templates/{id}", "/app/deco-templates/{id}"),
|
||||
new("/media/trakt/lists/{id}", "/app/trakt-lists/{id}"),
|
||||
new("/media/movies/{id}", "/app/media/movies/{id}"),
|
||||
new("/media/tv/shows/{id}", "/app/media/shows/{id}"),
|
||||
new("/media/tv/seasons/{id}", "/app/media/seasons/{id}"),
|
||||
new("/media/music/artists/{id}", "/app/media/artists/{id}"),
|
||||
|
||||
// (C2) id-substituting mid-path
|
||||
new("/playouts/{id}/alternate-schedules", "/app/playouts/{id}/alternate-schedules"),
|
||||
new("/playouts/{id}/templates", "/app/playouts/{id}/templates"),
|
||||
|
||||
// (D) id-dropping
|
||||
new("/schedules/{id}", "/app/schedules"),
|
||||
new("/schedules/{id}/items", "/app/schedules"),
|
||||
new("/playouts/classic/{id}", "/app/playouts"),
|
||||
new("/playouts/block/{id}", "/app/playouts"),
|
||||
new("/playouts/scripted/{id}", "/app/playouts"),
|
||||
new("/playouts/sequential/{id}", "/app/playouts"),
|
||||
new("/playouts/add/{any}", "/app/playouts"),
|
||||
new("/media/multi-collections/{id}/edit", "/app/multi-collections"),
|
||||
new("/media/rerun-collections/{id}/edit", "/app/rerun-collections"),
|
||||
new("/media/filler/presets/{id}/edit", "/app/filler-presets"),
|
||||
new("/media/collections/{id}/edit", "/app/collections"),
|
||||
new("/media/collections/{id}", "/app/collections"),
|
||||
new("/ffmpeg/{id}", "/app/ffmpeg-profiles"),
|
||||
new("/watermarks/{id}", "/app/watermarks"),
|
||||
|
||||
// (E-page) paging -> "?kind=" ({id} token reused for the page number, value dropped)
|
||||
new("/media/movies/page/{id}", "/app/media?kind=movies"),
|
||||
new("/media/tv/shows/page/{id}", "/app/media?kind=shows"),
|
||||
new("/media/tv/seasons/page/{id}", "/app/media?kind=seasons"),
|
||||
new("/media/tv/episodes/page/{id}", "/app/media?kind=episodes"),
|
||||
new("/media/music/artists/page/{id}", "/app/media?kind=artists"),
|
||||
new("/media/music/videos/page/{id}", "/app/media?kind=music-videos"),
|
||||
new("/media/music/songs/page/{id}", "/app/media?kind=songs"),
|
||||
new("/media/other/videos/page/{id}", "/app/media?kind=other-videos"),
|
||||
new("/media/remote/streams/page/{id}", "/app/media?kind=remote-streams"),
|
||||
new("/media/images/page/{id}", "/app/media?kind=images")
|
||||
};
|
||||
|
||||
public static bool TryGetRedirect(PathString path, out string target)
|
||||
{
|
||||
target = string.Empty;
|
||||
@@ -63,12 +184,123 @@ public static class LegacyUiRedirects
|
||||
value = trimmed;
|
||||
}
|
||||
|
||||
// Tier 1 — exact map (O(1); covers real traffic).
|
||||
if (Map.TryGetValue(value, out string mapped))
|
||||
{
|
||||
target = mapped;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tier 2 — ordered segment-template rules. Split with
|
||||
// StringSplitOptions.None (NOT RemoveEmptyEntries) and reject empty
|
||||
// segments so "/channels//5" cannot match "/channels/{id}".
|
||||
if (value.Length == 0 || value[0] != '/')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] segments = value[1..].Split('/', StringSplitOptions.None);
|
||||
foreach (string segment in segments)
|
||||
{
|
||||
if (segment.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PatternRule rule in Patterns)
|
||||
{
|
||||
if (rule.Segments.Length != segments.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var matched = true;
|
||||
string capturedId = string.Empty;
|
||||
for (var i = 0; i < rule.Segments.Length; i++)
|
||||
{
|
||||
string template = rule.Segments[i];
|
||||
string segment = segments[i];
|
||||
|
||||
if (template == "{id}")
|
||||
{
|
||||
if (!IsPositiveInteger(segment))
|
||||
{
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
|
||||
capturedId = segment;
|
||||
}
|
||||
else if (template == "{any}")
|
||||
{
|
||||
// any non-empty segment (already guaranteed non-empty above)
|
||||
}
|
||||
else if (!string.Equals(template, segment, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched)
|
||||
{
|
||||
target = capturedId.Length > 0
|
||||
? rule.Target.Replace("{id}", capturedId)
|
||||
: rule.Target;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Appends the incoming request query string to a redirect target that may
|
||||
// already carry its own query (e.g. "/app/media?kind=movies"). Merges with
|
||||
// '&' instead of producing a malformed double '?'. Targets WITHOUT a '?'
|
||||
// behave byte-for-byte as before (verbatim append). A duplicated key after a
|
||||
// merge (e.g. incoming "?kind=shows" onto "?kind=movies") is first-wins in
|
||||
// the SPA (URLSearchParams.get returns the first value) — acceptable.
|
||||
public static string AppendQueryString(string target, QueryString query)
|
||||
{
|
||||
if (!query.HasValue || query.Value is null || query.Value.Length <= 1)
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
return target.Contains('?')
|
||||
? target + "&" + query.Value[1..]
|
||||
: target + query.Value;
|
||||
}
|
||||
|
||||
// A {id} segment must be a positive integer. NumberStyles.None rejects signs,
|
||||
// whitespace and separators; TryParse failure also rejects overflow garbage
|
||||
// like "999999999999". The raw matched text (e.g. "007") is substituted — the
|
||||
// SPA's parseInt handles leading zeros, so do not re-format.
|
||||
private static bool IsPositiveInteger(string segment) =>
|
||||
int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int id) && id > 0;
|
||||
|
||||
// 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)
|
||||
// only rewrites the request side (Request.Path/PathBase); it never touches
|
||||
// redirect Location headers, so the PathBase prefix must be re-applied here.
|
||||
// AppendQueryString merges the incoming query into the target: a target that
|
||||
// already carries its own query (e.g. "/app/media?kind=movies") gets '&'-joined
|
||||
// instead of a malformed double '?'.
|
||||
blazor.Use(async (context, next) =>
|
||||
{
|
||||
if (HttpMethods.IsGet(context.Request.Method) ||
|
||||
@@ -751,7 +754,7 @@ public class Startup
|
||||
if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target))
|
||||
{
|
||||
context.Response.Redirect(
|
||||
context.Request.PathBase + target + context.Request.QueryString);
|
||||
context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target, context.Request.QueryString));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user