Files
ersatztv/ErsatzTV/LegacyUiRedirects.cs
T
timothyandClaude Opus 4.8 8a2238b62e
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(ui): pattern-based legacy→SPA redirect matcher for parameterized routes (#204)
Extend LegacyUiRedirects from an exact-match dictionary to a two-tier matcher:
Tier 1 keeps the exact Map (now 52 entries incl. the ?kind= browse roots),
Tier 2 adds 36 ordered segment-template PatternRules for id-carrying routes.
{id} is a strict positive integer (non-int/0/neg/overflow falls through), which
also makes the rule set collision-free by construction. New AppendQueryString
helper merges the incoming query into ?kind= targets with '&' (kills the
double-'?' bug); one-line Startup change keeps the redirect GET/HEAD-only 302
before UseRouting.

Completes phase-(a) Step 1 for every PARITY-OK route (#91 phase b); the
catch-all fallback replacing MapFallbackToPage stays with the removal PR.
/media/sources/* (#202) and /system/health remain deliberately un-redirected.

fixes #204

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:39:56 +02:00

298 lines
13 KiB
C#

using System.Globalization;
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),
// /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
// trailing slash on the request is normalized away before lookup.
public static readonly IReadOnlyDictionary<string, string> Map =
new Dictionary<string, string>(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",
// (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;
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;
}
// Tier 1 — exact map (O(1); covers real traffic).
if (Map.TryGetValue(value, out string mapped))
{
target = mapped;
return true;
}
// Tier 2 — ordered segment-template rules. Split with
// StringSplitOptions.None (NOT RemoveEmptyEntries) and reject empty
// segments so "/channels//5" cannot match "/channels/{id}".
if (value.Length == 0 || value[0] != '/')
{
return false;
}
string[] segments = value[1..].Split('/', StringSplitOptions.None);
foreach (string segment in segments)
{
if (segment.Length == 0)
{
return false;
}
}
foreach (PatternRule rule in Patterns)
{
if (rule.Segments.Length != segments.Length)
{
continue;
}
var matched = true;
string capturedId = string.Empty;
for (var i = 0; i < rule.Segments.Length; i++)
{
string template = rule.Segments[i];
string segment = segments[i];
if (template == "{id}")
{
if (!IsPositiveInteger(segment))
{
matched = false;
break;
}
capturedId = segment;
}
else if (template == "{any}")
{
// any non-empty segment (already guaranteed non-empty above)
}
else if (!string.Equals(template, segment, StringComparison.OrdinalIgnoreCase))
{
matched = false;
break;
}
}
if (matched)
{
target = capturedId.Length > 0
? rule.Target.Replace("{id}", capturedId)
: rule.Target;
return true;
}
}
return false;
}
// Appends the incoming request query string to a redirect target that may
// already carry its own query (e.g. "/app/media?kind=movies"). Merges with
// '&' instead of producing a malformed double '?'. Targets WITHOUT a '?'
// behave byte-for-byte as before (verbatim append). A duplicated key after a
// merge (e.g. incoming "?kind=shows" onto "?kind=movies") is first-wins in
// the SPA (URLSearchParams.get returns the first value) — acceptable.
public static string AppendQueryString(string target, QueryString query)
{
if (!query.HasValue || query.Value is null || query.Value.Length <= 1)
{
return target;
}
return target.Contains('?')
? target + "&" + query.Value[1..]
: target + query.Value;
}
// A {id} segment must be a positive integer. NumberStyles.None rejects signs,
// whitespace and separators; TryParse failure also rejects overflow garbage
// like "999999999999". The raw matched text (e.g. "007") is substituted — the
// SPA's parseInt handles leading zeros, so do not re-format.
private static bool IsPositiveInteger(string segment) =>
int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int id) && id > 0;
private sealed class PatternRule
{
public PatternRule(string template, string target)
{
Segments = template.Split('/', StringSplitOptions.RemoveEmptyEntries);
Target = target;
}
// e.g. ["channels", "{id}"]
public string[] Segments { get; }
// e.g. "/app/edit-channel/{id}"
public string Target { get; }
}
}