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 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", // (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 Patterns = new List { // (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; // 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 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; } } }