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

# Conflicts:
#	docs/blazor-route-parity.md
#	docs/decisions.md
This commit is contained in:
2026-07-11 17:31:04 +02:00
6 changed files with 571 additions and 68 deletions
+236 -4
View File
@@ -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
View File
@@ -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;
}
}